This commit is contained in:
Morten Olsen
2025-05-18 18:43:30 +02:00
commit db59fee6b1
26 changed files with 5001 additions and 0 deletions

48
.github/release-drafter-config.yml vendored Normal file
View File

@@ -0,0 +1,48 @@
name-template: "$RESOLVED_VERSION 🌈"
tag-template: "$RESOLVED_VERSION"
categories:
- title: "🚀 Features"
labels:
- "feature"
- "enhancement"
- title: "🐛 Bug Fixes"
labels:
- "fix"
- "bugfix"
- "bug"
- title: "🧰 Maintenance"
label: "chore"
change-template: "- $TITLE @$AUTHOR (#$NUMBER)"
change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks.
version-resolver:
major:
labels:
- "major"
minor:
labels:
- "minor"
patch:
labels:
- "patch"
default: patch
autolabeler:
- label: "chore"
files:
- "*.md"
branch:
- '/docs{0,1}\/.+/'
- label: "bug"
branch:
- '/fix\/.+/'
title:
- "/fix/i"
- label: "enhancement"
branch:
- '/feature\/.+/'
- '/feat\/.+/'
title:
- "/feat:.+/"
template: |
## Changes
$CHANGES

21
.github/workflows/auto-labeler.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
name: Auto Labeler
on:
pull_request:
types: [opened, reopened, synchronize]
permissions:
contents: read
jobs:
auto-labeler:
permissions:
contents: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@v6
with:
config-name: release-drafter-config.yml
disable-releaser: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

137
.github/workflows/main.yaml vendored Normal file
View File

@@ -0,0 +1,137 @@
name: Build and release
on:
push:
branches:
- main
pull_request:
types:
- opened
- synchronize
env:
environment: test
release_channel: latest
DO_NOT_TRACK: "1"
NODE_VERSION: "23.x"
NODE_REGISTRY: "https://registry.npmjs.org"
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
DOCKER_REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
permissions:
contents: read
packages: read
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "${{ env.NODE_VERSION }}"
registry-url: "${{ env.NODE_REGISTRY }}"
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 10.6.0
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v4
name: Setup pnpm cache
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Build
run: pnpm build
# - name: Run tests
# run: pnpm test
- uses: actions/upload-artifact@v4
with:
name: lib
retention-days: 5
path: |
dist
README.md
update-release-draft:
name: Update release drafter
if: github.ref == 'refs/heads/main'
permissions:
contents: write
pull-requests: write
needs: build
environment: release
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@v6
with:
config-name: release-drafter-config.yml
publish: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
release:
permissions:
contents: read
packages: write
attestations: write
id-token: write
pages: write
name: Release
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
needs: update-release-draft
environment: release
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "${{ env.NODE_VERSION }}"
registry-url: "${{ env.NODE_REGISTRY }}"
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 10.6.0
run_install: false
- name: Install dependencies
run: pnpm install
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- uses: actions/download-artifact@v4
with:
name: lib
path: ./
- run: |
git config user.name "Github Actions Bot"
git config user.email "<>"
node ./scripts/set-version.mjs $(git describe --tag --abbrev=0)
pnpm publish --no-git-checks --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/node_modules/
/dist/

480
README.md Normal file
View File

@@ -0,0 +1,480 @@
# http.md Documentation
**`http.md` is a powerful tool that transforms your markdown files into living, executable API documentation and testing suites. Write your HTTP requests directly within markdown, see their responses, and use templating to build dynamic examples and test flows.**
It allows developers to create API documentation that is always accurate and up-to-date because the documentation itself *is* the set of executable requests. This ensures that your examples work and your tests run directly from the documents you share.
## Key Features
* **Markdown-Native:** Define HTTP requests using familiar markdown code blocks.
* **Live Requests:** Execute requests and embed their responses directly into your documentation.
* **Templating:** Use Handlebars syntax to chain requests, extract data from responses, and use external inputs.
* **File Embedding:** Include and reuse requests from other markdown files.
* **Terminal & File Output:** View live previews in your terminal or build static markdown files for sharing or static site generation.
* **Watch Mode:** Automatically re-render documents on file changes for a fast development loop.
* **Flexible Configuration:** Control request execution, output formatting, and visibility.
## Use Cases
* **API Documentation:** Create clear, executable examples that users can trust.
* **Integration Testing:** Write simple integration test suites that verify API behavior.
* **Tutorials & Guides:** Build step-by-step guides where each HTTP interaction is shown with its real output.
* **Rapid Prototyping:** Quickly experiment with APIs and document your findings.
## Installation
Install `http.md` globally using npm:
```shell
npm i -g @morten-olsen/httpmd
```
## Getting Started
### Your First Request
`http.md` documents are written using an extended markdown format. To make an HTTP request, you define it within a `http` code block. To display the response from the most recent request, you use the `::response` directive.
Create a file named `example.md`:
````markdown
# My API Document
Let's make a POST request to httpbin.org.
```http
POST https://httpbin.org/post
Content-Type: application/json
{"greeting": "Hello, http.md!"}
```
And here is the response:
::response
````
### Rendering Documents
You have two primary ways to render your `http.md` file:
1. **Live Terminal Output (`dev`):**
For a development server that outputs to your terminal and watches for changes:
```shell
httpmd dev example.md
```
With watch mode:
```shell
httpmd dev --watch example.md
```
This command will process `example.md`, execute the HTTP requests, and print the resulting markdown (with responses filled in) to the terminal. With `--watch`, any changes to `example.md` will trigger a re-run.
2. **Building Static Files (`build`):**
To generate a new markdown file with the responses and templated values rendered:
```shell
httpmd build example.md output.md
```
With watch mode:
```shell
httpmd build --watch example.md output.md
```
This creates `output.md`, which is a static snapshot of `example.md` after all requests have been executed and templating applied. This file is suitable for version control, sharing, or integration with static site generators.
**Example Output (`output.md` or terminal output):**
````markdown
# My API Document
Let's make a POST request to httpbin.org.
```http
POST https://httpbin.org/post
Content-Type: application/json
{"greeting": "Hello, http.md!"}
```
And here is the response:
```
HTTP/200 OK
access-control-allow-credentials: true
access-control-allow-origin: *
connection: keep-alive
content-length: 559
content-type: application/json
date: Sun, 18 May 2025 17:17:15 GMT
server: gunicorn/19.9.0
{
"args": {},
"data": "{\"greeting\": \"Hello, http.md!\"}",
"files": {},
"form": {},
"headers": {
"Accept": "*/*",
"Accept-Encoding": "br, gzip, deflate",
"Accept-Language": "*",
"Content-Length": "31",
"Content-Type": "application/json",
"Host": "httpbin.org",
"Sec-Fetch-Mode": "cors",
"User-Agent": "node",
"X-Amzn-Trace-Id": "Root=1-682a161b-6f8d778138665a8f22ffbe94"
},
"json": {
"greeting": "Hello, http.md!"
},
"origin": "185.181.220.204",
"url": "https://httpbin.org/post"
}
```
````
*(Note: Actual headers and some response fields might vary.)*
## Core Concepts
### HTTP Request Blocks
HTTP requests are defined in fenced code blocks annotated with `http`. The syntax is similar to the raw HTTP format:
```
<METHOD> <URL>
<Header-Name>: <Header-Value>
...
<Blank Line>
<Request Body (optional)>
```
**Example:**
````markdown
```http
GET https://api.example.com/items
Accept: application/json
X-Custom-Header: MyValue
```
````
````markdown
```http
POST https://api.example.com/users
Content-Type: application/json
{"name": "John Doe", "email": "john.doe@example.com"}
```
````
All requests in a document are executed sequentially from top to bottom by default.
### The `::response` Directive
The `::response` directive is used to render the full HTTP response (status line, headers, and body) of an HTTP request.
* **Implicit Last:** If used without any arguments (i.e., `::response`), it renders the response of the most recently defined `http` block above it.
* **Explicit by ID:** You can render the response of a specific request by referencing its ID (see [Request IDs](https://www.google.com/search?q=%23request-ids)).
### Request IDs
You can assign a unique ID to an `http` request block. This allows you to:
1. Reference its specific response in a `::response` directive.
2. Access its request and response data in [Templating](https://www.google.com/search?q=%23templating-with-handlebars) via the `requests` and `responses` dictionaries.
To add an ID, include `id=yourUniqueId` in the `http` block's info string:
````markdown
# Document with Multiple Requests
First, create a resource:
```http id=createUser
POST https://httpbin.org/post
Content-Type: application/json
{"username": "alpha"}
```
Then, fetch a different resource:
```http id=getItem
GET https://httpbin.org/get?item=123
```
Response from creating the user:
::response{#createUser}
Response from getting the item:
::response{#getItem}
````
## Templating with Handlebars
`http.md` uses [Handlebars](https://handlebarsjs.com/) for templating, allowing you to create dynamic content within your markdown files. You can inject data from request responses, input variables, and other requests into your HTTP blocks or general markdown text.
Templating syntax uses double curly braces: `{{expression}}`.
### Available Variables for Templating
Within your markdown document, the following variables are available in the Handlebars context:
* **`request`** (Object): Details of the most recently processed HTTP request *before* it's sent.
* `request.method` (String): The HTTP method (e.g., "GET", "POST").
* `request.url` (String): The request URL.
* `request.headers` (Object): An object containing request headers.
* `request.body` (String): The raw request body.
* **`response`** (Object): Details of the most recently received HTTP response.
* `response.status` (Number): The HTTP status code (e.g., 200, 404).
* `response.statusText` (String): The HTTP status message (e.g., "OK", "Not Found").
* `response.headers` (Object): An object containing response headers.
* `response.body` (String/Object): The response body. If the `http` block had the `json` option and the response was valid JSON, this will be a parsed JSON object. Otherwise, it's a raw string.
* `response.rawBody` (String): The raw response body as a string, regardless of parsing.
* *(In case of network errors or non-HTTP errors, `status` and `body` might reflect error information.)*
* **`requests`** (Object): A dictionary mapping request IDs to their respective `request` objects (as defined above).
* Example: `{{requests.createUser.url}}`
* **`responses`** (Object): A dictionary mapping request IDs to their respective `response` objects (as defined above).
* Example: `{{responses.createUser.status}}`, `{{responses.createUser.body.id}}` (if `body` is a parsed JSON object).
* **`input`** (Object): A dictionary of variables passed to `http.md` via the command line using the `-i` or `--input` flag.
* Example: If you run `httpmd dev -i userId=123 -i apiKey=secret myfile.md`, you can use `{{input.userId}}` and `{{input.apiKey}}`.
### Templating Examples
**1. Using a value from a previous response in a new request:**
````markdown
```http id=createItem json
POST https://httpbin.org/post
Content-Type: application/json
{"name": "My New Item"}
```
The new item ID is: {{responses.createItem.body.json.name}}
Now, let's fetch the item using a (mocked) ID from the response:
```http id=fetchItem
GET https://httpbin.org/anything/{{responses.createItem.body.json.name}}
```
::response{#fetchItem}
````
*(Note: `httpbin.org/post` wraps the JSON sent in a "json" field in its response. If your API returns the ID directly at the root of the JSON body, you'd use `{{responses.createItem.body.id}}` assuming the `createItem` request had the `json` option.)*
**2. Displaying a status code in markdown text:**
````markdown
```http
GET https://httpbin.org/status/201
```
The request to `/status/201` completed with status code: **{{response.status}}**.
````
## Managing Documents
### Embedding Other Documents (`::md`)
You can embed other `.md` files into your current document using the `::md` directive. This is useful for breaking down large documentation into smaller, reusable parts, or for including a set of common requests.
The requests from the embedded document are processed, and their `request` and `response` objects become available in the `requests` and `responses` dictionaries of the parent document, keyed by their IDs (if any).
**Syntax:** `::md[./path/to/other-document.md]`
**Example:**
Assume `_shared_requests.md` contains:
````markdown
```http id=sharedGetRequest
GET https://httpbin.org/get
```
````
Then, in `main.md`:
````markdown
# Main Document
Let's include some shared requests:
::md[./_shared_requests.md]
The shared GET request returned: {{responses.sharedGetRequest.status}}
Now, a request specific to this document:
```http
POST https://httpbin.org/post
Content-Type: application/json
{"dataFromMain": "someValue", "sharedUrl": "{{requests.sharedGetRequest.url}}"}
```
::response
````
When `main.md` is processed, `_shared_requests.md` will be embedded, its `sharedGetRequest` will be executed, and its data will be available for templating.
## Advanced Usage
### Using Input Variables
You can pass external data into your `http.md` documents using the `-i` (or `--input`) CLI flag. This is useful for parameterizing requests with environment-specific values, user inputs, or sensitive data.
**CLI Command:**
```shell
httpmd build mydoc.md output.md -i baseUrl=https://api.production.example.com -i apiKey=YOUR_SECRET_KEY
```
**Markdown Usage (`mydoc.md`):**
````markdown
```http
GET {{input.baseUrl}}/users/1
Authorization: Bearer {{input.apiKey}}
```
::response
````
**Security Note:** For sensitive data like API keys, using input variables is highly recommended over hardcoding them in your markdown files. Avoid committing files with plaintext secrets; instead, provide them at runtime via the CLI.
### HTTP Block Configuration Options
You can configure the behavior of each `http` code block by adding options to its info string, separated by commas.
* `id={your-id}`: Assigns a unique ID to the request. This ID can be used to reference the request's response in the `::response` directive and in templating variables (`requests.your-id`, `responses.your-id`).
* Example: ` ```http id=getUser,json `
* `json`: If present, `http.md` will attempt to parse the **response body** as JSON. If successful, `response.body` (and `responses.id.body`) will be the parsed JavaScript object/array, making it easier to access its properties in templates (e.g., `{{response.body.fieldName}}`).
* Example: ` ```http json `
* `yaml`: If present, the **request body** written in YAML format within the code block will be automatically converted to JSON before the request is sent. This allows for writing complex request bodies in a more human-readable YAML syntax. You should still set the `Content-Type` header appropriately (e.g., to `application/json`) if the server expects JSON.
* Example:
````markdown
```http yaml
POST https://api.example.com/submit
Content-Type: application/json
# This is YAML
name: Example User
details:
age: 30
city: New York
```
````
* `disable`: If present, the HTTP request will **not** be executed. No actual network call will be made. The corresponding `response` variable will be undefined or empty, and `::response` will typically render a "Request disabled" message or similar.
* Example: ` ```http disable `
* `hidden`: If present, the `http` code block itself will **not be included** in the rendered output document. However, the request *is still made* (unless `disable` is also specified), and its response data can be used in templates or displayed with an explicit `::response{#id}` directive. This is useful for prerequisite requests (like authentication) whose details you don't want to clutter the main documentation.
* Example: ` ```http id=authRequest,hidden `
**Combined Example:**
````markdown
```http id=complexRequest,json,yaml,hidden
POST {{input.apiEndpoint}}/data
X-API-Key: {{input.apiKey}}
Content-Type: application/json
# Request body written in YAML, will be converted to JSON
# This entire block will be hidden in the output, but the request runs
# The response will be parsed as JSON
user:
id: 123
preferences:
theme: dark
```
````
### Directive Options
Directives can also have options, specified similarly.
#### `::response` Directive Options
* `id={id}` (or `#{id}` as a shorthand): Renders the output of a specific request identified by `{id}`.
* Example: `::response{#getUser}` or `::response{id=getUser}`
* `yaml`: Renders the (typically JSON) response body formatted as YAML. This is for display purposes.
* Example: `::response{yaml}`
* `truncate={chars}`: Truncates the displayed **response body** to the specified number of characters. Headers and status line are not affected.
* Example: `::response{truncate=100}`
**Combined Example for `::response`:**
`::response{#getUser,yaml,truncate=500}` - Displays the response for request `getUser`, formats its body as YAML, and truncates the body display to 500 characters.
#### `::md[{file}]` Directive Options
The `::md` directive embeds another markdown document.
* **File Path:** The first argument (required) is the path to the markdown file to embed.
* Example: `::md[./includes/authentication.md]`
* `hidden`: If present, the actual content (markdown) of the embedded document will not be rendered in the output. However, any `http` requests within the embedded document *are still processed*, and their `request` and `response` data become available in the parent document's templating context (via `requests.id` and `responses.id`). This is useful if you only want to execute the requests from an included file (e.g., a common setup sequence) and use their results, without displaying the embedded file's content.
* Example: `::md[./setup_requests.md]{hidden}`
## Command-Line Interface (CLI)
The `httpmd` tool provides the following commands:
### `httpmd dev <source_file.md>`
Processes the `<source_file.md>`, executes all HTTP requests, resolves templates, and prints the resulting markdown to the **terminal (stdout)**.
* **Purpose:** Useful for live development and quick previews.
* **Options:**
* `--watch`: Monitors the `<source_file.md>` (and any embedded files) for changes. On detection of a change, it automatically re-processes and re-renders the output to the terminal.
* `-i <key=value>`, `--input <key=value>`: Defines an input variable for templating (see [Using Input Variables](https://www.google.com/search?q=%23using-input-variables)). Can be specified multiple times for multiple variables.
**Example:**
```shell
httpmd dev api_tests.md --watch -i host=localhost:3000
```
### `httpmd build <source_file.md> <output_file.md>`
Processes the `<source_file.md>`, executes all HTTP requests, resolves templates, and saves the resulting markdown to `<output_file.md>`.
* **Purpose:** Generates a static, shareable markdown file with all dynamic content resolved. Ideal for version control, static site generation, or distributing documentation.
* **Options:**
* `--watch`: Monitors the `<source_file.md>` (and any embedded files) for changes. On detection of a change, it automatically re-processes and re-builds the `<output_file.md>`.
* `-i <key=value>`, `--input <key=value>`: Defines an input variable for templating.
**Example:**
```shell
httpmd build official_api_docs.md public/api_docs_v1.md -i version=v1.0
```

3
bin/cli.mjs Normal file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env node
import '../dist/cli/cli.js';

373
docs/README.md Normal file
View File

@@ -0,0 +1,373 @@
# http.md Documentation
**`http.md` is a powerful tool that transforms your markdown files into living, executable API documentation and testing suites. Write your HTTP requests directly within markdown, see their responses, and use templating to build dynamic examples and test flows.**
It allows developers to create API documentation that is always accurate and up-to-date because the documentation itself _is_ the set of executable requests. This ensures that your examples work and your tests run directly from the documents you share.
## Key Features
- **Markdown-Native:** Define HTTP requests using familiar markdown code blocks.
- **Live Requests:** Execute requests and embed their responses directly into your documentation.
- **Templating:** Use Handlebars syntax to chain requests, extract data from responses, and use external inputs.
- **File Embedding:** Include and reuse requests from other markdown files.
- **Terminal & File Output:** View live previews in your terminal or build static markdown files for sharing or static site generation.
- **Watch Mode:** Automatically re-render documents on file changes for a fast development loop.
- **Flexible Configuration:** Control request execution, output formatting, and visibility.
## Use Cases
- **API Documentation:** Create clear, executable examples that users can trust.
- **Integration Testing:** Write simple integration test suites that verify API behavior.
- **Tutorials & Guides:** Build step-by-step guides where each HTTP interaction is shown with its real output.
- **Rapid Prototyping:** Quickly experiment with APIs and document your findings.
## Installation
Install `http.md` globally using npm:
```shell
npm i -g @morten-olsen/httpmd
```
## Getting Started
### Your First Request
`http.md` documents are written using an extended markdown format. To make an HTTP request, you define it within a `http` code block. To display the response from the most recent request, you use the `::response` directive.
Create a file named `example.md`:
::raw-md[./examples/getting-started.md]
### Rendering Documents
You have two primary ways to render your `http.md` file:
1. **Live Terminal Output (`dev`):**
For a development server that outputs to your terminal and watches for changes:
```shell
httpmd dev example.md
```
With watch mode:
```shell
httpmd dev --watch example.md
```
This command will process `example.md`, execute the HTTP requests, and print the resulting markdown (with responses filled in) to the terminal. With `--watch`, any changes to `example.md` will trigger a re-run.
2. **Building Static Files (`build`):**
To generate a new markdown file with the responses and templated values rendered:
```shell
httpmd build example.md output.md
```
With watch mode:
```shell
httpmd build --watch example.md output.md
```
This creates `output.md`, which is a static snapshot of `example.md` after all requests have been executed and templating applied. This file is suitable for version control, sharing, or integration with static site generators.
**Example Output (`output.md` or terminal output):**
::raw-md[./examples/getting-started.md]{render}
_(Note: Actual headers and some response fields might vary.)_
## Core Concepts
### HTTP Request Blocks
HTTP requests are defined in fenced code blocks annotated with `http`. The syntax is similar to the raw HTTP format:
```
<METHOD> <URL>
<Header-Name>: <Header-Value>
...
<Blank Line>
<Request Body (optional)>
```
**Example:**
````markdown
```http
GET https://api.example.com/items
Accept: application/json
X-Custom-Header: MyValue
```
````
````markdown
```http
POST https://api.example.com/users
Content-Type: application/json
{"name": "John Doe", "email": "john.doe@example.com"}
```
````
All requests in a document are executed sequentially from top to bottom by default.
### The `::response` Directive
The `::response` directive is used to render the full HTTP response (status line, headers, and body) of an HTTP request.
- **Implicit Last:** If used without any arguments (i.e., `::response`), it renders the response of the most recently defined `http` block above it.
- **Explicit by ID:** You can render the response of a specific request by referencing its ID (see [Request IDs](https://www.google.com/search?q=%23request-ids)).
### Request IDs
You can assign a unique ID to an `http` request block. This allows you to:
1. Reference its specific response in a `::response` directive.
2. Access its request and response data in [Templating](https://www.google.com/search?q=%23templating-with-handlebars) via the `requests` and `responses` dictionaries.
To add an ID, include `id=yourUniqueId` in the `http` block's info string:
::raw-md[./examples/with-multiple-requests.md]
## Templating with Handlebars
`http.md` uses [Handlebars](https://handlebarsjs.com/) for templating, allowing you to create dynamic content within your markdown files. You can inject data from request responses, input variables, and other requests into your HTTP blocks or general markdown text.
Templating syntax uses double curly braces: `{{expression}}`.
### Available Variables for Templating
Within your markdown document, the following variables are available in the Handlebars context:
- **`request`** (Object): Details of the most recently processed HTTP request _before_ it's sent.
- `request.method` (String): The HTTP method (e.g., "GET", "POST").
- `request.url` (String): The request URL.
- `request.headers` (Object): An object containing request headers.
- `request.body` (String): The raw request body.
- **`response`** (Object): Details of the most recently received HTTP response.
- `response.status` (Number): The HTTP status code (e.g., 200, 404).
- `response.statusText` (String): The HTTP status message (e.g., "OK", "Not Found").
- `response.headers` (Object): An object containing response headers.
- `response.body` (String/Object): The response body. If the `http` block had the `json` option and the response was valid JSON, this will be a parsed JSON object. Otherwise, it's a raw string.
- `response.rawBody` (String): The raw response body as a string, regardless of parsing.
- _(In case of network errors or non-HTTP errors, `status` and `body` might reflect error information.)_
- **`requests`** (Object): A dictionary mapping request IDs to their respective `request` objects (as defined above).
- Example: `{{requests.createUser.url}}`
- **`responses`** (Object): A dictionary mapping request IDs to their respective `response` objects (as defined above).
- Example: `{{responses.createUser.status}}`, `{{responses.createUser.body.id}}` (if `body` is a parsed JSON object).
- **`input`** (Object): A dictionary of variables passed to `http.md` via the command line using the `-i` or `--input` flag.
- Example: If you run `httpmd dev -i userId=123 -i apiKey=secret myfile.md`, you can use `{{input.userId}}` and `{{input.apiKey}}`.
### Templating Examples
**1. Using a value from a previous response in a new request:**
::raw-md[./examples/with-template.md]
_(Note: `httpbin.org/post` wraps the JSON sent in a "json" field in its response. If your API returns the ID directly at the root of the JSON body, you'd use `{{responses.createItem.body.id}}` assuming the `createItem` request had the `json` option.)_
**2. Displaying a status code in markdown text:**
````markdown
```http
GET https://httpbin.org/status/201
```
The request to `/status/201` completed with status code: **{{response.status}}**.
````
## Managing Documents
### Embedding Other Documents (`::md`)
You can embed other `.md` files into your current document using the `::md` directive. This is useful for breaking down large documentation into smaller, reusable parts, or for including a set of common requests.
The requests from the embedded document are processed, and their `request` and `response` objects become available in the `requests` and `responses` dictionaries of the parent document, keyed by their IDs (if any).
**Syntax:** `::md[./path/to/other-document.md]`
**Example:**
Assume `_shared_requests.md` contains:
````markdown
```http id=sharedGetRequest
GET https://httpbin.org/get
```
````
Then, in `main.md`:
````markdown
# Main Document
Let's include some shared requests:
::md[./_shared_requests.md]
The shared GET request returned: {{responses.sharedGetRequest.status}}
Now, a request specific to this document:
```http
POST https://httpbin.org/post
Content-Type: application/json
{"dataFromMain": "someValue", "sharedUrl": "{{requests.sharedGetRequest.url}}"}
```
::response
````
When `main.md` is processed, `_shared_requests.md` will be embedded, its `sharedGetRequest` will be executed, and its data will be available for templating.
## Advanced Usage
### Using Input Variables
You can pass external data into your `http.md` documents using the `-i` (or `--input`) CLI flag. This is useful for parameterizing requests with environment-specific values, user inputs, or sensitive data.
**CLI Command:**
```shell
httpmd build mydoc.md output.md -i baseUrl=https://api.production.example.com -i apiKey=YOUR_SECRET_KEY
```
**Markdown Usage (`mydoc.md`):**
````markdown
```http
GET {{input.baseUrl}}/users/1
Authorization: Bearer {{input.apiKey}}
```
::response
````
**Security Note:** For sensitive data like API keys, using input variables is highly recommended over hardcoding them in your markdown files. Avoid committing files with plaintext secrets; instead, provide them at runtime via the CLI.
### HTTP Block Configuration Options
You can configure the behavior of each `http` code block by adding options to its info string, separated by commas.
- `id={your-id}`: Assigns a unique ID to the request. This ID can be used to reference the request's response in the `::response` directive and in templating variables (`requests.your-id`, `responses.your-id`).
- Example: ` ```http id=getUser,json `
- `json`: If present, `http.md` will attempt to parse the **response body** as JSON. If successful, `response.body` (and `responses.id.body`) will be the parsed JavaScript object/array, making it easier to access its properties in templates (e.g., `{{response.body.fieldName}}`).
- Example: ` ```http json `
- `yaml`: If present, the **request body** written in YAML format within the code block will be automatically converted to JSON before the request is sent. This allows for writing complex request bodies in a more human-readable YAML syntax. You should still set the `Content-Type` header appropriately (e.g., to `application/json`) if the server expects JSON.
- Example:
````markdown
```http yaml
POST https://api.example.com/submit
Content-Type: application/json
# This is YAML
name: Example User
details:
age: 30
city: New York
```
````
- `disable`: If present, the HTTP request will **not** be executed. No actual network call will be made. The corresponding `response` variable will be undefined or empty, and `::response` will typically render a "Request disabled" message or similar.
- Example: ` ```http disable `
- `hidden`: If present, the `http` code block itself will **not be included** in the rendered output document. However, the request _is still made_ (unless `disable` is also specified), and its response data can be used in templates or displayed with an explicit `::response{#id}` directive. This is useful for prerequisite requests (like authentication) whose details you don't want to clutter the main documentation.
- Example: ` ```http id=authRequest,hidden `
**Combined Example:**
````markdown
```http id=complexRequest,json,yaml,hidden
POST {{input.apiEndpoint}}/data
X-API-Key: {{input.apiKey}}
Content-Type: application/json
# Request body written in YAML, will be converted to JSON
# This entire block will be hidden in the output, but the request runs
# The response will be parsed as JSON
user:
id: 123
preferences:
theme: dark
```
````
### Directive Options
Directives can also have options, specified similarly.
#### `::response` Directive Options
- `id={id}` (or `#{id}` as a shorthand): Renders the output of a specific request identified by `{id}`.
- Example: `::response{#getUser}` or `::response{id=getUser}`
- `yaml`: Renders the (typically JSON) response body formatted as YAML. This is for display purposes.
- Example: `::response{yaml}`
- `truncate={chars}`: Truncates the displayed **response body** to the specified number of characters. Headers and status line are not affected.
- Example: `::response{truncate=100}`
**Combined Example for `::response`:**
`::response{#getUser,yaml,truncate=500}` - Displays the response for request `getUser`, formats its body as YAML, and truncates the body display to 500 characters.
#### `::md[{file}]` Directive Options
The `::md` directive embeds another markdown document.
- **File Path:** The first argument (required) is the path to the markdown file to embed.
- Example: `::md[./includes/authentication.md]`
- `hidden`: If present, the actual content (markdown) of the embedded document will not be rendered in the output. However, any `http` requests within the embedded document _are still processed_, and their `request` and `response` data become available in the parent document's templating context (via `requests.id` and `responses.id`). This is useful if you only want to execute the requests from an included file (e.g., a common setup sequence) and use their results, without displaying the embedded file's content.
- Example: `::md[./setup_requests.md]{hidden}`
## Command-Line Interface (CLI)
The `httpmd` tool provides the following commands:
### `httpmd dev <source_file.md>`
Processes the `<source_file.md>`, executes all HTTP requests, resolves templates, and prints the resulting markdown to the **terminal (stdout)**.
- **Purpose:** Useful for live development and quick previews.
- **Options:**
- `--watch`: Monitors the `<source_file.md>` (and any embedded files) for changes. On detection of a change, it automatically re-processes and re-renders the output to the terminal.
- `-i <key=value>`, `--input <key=value>`: Defines an input variable for templating (see [Using Input Variables](https://www.google.com/search?q=%23using-input-variables)). Can be specified multiple times for multiple variables.
**Example:**
```shell
httpmd dev api_tests.md --watch -i host=localhost:3000
```
### `httpmd build <source_file.md> <output_file.md>`
Processes the `<source_file.md>`, executes all HTTP requests, resolves templates, and saves the resulting markdown to `<output_file.md>`.
- **Purpose:** Generates a static, shareable markdown file with all dynamic content resolved. Ideal for version control, static site generation, or distributing documentation.
- **Options:**
- `--watch`: Monitors the `<source_file.md>` (and any embedded files) for changes. On detection of a change, it automatically re-processes and re-builds the `<output_file.md>`.
- `-i <key=value>`, `--input <key=value>`: Defines an input variable for templating.
**Example:**
```shell
httpmd build official_api_docs.md public/api_docs_v1.md -i version=v1.0
```

View File

@@ -0,0 +1,14 @@
# My API Document
Let's make a POST request to httpbin.org.
```http
POST https://httpbin.org/post
Content-Type: application/json
{"greeting": "Hello, http.md!"}
```
And here is the response:
::response

View File

@@ -0,0 +1,22 @@
# Document with Multiple Requests
First, create a resource:
```http id=createUser
POST https://httpbin.org/post
Content-Type: application/json
{"username": "alpha"}
```
Then, fetch a different resource:
```http id=getItem
GET https://httpbin.org/get?item=123
```
Response from creating the user:
::response{#createUser}
Response from getting the item:
::response{#getItem}

View File

@@ -0,0 +1,16 @@
```http id=createItem json
POST https://httpbin.org/post
Content-Type: application/json
{"name": "My New Item"}
```
The new item ID is: {{responses.createItem.body.json.name}}
Now, let's fetch the item using a (mocked) ID from the response:
```http id=fetchItem
GET https://httpbin.org/anything/{{responses.createItem.body.json.name}}
```
::response{#fetchItem}

57
package.json Normal file
View File

@@ -0,0 +1,57 @@
{
"name": "@morten-olsen/httpmd",
"version": "1.0.0",
"description": "",
"main": "dist/exports.js",
"type": "module",
"bins": {
"httpmd": "bin/cli.mjs"
},
"files": [
"dist",
"bin",
"README.md"
],
"scripts": {
"cli": "tsx src/cli/cli.ts",
"build": "pnpm run build:lib && pnpm run build:readme",
"build:lib": "tsc --build",
"build:readme": "pnpm run cli build docs/README.md README.md",
"dev:readme": "pnpm run cli dev docs/README.md --watch",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"packageManager": "pnpm@10.6.0",
"devDependencies": {
"@pnpm/find-workspace-packages": "^6.0.9",
"@types/blessed": "^0.1.25",
"@types/marked-terminal": "^6.1.1",
"@types/mdast": "^4.0.4",
"@types/node": "^22.15.18",
"@types/terminal-kit": "^2.5.7",
"tsx": "^4.19.4",
"typescript": "^5.8.3"
},
"dependencies": {
"blessed": "^0.1.81",
"commander": "^14.0.0",
"eventemitter3": "^5.0.1",
"handlebars": "^4.7.8",
"hastscript": "^9.0.1",
"marked": "^15.0.11",
"marked-terminal": "^7.3.0",
"mdast-util-to-markdown": "^2.1.2",
"mdast-util-to-string": "^4.0.0",
"rehype-stringify": "^10.0.1",
"remark-directive": "^4.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"remark-stringify": "^11.0.0",
"terminal-kit": "^3.1.2",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0",
"yaml": "^2.8.0"
}
}

3130
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

15
scripts/set-version.mjs Normal file
View File

@@ -0,0 +1,15 @@
import { readFile, writeFile } from 'fs/promises';
import { join } from 'path';
import { findWorkspacePackages } from '@pnpm/find-workspace-packages';
const packages = await findWorkspacePackages(process.cwd());
for (const pkg of packages) {
const pkgPath = join(pkg.dir, 'package.json');
const pkgJson = JSON.parse(await readFile(pkgPath, 'utf-8'));
pkgJson.version = process.argv[2];
await writeFile(pkgPath, JSON.stringify(pkgJson, null, 2) + '\n');
}

115
src/cli/cli.ts Normal file
View File

@@ -0,0 +1,115 @@
import { program } from 'commander';
import { resolve } from 'node:path';
import { marked } from 'marked';
import { markedTerminal } from 'marked-terminal';
import { execute } from '../execution/execution.js';
import { Context } from '../context/context.js';
import { writeFile } from 'node:fs/promises';
import { Watcher } from '../watcher/watcher.js';
marked.use(markedTerminal() as any);
program
.command('dev')
.argument('<name>', 'http.md file name')
.description('Run a http.md document')
.option('-w, --watch', 'watch for changes')
.option('-i, --input <input...>', 'input variables (-i foo=bar -i baz=qux)')
.action(async (name, options) => {
const {
watch = false,
input: i = [],
} = options;
const input = Object.fromEntries(
i.map((item: string) => {
const [key, value] = item.split('=');
return [key, value];
})
);
const filePath = resolve(process.cwd(), name);
const build = async () => {
const context = new Context({
input,
})
const result = await execute(filePath, {
context,
});
const markdown = await marked.parse(result.markdown);
console.log(markdown);
return {
...result,
context,
}
}
const result = await build();
if (watch) {
const watcher = new Watcher();
watcher.watchFiles(Array.from(result.context.files));
watcher.on('changed', async () => {
const result = await build();
watcher.watchFiles(Array.from(result.context.files));
});
} else {
process.exit(0);
}
});
program
.command('build')
.argument('<name>', 'http.md file name')
.argument('<output>', 'output file name')
.description('Run a http.md document')
.option('-w, --watch', 'watch for changes')
.option('-i, --input <input...>', 'input variables (-i foo=bar -i baz=qux)')
.action(async (name, output, options) => {
const {
watch = false,
input: i = [],
} = options;
const input = Object.fromEntries(
i.map((item: string) => {
const [key, value] = item.split('=');
return [key, value];
})
);
const filePath = resolve(process.cwd(), name);
const build = async () => {
const context = new Context({
input,
})
const result = await execute(filePath, {
context,
});
await writeFile(output, result.markdown);
return {
...result,
context,
}
}
const result = await build();
if (watch) {
const watcher = new Watcher();
watcher.watchFiles(Array.from(result.context.files));
watcher.on('changed', async () => {
const result = await build();
watcher.watchFiles(Array.from(result.context.files));
});
} else {
process.exit(0);
}
});
await program.parseAsync(process.argv);

55
src/context/context.ts Normal file
View File

@@ -0,0 +1,55 @@
type Request = {
method: string;
url: string;
headers: Record<string, string>;
body?: string;
};
type Response = {
status: number;
statusText: string;
headers: Record<string, string>;
body?: string;
};
type AddRequestOptios = {
request: Request;
response: Response;
id?: string;
}
type ContextOptions = {
input?: Record<string, unknown>;
env?: Record<string, unknown>;
requests?: Record<string, Request>;
responses?: Record<string, Response>;
};
class Context {
input: Record<string, unknown> = {};
env: Record<string, unknown> = {};
files: Set<string> = new Set();
requests: Record<string, Request> = {};
responses: Record<string, Response> = {};
request?: Request;
response?: Response;
constructor(options: ContextOptions = {}) {
this.input = options.input || {};
this.env = options.env || {};
this.requests = options.requests || {};
this.responses = options.responses || {};
}
public addRequest(options: AddRequestOptios) {
const { request, response, id } = options;
if (id) {
this.requests[id] = request;
this.responses[id] = response;
}
this.request = request;
this.response = response;
}
}
export { Context };

View File

@@ -0,0 +1,99 @@
import { readFile } from 'node:fs/promises';
import { Root } from "mdast";
import remarkGfm from 'remark-gfm'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import remarkDirective from 'remark-directive'
import remarkStringify from 'remark-stringify'
import { unified } from 'unified'
import { visit } from 'unist-util-visit'
import { Context } from "../context/context.js";
import { handlers } from './handlers/handlers.js';
const parser = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkDirective)
.use(remarkStringify)
.use(remarkRehype);
type BaseNode = {
type: string;
name?: string;
children?: BaseNode[];
attributes?: Record<string, string>;
meta?: string;
lang?: string;
value?: string;
}
type ExecutionStepOptions = {
file: string;
input?: {};
context: Context;
root: Root;
node: BaseNode;
}
type ExecutionStep = {
type: string;
node: BaseNode;
action: (options: ExecutionStepOptions) => Promise<void>;
}
type ExecutionHandler = (options: {
file: string;
addStep: (step: ExecutionStep) => void;
node: BaseNode;
parent?: BaseNode;
root: Root;
index?: number;
}) => void;
type ExexutionExecuteOptions = {
context: Context;
}
const execute = async (file: string, options: ExexutionExecuteOptions) => {
const { context } = options;
context.files.add(file);
const content = await readFile(file, 'utf-8');
const steps: Set<ExecutionStep> = new Set();
const root = parser.parse(content);
visit(root, (node, index, parent) => {
for (const handler of handlers) {
handler({
addStep: (step) => steps.add(step),
node: node as BaseNode,
root,
parent: parent as BaseNode | undefined,
index,
file,
});
}
});
for (const step of steps) {
const { node, action } = step;
const options: ExecutionStepOptions = {
file,
input: {},
context,
node,
root,
};
await action(options);
}
const markdown = parser.stringify(root);
return {
root,
markdown,
};
}
export { execute, type ExecutionHandler };

View File

@@ -0,0 +1,43 @@
import { dirname, resolve } from 'path';
import { toString } from 'mdast-util-to-string'
import { execute, type ExecutionHandler } from '../execution.js';
const fileHandler: ExecutionHandler = ({
addStep,
node,
parent,
index,
file,
}) => {
if (node.type === 'leafDirective' && node.name === 'md') {
addStep({
type: 'file',
node,
action: async ({ context }) => {
const filePath = resolve(
dirname(file),
toString(node)
);
if (!filePath) {
throw new Error('File path is required');
}
const { root: newRoot } = await execute(filePath, {
context,
});
if (!parent) {
throw new Error('Parent node is required');
}
if (index === undefined) {
throw new Error('Index is required');
}
if (node.attributes?.hidden === '') {
parent.children?.splice(index, 1);
return;
}
parent.children?.splice(index, 1, ...newRoot.children as any);
},
})
}
}
export { fileHandler };

View File

@@ -0,0 +1,90 @@
import Handlebars from "handlebars";
import YAML from "yaml";
import { ExecutionHandler } from "../execution.js";
const httpHandler: ExecutionHandler = ({
node,
addStep,
}) => {
if (node.type === 'code') {
if (node.lang !== 'http') {
return;
}
const optionParts = node.meta?.split(',') || [];
const options = Object.fromEntries(
optionParts.filter(Boolean).map((option) => {
const [key, value] = option.split('=');
return [key.trim(), value?.trim() || true];
})
);
addStep({
type: 'http',
node,
action: async ({ context }) => {
if (options.disable === true) {
return;
}
const template = Handlebars.compile(node.value);
const content = template(context);
const [head, body] = content.split('\n\n');
const [top, ...headerItems] = head.split('\n');
const [method, url] = top.split(' ');
const headers = Object.fromEntries(
headerItems.map((header) => {
const [key, value] = header.split(':');
return [key.trim(), value?.trim() || ''];
})
);
let parsedBody = body;
if (options.format === 'yaml') {
try {
const parsed = YAML.parse(body);
parsedBody = JSON.stringify(parsed);
} catch (error) {
parsedBody = `Error parsing YAML: ${error}`;
}
}
const response = await fetch(url, {
method,
headers,
body
});
let responseText = await response.text();
if (options.json) {
try {
responseText = JSON.parse(responseText);
} catch (e) {
responseText = `Error parsing JSON: ${e}`;
}
}
node.value = content;
node.meta = undefined;
context.addRequest({
id: options.id?.toString(),
request: {
method,
url,
headers,
body,
},
response: {
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: responseText,
},
});
},
});
}
};
export { httpHandler };

View File

@@ -0,0 +1,56 @@
import { toString } from 'mdast-util-to-string';
import { type ExecutionHandler } from '../execution.js';
const inputHandler: ExecutionHandler = ({
addStep,
node,
parent,
index,
}) => {
if (node.type === 'leafDirective' && node.name === 'input') {
addStep({
type: 'input',
node,
action: async ({ context }) => {
const name = toString(node);
if (node.attributes?.required === '' && context.input[name] === undefined) {
throw new Error(`Input "${name}" is required`);
}
if (node.attributes?.default !== undefined && context.input[name] === undefined) {
context.input[name] = node.attributes.default;
}
if (node.attributes?.format === 'number' && context.input[name] !== undefined) {
context.input[name] = Number(context.input[name]);
if (context.input[name] !== undefined && isNaN(Number(context.input[name]))) {
throw new Error(`Input "${name}" must be a number, but got "${context.input[name]}"`);
}
}
if (!parent || !('children' in parent) || index === undefined) {
throw new Error('Parent node is required');
}
if (node.attributes?.hidden === '') {
parent?.children?.splice(index, 1);
return;
}
const newNode = {
type: 'code',
value: `${name}=${context.input[name] ?? '[undefined]'}`,
};
parent.children?.splice(
index,
1,
newNode as any,
);
},
})
}
}
export { inputHandler };

View File

@@ -0,0 +1,59 @@
import { readFile } from 'fs/promises';
import { Context } from '../../context/context.js';
import { execute, type ExecutionHandler } from '../execution.js';
import { dirname, resolve } from 'path';
import { toString } from 'mdast-util-to-string';
const rawMdHandler: ExecutionHandler = ({
addStep,
node,
parent,
index,
file,
}) => {
if (node.type === 'leafDirective' && node.name === 'raw-md') {
addStep({
type: 'raw-md',
node,
action: async ({ context: parentContext }) => {
const name = resolve(
dirname(file),
toString(node),
);
const context = new Context({
input: {},
});
let markdown = '';
if (node.attributes?.render === '') {
const result = await execute(name, {
context,
});
markdown = result.markdown;
for (const file of context.files) {
parentContext.files.add(file);
}
} else {
markdown = await readFile(name, 'utf-8');
parentContext.files.add(name);
}
const newNode = {
type: 'code',
lang: 'markdown',
value: markdown,
};
if (!parent || !('children' in parent) || index === undefined) {
throw new Error('Parent node is required');
}
parent.children?.splice(
index,
1,
newNode as any,
);
},
})
}
}
export { rawMdHandler };

View File

@@ -0,0 +1,72 @@
import YAML from 'yaml';
import { type ExecutionHandler } from '../execution.js';
const responseHandler: ExecutionHandler = ({
addStep,
node,
parent,
index,
}) => {
if (node.type === 'leafDirective' && node.name === 'response') {
addStep({
type: 'file',
node,
action: async ({ context }) => {
const response = node.attributes?.id ?
context.responses[node.attributes.id] : context.response
if (!response) {
return;
}
let body = '';
if (response.body) {
body = response.body;
}
if (typeof response.body === 'object') {
body = JSON.stringify(response.body, null, 2);
}
if (node.attributes?.format === 'yaml') {
try {
const parsed = YAML.parse(body);
body = YAML.stringify(parsed);
} catch (error) {
body = `Error parsing YAML: ${error}`;
}
}
if (node.attributes?.truncate) {
const maxLength = parseInt(node.attributes.truncate);
if (body.length > maxLength) {
body = body.slice(0, maxLength) + '...';
}
}
const responseContent = [
`HTTP/${response.status} ${response.statusText}`,
...Object.entries(response.headers).map(([key, value]) => {
return `${key}: ${value}`;
}),
'',
body || '[empty]',
].join('\n');
const codeNode = {
type: 'code',
value: responseContent,
};
if (!parent || !('children' in parent) || index === undefined) {
throw new Error('Parent node is required');
}
parent.children?.splice(
index,
1,
codeNode as any,
);
},
})
}
}
export { responseHandler };

View File

@@ -0,0 +1,21 @@
import { type ExecutionHandler } from '../execution.js';
import Handlebars from "handlebars";
const textHandler: ExecutionHandler = ({
addStep,
node,
}) => {
if (node.type === 'text') {
addStep({
type: 'parse-text',
node,
action: async ({ context }) => {
const template = Handlebars.compile(node.value);
const content = template(context);
node.value = content;
},
})
}
}
export { textHandler };

View File

@@ -0,0 +1,18 @@
import { ExecutionHandler } from "../execution.js";
import { fileHandler } from "./handlers.file.js";
import { httpHandler } from "./handlers.http.js";
import { inputHandler } from "./handlers.input.js";
import { rawMdHandler } from "./handlers.raw-md.js";
import { responseHandler } from "./handlers.response.js";
import { textHandler } from "./handlers.text.js";
const handlers = [
fileHandler,
httpHandler,
responseHandler,
textHandler,
inputHandler,
rawMdHandler,
] satisfies ExecutionHandler[];
export { handlers };

16
src/exports.ts Normal file
View File

@@ -0,0 +1,16 @@
import { inspect } from "node:util";
import { Context } from "./context/context.js";
import { execute } from "./execution/execution.js";
const context = new Context({
input: {
foo: '10',
},
});
const result = await execute('./demo.md', {
context,
});
console.log(result.markdown);
console.log(context.files);

23
src/watcher/watcher.ts Normal file
View File

@@ -0,0 +1,23 @@
import { EventEmitter } from "eventemitter3";
import { FSWatcher, watch } from "node:fs";
type WatcherEvent = {
changed: () => void;
};
class Watcher extends EventEmitter<WatcherEvent> {
#watching: Map<string, FSWatcher> = new Map()
public watchFiles = (files: string[]) => {
for (const file of files) {
if (this.#watching.has(file)) {
continue;
}
const watcher = watch(file, () => {
this.emit("changed");
});
this.#watching.set(file, watcher);
}
}
}
export { Watcher }

16
tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}