mirror of
https://github.com/morten-olsen/http.md.git
synced 2026-02-08 00:46:28 +01:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c01dce4998 | ||
|
|
9d04cf0414 | ||
|
|
11f76a7378 | ||
|
|
4514972880 | ||
|
|
c7b9abf868 |
27
.github/workflows/main.yaml
vendored
27
.github/workflows/main.yaml
vendored
@@ -12,9 +12,9 @@ on:
|
|||||||
env:
|
env:
|
||||||
environment: test
|
environment: test
|
||||||
release_channel: latest
|
release_channel: latest
|
||||||
DO_NOT_TRACK: "1"
|
DO_NOT_TRACK: '1'
|
||||||
NODE_VERSION: "23.x"
|
NODE_VERSION: '23.x'
|
||||||
NODE_REGISTRY: "https://registry.npmjs.org"
|
NODE_REGISTRY: 'https://registry.npmjs.org'
|
||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
DOCKER_REGISTRY: ghcr.io
|
DOCKER_REGISTRY: ghcr.io
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
@@ -22,6 +22,7 @@ env:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: read
|
packages: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -32,8 +33,8 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "${{ env.NODE_VERSION }}"
|
node-version: '${{ env.NODE_VERSION }}'
|
||||||
registry-url: "${{ env.NODE_REGISTRY }}"
|
registry-url: '${{ env.NODE_REGISTRY }}'
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
@@ -62,8 +63,12 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
# - name: Run tests
|
- name: Run tests
|
||||||
# run: pnpm test
|
run: pnpm test
|
||||||
|
|
||||||
|
- name: 'Report Coverage'
|
||||||
|
if: always()
|
||||||
|
uses: davelosert/vitest-coverage-report-action@v2
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -109,8 +114,8 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "${{ env.NODE_VERSION }}"
|
node-version: '${{ env.NODE_VERSION }}'
|
||||||
registry-url: "${{ env.NODE_REGISTRY }}"
|
registry-url: '${{ env.NODE_REGISTRY }}'
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
@@ -138,5 +143,5 @@ jobs:
|
|||||||
|
|
||||||
- uses: stefanzweifel/git-auto-commit-action@v5
|
- uses: stefanzweifel/git-auto-commit-action@v5
|
||||||
with:
|
with:
|
||||||
commit_message: "docs: generated README"
|
commit_message: 'docs: generated README'
|
||||||
file_pattern: "*.md"
|
file_pattern: '*.md'
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
/node_modules/
|
/node_modules/
|
||||||
/dist/
|
/dist/
|
||||||
|
/coverage/
|
||||||
/*.html
|
/*.html
|
||||||
|
|||||||
18
.prettierrc.json
Normal file
18
.prettierrc.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"arrowParens": "always",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"htmlWhitespaceSensitivity": "css",
|
||||||
|
"insertPragma": false,
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"jsxSingleQuote": false,
|
||||||
|
"printWidth": 120,
|
||||||
|
"proseWrap": "preserve",
|
||||||
|
"quoteProps": "as-needed",
|
||||||
|
"requirePragma": false,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"useTabs": false,
|
||||||
|
"singleAttributePerLine": false
|
||||||
|
}
|
||||||
107
README.md
107
README.md
@@ -38,7 +38,7 @@ It allows developers to create API documentation that is always accurate and up-
|
|||||||
* [Installation](#installation)
|
* [Installation](#installation)
|
||||||
* [Getting Started](#getting-started)
|
* [Getting Started](#getting-started)
|
||||||
* [Your First Request](#your-first-request)
|
* [Your First Request](#your-first-request)
|
||||||
* [Rendering Documents](#rendering-documents)
|
* [Rendering Document](#rendering-document)
|
||||||
* [Core Concepts](#core-concepts)
|
* [Core Concepts](#core-concepts)
|
||||||
* [HTTP Request Blocks](#http-request-blocks)
|
* [HTTP Request Blocks](#http-request-blocks)
|
||||||
* [The `::response` Directive](#the-response-directive)
|
* [The `::response` Directive](#the-response-directive)
|
||||||
@@ -50,6 +50,7 @@ It allows developers to create API documentation that is always accurate and up-
|
|||||||
* [Embedding Other Documents (`::md`)](#embedding-other-documents-md)
|
* [Embedding Other Documents (`::md`)](#embedding-other-documents-md)
|
||||||
* [Advanced Usage](#advanced-usage)
|
* [Advanced Usage](#advanced-usage)
|
||||||
* [Using Input Variables](#using-input-variables)
|
* [Using Input Variables](#using-input-variables)
|
||||||
|
* [JavaScript Execution](#javascript-execution)
|
||||||
* [HTTP Block Configuration Options](#http-block-configuration-options)
|
* [HTTP Block Configuration Options](#http-block-configuration-options)
|
||||||
* [Directive Options](#directive-options)
|
* [Directive Options](#directive-options)
|
||||||
* [`::response` Directive Options](#response-directive-options)
|
* [`::response` Directive Options](#response-directive-options)
|
||||||
@@ -93,7 +94,7 @@ And here is the response:
|
|||||||
|
|
||||||
````
|
````
|
||||||
|
|
||||||
### Rendering Documents
|
### Rendering Document
|
||||||
|
|
||||||
You have two primary ways to render your `http.md` file:
|
You have two primary ways to render your `http.md` file:
|
||||||
|
|
||||||
@@ -148,9 +149,9 @@ HTTP/200 OK
|
|||||||
access-control-allow-credentials: true
|
access-control-allow-credentials: true
|
||||||
access-control-allow-origin: *
|
access-control-allow-origin: *
|
||||||
connection: keep-alive
|
connection: keep-alive
|
||||||
content-length: 559
|
content-length: 558
|
||||||
content-type: application/json
|
content-type: application/json
|
||||||
date: Mon, 19 May 2025 07:15:17 GMT
|
date: Mon, 19 May 2025 14:23:24 GMT
|
||||||
server: gunicorn/19.9.0
|
server: gunicorn/19.9.0
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -167,12 +168,12 @@ server: gunicorn/19.9.0
|
|||||||
"Host": "httpbin.org",
|
"Host": "httpbin.org",
|
||||||
"Sec-Fetch-Mode": "cors",
|
"Sec-Fetch-Mode": "cors",
|
||||||
"User-Agent": "node",
|
"User-Agent": "node",
|
||||||
"X-Amzn-Trace-Id": "Root=1-682ada85-516dfea550431bd2238aa456"
|
"X-Amzn-Trace-Id": "Root=1-682b3edc-1e6e4ce373832c8925a96017"
|
||||||
},
|
},
|
||||||
"json": {
|
"json": {
|
||||||
"greeting": "Hello, http.md!"
|
"greeting": "Hello, http.md!"
|
||||||
},
|
},
|
||||||
"origin": "172.214.199.239",
|
"origin": "20.236.119.125",
|
||||||
"url": "https://httpbin.org/post"
|
"url": "https://httpbin.org/post"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,9 +288,9 @@ HTTP/200 OK
|
|||||||
access-control-allow-credentials: true
|
access-control-allow-credentials: true
|
||||||
access-control-allow-origin: *
|
access-control-allow-origin: *
|
||||||
connection: keep-alive
|
connection: keep-alive
|
||||||
content-length: 504
|
content-length: 503
|
||||||
content-type: application/json
|
content-type: application/json
|
||||||
date: Mon, 19 May 2025 07:15:18 GMT
|
date: Mon, 19 May 2025 14:23:24 GMT
|
||||||
server: gunicorn/19.9.0
|
server: gunicorn/19.9.0
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -306,10 +307,10 @@ server: gunicorn/19.9.0
|
|||||||
"Host": "httpbin.org",
|
"Host": "httpbin.org",
|
||||||
"Sec-Fetch-Mode": "cors",
|
"Sec-Fetch-Mode": "cors",
|
||||||
"User-Agent": "node",
|
"User-Agent": "node",
|
||||||
"X-Amzn-Trace-Id": "Root=1-682ada85-5841d69c253c03e450c0cfc8"
|
"X-Amzn-Trace-Id": "Root=1-682b3edc-4e2ecf605646f8856ed8fb62"
|
||||||
},
|
},
|
||||||
"json": null,
|
"json": null,
|
||||||
"origin": "172.214.199.239",
|
"origin": "20.236.119.125",
|
||||||
"url": "https://httpbin.org/post"
|
"url": "https://httpbin.org/post"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -321,9 +322,9 @@ HTTP/200 OK
|
|||||||
access-control-allow-credentials: true
|
access-control-allow-credentials: true
|
||||||
access-control-allow-origin: *
|
access-control-allow-origin: *
|
||||||
connection: keep-alive
|
connection: keep-alive
|
||||||
content-length: 385
|
content-length: 384
|
||||||
content-type: application/json
|
content-type: application/json
|
||||||
date: Mon, 19 May 2025 07:15:18 GMT
|
date: Mon, 19 May 2025 14:23:25 GMT
|
||||||
server: gunicorn/19.9.0
|
server: gunicorn/19.9.0
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -337,9 +338,9 @@ server: gunicorn/19.9.0
|
|||||||
"Host": "httpbin.org",
|
"Host": "httpbin.org",
|
||||||
"Sec-Fetch-Mode": "cors",
|
"Sec-Fetch-Mode": "cors",
|
||||||
"User-Agent": "node",
|
"User-Agent": "node",
|
||||||
"X-Amzn-Trace-Id": "Root=1-682ada86-50ccd79351313f742de31921"
|
"X-Amzn-Trace-Id": "Root=1-682b3edc-63188a636bbcddd71d66a9a9"
|
||||||
},
|
},
|
||||||
"origin": "172.214.199.239",
|
"origin": "20.236.119.125",
|
||||||
"url": "https://httpbin.org/get?item=123"
|
"url": "https://httpbin.org/get?item=123"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,9 +436,9 @@ HTTP/200 OK
|
|||||||
access-control-allow-credentials: true
|
access-control-allow-credentials: true
|
||||||
access-control-allow-origin: *
|
access-control-allow-origin: *
|
||||||
connection: keep-alive
|
connection: keep-alive
|
||||||
content-length: 455
|
content-length: 454
|
||||||
content-type: application/json
|
content-type: application/json
|
||||||
date: Mon, 19 May 2025 07:15:18 GMT
|
date: Mon, 19 May 2025 14:23:32 GMT
|
||||||
server: gunicorn/19.9.0
|
server: gunicorn/19.9.0
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -452,11 +453,11 @@ server: gunicorn/19.9.0
|
|||||||
"Host": "httpbin.org",
|
"Host": "httpbin.org",
|
||||||
"Sec-Fetch-Mode": "cors",
|
"Sec-Fetch-Mode": "cors",
|
||||||
"User-Agent": "node",
|
"User-Agent": "node",
|
||||||
"X-Amzn-Trace-Id": "Root=1-682ada86-5c212bfd7fd8d81a7749fe52"
|
"X-Amzn-Trace-Id": "Root=1-682b3edf-02e1f60832842817408a7101"
|
||||||
},
|
},
|
||||||
"json": null,
|
"json": null,
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"origin": "172.214.199.239",
|
"origin": "20.236.119.125",
|
||||||
"url": "https://httpbin.org/anything/My New Item"
|
"url": "https://httpbin.org/anything/My New Item"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,9 +552,9 @@ HTTP/200 OK
|
|||||||
access-control-allow-credentials: true
|
access-control-allow-credentials: true
|
||||||
access-control-allow-origin: *
|
access-control-allow-origin: *
|
||||||
connection: keep-alive
|
connection: keep-alive
|
||||||
content-length: 644
|
content-length: 643
|
||||||
content-type: application/json
|
content-type: application/json
|
||||||
date: Mon, 19 May 2025 07:15:18 GMT
|
date: Mon, 19 May 2025 14:23:41 GMT
|
||||||
server: gunicorn/19.9.0
|
server: gunicorn/19.9.0
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -570,13 +571,13 @@ server: gunicorn/19.9.0
|
|||||||
"Host": "httpbin.org",
|
"Host": "httpbin.org",
|
||||||
"Sec-Fetch-Mode": "cors",
|
"Sec-Fetch-Mode": "cors",
|
||||||
"User-Agent": "node",
|
"User-Agent": "node",
|
||||||
"X-Amzn-Trace-Id": "Root=1-682ada86-4c99fed83b21605713289d8a"
|
"X-Amzn-Trace-Id": "Root=1-682b3ee6-44455a2003d5b30b006a3b64"
|
||||||
},
|
},
|
||||||
"json": {
|
"json": {
|
||||||
"dataFromMain": "someValue",
|
"dataFromMain": "someValue",
|
||||||
"sharedUrl": "https://httpbin.org/get"
|
"sharedUrl": "https://httpbin.org/get"
|
||||||
},
|
},
|
||||||
"origin": "172.214.199.239",
|
"origin": "20.236.119.125",
|
||||||
"url": "https://httpbin.org/post"
|
"url": "https://httpbin.org/post"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,6 +614,68 @@ Authorization: Bearer
|
|||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
|
### JavaScript Execution
|
||||||
|
|
||||||
|
You can execute `javascript` blocks by adding a `run` option which allows programmatically changing the context, making request assertions and solve other more advanced use cases
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
```javascript run
|
||||||
|
input.test = "Hello World";
|
||||||
|
```
|
||||||
|
|
||||||
|
::input[test]
|
||||||
|
|
||||||
|
```http json
|
||||||
|
POST https://httpbin.org/post
|
||||||
|
|
||||||
|
{"input": "{{input.test}}"}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript run,hidden
|
||||||
|
// Use chai's `expect`, `assert` or `should` to make assumptions
|
||||||
|
expect(response.body.json.input).to.equal("Hello World");
|
||||||
|
```
|
||||||
|
|
||||||
|
````
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Output</summary>
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
```javascript
|
||||||
|
input.test = "Hello World";
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
test=Hello World
|
||||||
|
```
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST https://httpbin.org/post
|
||||||
|
|
||||||
|
{"input": "Hello World"}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
````
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
|
||||||
|
* `run`: If present the code block will be executed
|
||||||
|
|
||||||
|
* Example: ` ```javascript run `
|
||||||
|
|
||||||
|
* `hidden`: If present the code block will not be included in the resulting output
|
||||||
|
|
||||||
|
* Example: ` ```javascript hidden `
|
||||||
|
|
||||||
|
* `output`: If present the code blocks return value will be rendered as a `yaml` code block
|
||||||
|
|
||||||
### HTTP Block Configuration Options
|
### HTTP Block Configuration Options
|
||||||
|
|
||||||
You can configure the behavior of each `http` code block by adding options to its info string, separated by commas.
|
You can configure the behavior of each `http` code block by adding options to its info string, separated by commas.
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ Create a file named `example.md`:
|
|||||||
|
|
||||||
::raw-md[./examples/getting-started.md]
|
::raw-md[./examples/getting-started.md]
|
||||||
|
|
||||||
### Rendering Documents
|
### Rendering Document
|
||||||
|
|
||||||
You have two primary ways to render your `http.md` file:
|
You have two primary ways to render your `http.md` file:
|
||||||
|
|
||||||
@@ -279,7 +279,7 @@ You can execute `javascript` blocks by adding a `run` option which allows progra
|
|||||||
<details>
|
<details>
|
||||||
<summary>Output</summary>
|
<summary>Output</summary>
|
||||||
|
|
||||||
::raw-md[./examples/with-javascript.md]{run}
|
::raw-md[./examples/with-javascript.md]{render}
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
51
eslint.config.mjs
Normal file
51
eslint.config.mjs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { FlatCompat } from '@eslint/eslintrc';
|
||||||
|
import importPlugin from 'eslint-plugin-import';
|
||||||
|
import eslint from '@eslint/js';
|
||||||
|
import eslintConfigPrettier from 'eslint-config-prettier';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: import.meta.__dirname,
|
||||||
|
resolvePluginsRelativeTo: import.meta.__dirname,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.strict,
|
||||||
|
...tseslint.configs.stylistic,
|
||||||
|
eslintConfigPrettier,
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsxx}'],
|
||||||
|
extends: [importPlugin.flatConfigs.recommended, importPlugin.flatConfigs.typescript],
|
||||||
|
rules: {
|
||||||
|
'import/no-unresolved': 'off',
|
||||||
|
'import/extensions': ['error', 'ignorePackages'],
|
||||||
|
'import/exports-last': 'error',
|
||||||
|
'import/no-default-export': 'error',
|
||||||
|
'import/order': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
|
||||||
|
'newlines-between': 'always',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'import/no-duplicates': 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**.d.ts'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/triple-slash-reference': 'off',
|
||||||
|
'@typescript-eslint/consistent-type-definitions': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...compat.extends('plugin:prettier/recommended'),
|
||||||
|
{
|
||||||
|
ignores: ['**/node_modules/', '**/dist/', '**/.turbo/', '**/generated/'],
|
||||||
|
},
|
||||||
|
);
|
||||||
22
package.json
22
package.json
@@ -19,21 +19,36 @@
|
|||||||
"build:readme": "pnpm run cli build docs/README.md README.md",
|
"build:readme": "pnpm run cli build docs/README.md README.md",
|
||||||
"build:readme-html": "pnpm run cli build docs/README.md README.html -f html",
|
"build:readme-html": "pnpm run cli build docs/README.md README.html -f html",
|
||||||
"dev:readme": "pnpm run cli dev docs/README.md --watch",
|
"dev:readme": "pnpm run cli dev docs/README.md --watch",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "pnpm run test:lint && pnpm run test:unit",
|
||||||
|
"test:lint": "eslint src tests",
|
||||||
|
"test:unit": "vitest --run --coverage"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"packageManager": "pnpm@10.6.0",
|
"packageManager": "pnpm@10.6.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
|
"@eslint/js": "^9.27.0",
|
||||||
"@pnpm/find-workspace-packages": "^6.0.9",
|
"@pnpm/find-workspace-packages": "^6.0.9",
|
||||||
"@types/blessed": "^0.1.25",
|
"@types/blessed": "^0.1.25",
|
||||||
"@types/chai": "^5.2.2",
|
"@types/chai": "^5.2.2",
|
||||||
|
"@types/ink": "^2.0.3",
|
||||||
"@types/marked-terminal": "^6.1.1",
|
"@types/marked-terminal": "^6.1.1",
|
||||||
"@types/mdast": "^4.0.4",
|
"@types/mdast": "^4.0.4",
|
||||||
"@types/node": "^22.15.18",
|
"@types/node": "^22.15.18",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
"@types/terminal-kit": "^2.5.7",
|
"@types/terminal-kit": "^2.5.7",
|
||||||
|
"@vitest/coverage-v8": "3.1.4",
|
||||||
|
"eslint": "^9.27.0",
|
||||||
|
"eslint-config-prettier": "^10.1.5",
|
||||||
|
"eslint-plugin-import": "^2.31.0",
|
||||||
|
"eslint-plugin-prettier": "^5.4.0",
|
||||||
|
"msw": "^2.8.4",
|
||||||
|
"prettier": "^3.5.3",
|
||||||
"tsx": "^4.19.4",
|
"tsx": "^4.19.4",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3",
|
||||||
|
"typescript-eslint": "^8.32.1",
|
||||||
|
"vitest": "^3.1.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"blessed": "^0.1.81",
|
"blessed": "^0.1.81",
|
||||||
@@ -44,11 +59,13 @@
|
|||||||
"eventemitter3": "^5.0.1",
|
"eventemitter3": "^5.0.1",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"hastscript": "^9.0.1",
|
"hastscript": "^9.0.1",
|
||||||
|
"ink": "^5.2.1",
|
||||||
"marked": "^15.0.11",
|
"marked": "^15.0.11",
|
||||||
"marked-terminal": "^7.3.0",
|
"marked-terminal": "^7.3.0",
|
||||||
"mdast-util-to-markdown": "^2.1.2",
|
"mdast-util-to-markdown": "^2.1.2",
|
||||||
"mdast-util-to-string": "^4.0.0",
|
"mdast-util-to-string": "^4.0.0",
|
||||||
"mdast-util-toc": "^7.1.0",
|
"mdast-util-toc": "^7.1.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
"rehype-stringify": "^10.0.1",
|
"rehype-stringify": "^10.0.1",
|
||||||
"remark-behead": "^3.1.0",
|
"remark-behead": "^3.1.0",
|
||||||
"remark-directive": "^4.0.0",
|
"remark-directive": "^4.0.0",
|
||||||
@@ -59,6 +76,7 @@
|
|||||||
"terminal-kit": "^3.1.2",
|
"terminal-kit": "^3.1.2",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
"unist-util-visit": "^5.0.0",
|
"unist-util-visit": "^5.0.0",
|
||||||
|
"wrap-ansi": "^9.0.0",
|
||||||
"yaml": "^2.8.0"
|
"yaml": "^2.8.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3404
pnpm-lock.yaml
generated
3404
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,62 +1,63 @@
|
|||||||
import { program } from 'commander';
|
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
import { program } from 'commander';
|
||||||
import { Marked } from 'marked';
|
import { Marked } from 'marked';
|
||||||
import { markedTerminal } from 'marked-terminal';
|
|
||||||
import { execute } from '../execution/execution.js';
|
import { execute } from '../execution/execution.js';
|
||||||
import { Context } from '../context/context.js';
|
import { Context } from '../context/context.js';
|
||||||
import { writeFile } from 'node:fs/promises';
|
|
||||||
import { Watcher } from '../watcher/watcher.js';
|
import { Watcher } from '../watcher/watcher.js';
|
||||||
import { UI } from './ui/ui.js';
|
|
||||||
import { wrapBody } from '../theme/theme.html.js';
|
import { wrapBody } from '../theme/theme.html.js';
|
||||||
|
import { InvalidFormatError } from '../utils/errors.js';
|
||||||
|
|
||||||
|
import { loadInputFiles } from './utils/input.js';
|
||||||
|
import { renderUI, State } from './ui/ui.js';
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('dev')
|
.command('dev')
|
||||||
.argument('<name>', 'http.md file name')
|
.argument('<name>', 'http.md file name')
|
||||||
.description('Run a http.md document')
|
.description('Run a http.md document')
|
||||||
.option('-w, --watch', 'watch for changes')
|
.option('-w, --watch', 'watch for changes')
|
||||||
|
.option('-f, --file <file...>', 'input files (-f foo.js -f bar.json)')
|
||||||
.option('-i, --input <input...>', 'input variables (-i foo=bar -i baz=qux)')
|
.option('-i, --input <input...>', 'input variables (-i foo=bar -i baz=qux)')
|
||||||
.action(async (name, options) => {
|
.action(async (name, options) => {
|
||||||
const marked = new Marked();
|
const { file: f = [], watch = false, input: i = [] } = options;
|
||||||
marked.use(markedTerminal() as any);
|
|
||||||
const {
|
|
||||||
watch = false,
|
|
||||||
input: i = [],
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const ui = new UI();
|
const input = {
|
||||||
|
...Object.fromEntries(
|
||||||
const input = Object.fromEntries(
|
|
||||||
i.map((item: string) => {
|
i.map((item: string) => {
|
||||||
const [key, value] = item.split('=');
|
const [key, value] = item.split('=');
|
||||||
return [key, value];
|
return [key, value];
|
||||||
})
|
}),
|
||||||
);
|
),
|
||||||
|
...loadInputFiles(f),
|
||||||
|
};
|
||||||
|
const state = new State<ExpectedAny>({
|
||||||
|
markdown: 'Loading',
|
||||||
|
});
|
||||||
const filePath = resolve(process.cwd(), name);
|
const filePath = resolve(process.cwd(), name);
|
||||||
|
|
||||||
const build = async () => {
|
const build = async () => {
|
||||||
const context = new Context({
|
const context = new Context({
|
||||||
input,
|
input,
|
||||||
})
|
});
|
||||||
const result = await execute(filePath, {
|
const result = await execute(filePath, {
|
||||||
context,
|
context,
|
||||||
});
|
});
|
||||||
|
|
||||||
const markdown = await marked.parse(result.markdown);
|
state.setState({
|
||||||
ui.content = markdown;
|
error: result.error ? (result.error instanceof Error ? result.error.message : result.error) : undefined,
|
||||||
|
markdown: result.markdown,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
context,
|
context,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
const result = await build();
|
const result = await build();
|
||||||
|
renderUI(state);
|
||||||
ui.screen.key(['r'], () => {
|
|
||||||
build();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (watch) {
|
if (watch) {
|
||||||
const watcher = new Watcher();
|
const watcher = new Watcher();
|
||||||
@@ -75,33 +76,36 @@ program
|
|||||||
.argument('<name>', 'http.md file name')
|
.argument('<name>', 'http.md file name')
|
||||||
.argument('<output>', 'output file name')
|
.argument('<output>', 'output file name')
|
||||||
.description('Run a http.md document')
|
.description('Run a http.md document')
|
||||||
.option('-f, --format <format>', 'output format (html, markdown)')
|
.option('-f, --file <file...>', 'input files (-f foo.js -f bar.json)')
|
||||||
|
.option('--format <format>', 'output format (html, markdown)')
|
||||||
.option('-w, --watch', 'watch for changes')
|
.option('-w, --watch', 'watch for changes')
|
||||||
.option('-i, --input <input...>', 'input variables (-i foo=bar -i baz=qux)')
|
.option('-i, --input <input...>', 'input variables (-i foo=bar -i baz=qux)')
|
||||||
.action(async (name, output, options) => {
|
.action(async (name, output, options) => {
|
||||||
const {
|
const { watch = false, file: f = [], input: i = [], format = 'markdown' } = options;
|
||||||
watch = false,
|
|
||||||
input: i = [],
|
|
||||||
format = 'markdown',
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
|
const input = {
|
||||||
const input = Object.fromEntries(
|
...Object.fromEntries(
|
||||||
i.map((item: string) => {
|
i.map((item: string) => {
|
||||||
const [key, value] = item.split('=');
|
const [key, value] = item.split('=');
|
||||||
return [key, value];
|
return [key, value];
|
||||||
})
|
}),
|
||||||
);
|
),
|
||||||
|
...loadInputFiles(f),
|
||||||
|
};
|
||||||
const filePath = resolve(process.cwd(), name);
|
const filePath = resolve(process.cwd(), name);
|
||||||
|
|
||||||
const build = async () => {
|
const build = async () => {
|
||||||
const context = new Context({
|
const context = new Context({
|
||||||
input,
|
input,
|
||||||
})
|
});
|
||||||
const result = await execute(filePath, {
|
const result = await execute(filePath, {
|
||||||
context,
|
context,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
console.error(result.error);
|
||||||
|
}
|
||||||
|
|
||||||
if (format === 'html') {
|
if (format === 'html') {
|
||||||
const marked = new Marked();
|
const marked = new Marked();
|
||||||
const html = await marked.parse(result.markdown);
|
const html = await marked.parse(result.markdown);
|
||||||
@@ -109,16 +113,20 @@ program
|
|||||||
} else if (format === 'markdown') {
|
} else if (format === 'markdown') {
|
||||||
await writeFile(output, result.markdown);
|
await writeFile(output, result.markdown);
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Invalid format');
|
throw new InvalidFormatError('Invalid format');
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
context,
|
context,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
const result = await build();
|
const result = await build();
|
||||||
|
|
||||||
|
if (result.error && !watch) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
if (watch) {
|
if (watch) {
|
||||||
const watcher = new Watcher();
|
const watcher = new Watcher();
|
||||||
watcher.watchFiles(Array.from(result.context.files));
|
watcher.watchFiles(Array.from(result.context.files));
|
||||||
|
|||||||
55
src/cli/ui/components/scrollable-markdown.tsx
Normal file
55
src/cli/ui/components/scrollable-markdown.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { Box, Text, useInput } from 'ink';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
import TerminalRenderer from 'marked-terminal';
|
||||||
|
import wrapAnsi from 'wrap-ansi';
|
||||||
|
import os from 'os';
|
||||||
|
import { useTerminalHeight, useTerminalWidth } from '../hooks/terminal.js';
|
||||||
|
|
||||||
|
type ScrollableMarkdownProps = {
|
||||||
|
markdownContent: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderer = new TerminalRenderer({});
|
||||||
|
marked.setOptions({ renderer: renderer as ExpectedAny });
|
||||||
|
|
||||||
|
const ScrollableMarkdown = ({ markdownContent }: ScrollableMarkdownProps) => {
|
||||||
|
const [scrollPosition, setScrollPosition] = React.useState(0);
|
||||||
|
const terminalHeight = useTerminalHeight();
|
||||||
|
const terminalWidth = useTerminalWidth();
|
||||||
|
const rendered = useMemo(() => {
|
||||||
|
return marked(markdownContent) as string;
|
||||||
|
}, [markdownContent]);
|
||||||
|
const wrapped = useMemo(() => {
|
||||||
|
return wrapAnsi(rendered, terminalWidth, {
|
||||||
|
hard: true,
|
||||||
|
trim: false,
|
||||||
|
wordWrap: true,
|
||||||
|
}).split(os.EOL);
|
||||||
|
}, [rendered, terminalWidth]);
|
||||||
|
|
||||||
|
const buffer = useMemo(() => {
|
||||||
|
return wrapped.slice(scrollPosition, scrollPosition + terminalHeight).join(os.EOL);
|
||||||
|
}, [wrapped, scrollPosition, terminalHeight]);
|
||||||
|
|
||||||
|
useInput((input, key) => {
|
||||||
|
if (key.downArrow) {
|
||||||
|
setScrollPosition((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
if (key.upArrow) {
|
||||||
|
setScrollPosition((prev) => Math.max(0, prev - 1));
|
||||||
|
}
|
||||||
|
if (key.escape) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
console.error('a', input, key);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" flexShrink={1} flexGrow={1} overflow="hidden">
|
||||||
|
<Text>{buffer}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ScrollableMarkdown };
|
||||||
39
src/cli/ui/hooks/terminal.ts
Normal file
39
src/cli/ui/hooks/terminal.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const useTerminalWidth = () => {
|
||||||
|
const [width, setWidth] = useState(process.stdout.columns || 80);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
setWidth(process.stdout.columns);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.stdout.on('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
process.stdout.off('resize', handleResize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return width;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useTerminalHeight = () => {
|
||||||
|
const [height, setHeight] = useState(process.stdout.rows || 24);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
setHeight(process.stdout.rows);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.stdout.on('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
process.stdout.off('resize', handleResize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return height;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { useTerminalWidth, useTerminalHeight };
|
||||||
74
src/cli/ui/state/state.tsx
Normal file
74
src/cli/ui/state/state.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { EventEmitter } from 'eventemitter3';
|
||||||
|
import React, { createContext, useRef, useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
type StateEvents = {
|
||||||
|
update: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
class State<T> extends EventEmitter<StateEvents> {
|
||||||
|
state: T;
|
||||||
|
|
||||||
|
constructor(initialState: T) {
|
||||||
|
super();
|
||||||
|
this.state = initialState;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get value() {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setState = (state: T) => {
|
||||||
|
this.state = state;
|
||||||
|
this.emit('update');
|
||||||
|
};
|
||||||
|
|
||||||
|
public subscribe = (callback: () => void) => {
|
||||||
|
this.on('update', callback);
|
||||||
|
return () => {
|
||||||
|
this.off('update', callback);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type StateContextValue<T> = {
|
||||||
|
state: State<T>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const StateContext = createContext<StateContextValue<ExpectedAny> | null>(null);
|
||||||
|
|
||||||
|
type StateProviderProps<T> = {
|
||||||
|
state: State<T>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const StateProvider = <T,>({ state, children }: StateProviderProps<T>) => {
|
||||||
|
return <StateContext.Provider value={{ state }}>{children}</StateContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useStateContext = <T = ExpectedAny,>() => {
|
||||||
|
const context = React.useContext(StateContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useStateContext must be used within a StateProvider');
|
||||||
|
}
|
||||||
|
return context as StateContextValue<T>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useStateValue = <T = ExpectedAny,>(selector: (state: T) => ExpectedAny = (state) => state) => {
|
||||||
|
const context = useStateContext<T>();
|
||||||
|
const value = useRef<T>(selector(context.state.value));
|
||||||
|
useSyncExternalStore(
|
||||||
|
context.state.subscribe,
|
||||||
|
() => {
|
||||||
|
const next = selector(context.state.value);
|
||||||
|
if (next !== value.current) {
|
||||||
|
value.current = next;
|
||||||
|
}
|
||||||
|
return value.current;
|
||||||
|
},
|
||||||
|
() => value.current,
|
||||||
|
);
|
||||||
|
|
||||||
|
return value.current;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { State, StateProvider, useStateContext, useStateValue };
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import blessed from 'blessed';
|
|
||||||
import chalk from 'chalk';
|
|
||||||
|
|
||||||
class UI {
|
|
||||||
#box: blessed.Widgets.BoxElement;
|
|
||||||
#screen: blessed.Widgets.Screen;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
const screen = blessed.screen({
|
|
||||||
smartCSR: true,
|
|
||||||
title: 'Markdown Viewer'
|
|
||||||
});
|
|
||||||
const scrollableBox = blessed.box({ // Or blessed.scrollablebox
|
|
||||||
parent: screen,
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
content: '',
|
|
||||||
scrollable: true,
|
|
||||||
alwaysScroll: true,
|
|
||||||
keys: true,
|
|
||||||
vi: true, // vi-like keybindings
|
|
||||||
mouse: true,
|
|
||||||
scrollbar: {
|
|
||||||
ch: ' ',
|
|
||||||
track: {
|
|
||||||
bg: 'cyan'
|
|
||||||
},
|
|
||||||
style: {
|
|
||||||
inverse: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
style: {
|
|
||||||
fg: 'white',
|
|
||||||
bg: 'black'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.#box = scrollableBox;
|
|
||||||
this.#screen = screen;
|
|
||||||
|
|
||||||
screen.key(['escape', 'q', 'C-c'], () => {
|
|
||||||
return process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
scrollableBox.focus();
|
|
||||||
screen.render();
|
|
||||||
}
|
|
||||||
|
|
||||||
public get screen() {
|
|
||||||
return this.#screen;
|
|
||||||
}
|
|
||||||
|
|
||||||
public set content(content: string) {
|
|
||||||
const originalLines = content.split('\n');
|
|
||||||
const maxLineNoDigits = String(originalLines.length).length; // For padding
|
|
||||||
|
|
||||||
const linesWithNumbers = originalLines.map((line, index) => {
|
|
||||||
const lineNumber = String(index + 1).padStart(maxLineNoDigits, ' ');
|
|
||||||
const styledLineNumber = chalk.dim.yellow(`${lineNumber} | `);
|
|
||||||
return `${styledLineNumber}${line}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const contentWithLineNumbers = linesWithNumbers.join('\n');
|
|
||||||
this.#box.setContent(contentWithLineNumbers);
|
|
||||||
this.#screen.render();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { UI };
|
|
||||||
66
src/cli/ui/ui.tsx
Normal file
66
src/cli/ui/ui.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { Box, render, Text, useApp, useInput } from 'ink';
|
||||||
|
import { ScrollableMarkdown } from './components/scrollable-markdown.js';
|
||||||
|
import { useStateValue, State, StateProvider } from './state/state.js';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useTerminalHeight } from './hooks/terminal.js';
|
||||||
|
|
||||||
|
const MarkdownView = () => {
|
||||||
|
const markdown = useStateValue((state) => state.markdown);
|
||||||
|
const error = useStateValue((state) => state.error);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexGrow={1} flexDirection="column">
|
||||||
|
<Box flexDirection="column" flexGrow={1} overflow="hidden">
|
||||||
|
<ScrollableMarkdown markdownContent={markdown} />
|
||||||
|
</Box>
|
||||||
|
{error && (
|
||||||
|
<Box flexDirection="column" padding={1}>
|
||||||
|
<Text color="red">{error}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
const { exit } = useApp();
|
||||||
|
const height = useTerminalHeight();
|
||||||
|
useInput((_input, key) => {
|
||||||
|
if (key.escape) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
const enterAltScreenCommand = '\x1b[?1049h';
|
||||||
|
const leaveAltScreenCommand = '\x1b[?1049l';
|
||||||
|
process.stdout.write(enterAltScreenCommand);
|
||||||
|
const onExit = () => {
|
||||||
|
exit();
|
||||||
|
process.stdout.write(leaveAltScreenCommand);
|
||||||
|
};
|
||||||
|
process.on('exit', onExit);
|
||||||
|
return () => {
|
||||||
|
process.stdout.write(leaveAltScreenCommand);
|
||||||
|
process.off('exit', onExit);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box height={height} flexDirection="column">
|
||||||
|
<MarkdownView />
|
||||||
|
<Box>
|
||||||
|
<Text>Press esc to exit</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderUI = (state: State<ExpectedAny>) => {
|
||||||
|
render(
|
||||||
|
<StateProvider state={state}>
|
||||||
|
<App />
|
||||||
|
</StateProvider>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { renderUI, State };
|
||||||
61
src/cli/utils/input.ts
Normal file
61
src/cli/utils/input.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { extname } from 'path';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { readFile } from 'fs/promises';
|
||||||
|
|
||||||
|
import YAML from 'yaml';
|
||||||
|
|
||||||
|
import { FileNotFoundError, InvalidFileError } from '../../utils/errors.js';
|
||||||
|
|
||||||
|
const loadJsonFile = async (filePath: string) => {
|
||||||
|
const content = await readFile(filePath, 'utf-8');
|
||||||
|
return JSON.parse(content);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadYamlFile = async (filePath: string) => {
|
||||||
|
const content = await readFile(filePath, 'utf-8');
|
||||||
|
return YAML.parse(content);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadJsFile = async (filePath: string) => {
|
||||||
|
const { default: content } = await import(filePath);
|
||||||
|
return content;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadInputFiles = async (filePaths: string[]) => {
|
||||||
|
let inputs: Record<string, unknown> = {};
|
||||||
|
for (const filePath of filePaths) {
|
||||||
|
const type = extname(filePath);
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
throw new FileNotFoundError(filePath);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
switch (type) {
|
||||||
|
case '.json':
|
||||||
|
inputs = {
|
||||||
|
...inputs,
|
||||||
|
...(await loadJsonFile(filePath)),
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case '.yaml':
|
||||||
|
case '.yml':
|
||||||
|
inputs = {
|
||||||
|
...inputs,
|
||||||
|
...(await loadYamlFile(filePath)),
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case '.js':
|
||||||
|
inputs = {
|
||||||
|
...inputs,
|
||||||
|
...(await loadJsFile(filePath)),
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new InvalidFileError(filePath);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new InvalidFileError(filePath, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { loadInputFiles };
|
||||||
@@ -17,7 +17,7 @@ type AddRequestOptios = {
|
|||||||
request: Request;
|
request: Request;
|
||||||
response: Response;
|
response: Response;
|
||||||
id?: string;
|
id?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
type ContextOptions = {
|
type ContextOptions = {
|
||||||
input?: Record<string, unknown>;
|
input?: Record<string, unknown>;
|
||||||
@@ -29,7 +29,7 @@ type ContextOptions = {
|
|||||||
class Context {
|
class Context {
|
||||||
input: Record<string, unknown> = {};
|
input: Record<string, unknown> = {};
|
||||||
env: Record<string, unknown> = {};
|
env: Record<string, unknown> = {};
|
||||||
files: Set<string> = new Set();
|
files = new Set<string>();
|
||||||
requests: Record<string, Request> = {};
|
requests: Record<string, Request> = {};
|
||||||
responses: Record<string, Response> = {};
|
responses: Record<string, Response> = {};
|
||||||
request?: Request;
|
request?: Request;
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { readFile } from 'node:fs/promises';
|
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 behead from 'remark-behead';
|
|
||||||
import { unified } from 'unified'
|
|
||||||
import { visit } from 'unist-util-visit'
|
|
||||||
|
|
||||||
import { Context } from "../context/context.js";
|
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 behead from 'remark-behead';
|
||||||
|
import { unified } from 'unified';
|
||||||
|
import { visit } from 'unist-util-visit';
|
||||||
|
|
||||||
|
import { Context } from '../context/context.js';
|
||||||
|
|
||||||
import { handlers, postHandlers } from './handlers/handlers.js';
|
import { handlers, postHandlers } from './handlers/handlers.js';
|
||||||
|
|
||||||
type BaseNode = {
|
type BaseNode = {
|
||||||
@@ -20,21 +22,21 @@ type BaseNode = {
|
|||||||
meta?: string;
|
meta?: string;
|
||||||
lang?: string;
|
lang?: string;
|
||||||
value?: string;
|
value?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
type ExecutionStepOptions = {
|
type ExecutionStepOptions = {
|
||||||
file: string;
|
file: string;
|
||||||
input?: {};
|
input?: Record<string, unknown>;
|
||||||
context: Context;
|
context: Context;
|
||||||
root: Root;
|
root: Root;
|
||||||
node: BaseNode;
|
node: BaseNode;
|
||||||
}
|
};
|
||||||
|
|
||||||
type ExecutionStep = {
|
type ExecutionStep = {
|
||||||
type: string;
|
type: string;
|
||||||
node: BaseNode;
|
node: BaseNode;
|
||||||
action: (options: ExecutionStepOptions) => Promise<void>;
|
action: (options: ExecutionStepOptions) => Promise<void>;
|
||||||
}
|
};
|
||||||
|
|
||||||
type ExecutionHandler = (options: {
|
type ExecutionHandler = (options: {
|
||||||
file: string;
|
file: string;
|
||||||
@@ -48,14 +50,14 @@ type ExecutionHandler = (options: {
|
|||||||
type ExexutionExecuteOptions = {
|
type ExexutionExecuteOptions = {
|
||||||
context: Context;
|
context: Context;
|
||||||
behead?: number;
|
behead?: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
const execute = async (file: string, options: ExexutionExecuteOptions) => {
|
const execute = async (file: string, options: ExexutionExecuteOptions) => {
|
||||||
|
let error: unknown | undefined;
|
||||||
const { context } = options;
|
const { context } = options;
|
||||||
context.files.add(file);
|
context.files.add(file);
|
||||||
const content = await readFile(file, 'utf-8');
|
const content = await readFile(file, 'utf-8');
|
||||||
const steps: Set<ExecutionStep> = new Set();
|
const steps = new Set<ExecutionStep>();
|
||||||
|
|
||||||
|
|
||||||
const parser = unified()
|
const parser = unified()
|
||||||
.use(remarkParse)
|
.use(remarkParse)
|
||||||
@@ -94,6 +96,7 @@ const execute = async (file: string, options: ExexutionExecuteOptions) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const step of steps) {
|
for (const step of steps) {
|
||||||
|
try {
|
||||||
const { node, action } = step;
|
const { node, action } = step;
|
||||||
const options: ExecutionStepOptions = {
|
const options: ExecutionStepOptions = {
|
||||||
file,
|
file,
|
||||||
@@ -103,14 +106,19 @@ const execute = async (file: string, options: ExexutionExecuteOptions) => {
|
|||||||
root,
|
root,
|
||||||
};
|
};
|
||||||
await action(options);
|
await action(options);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const markdown = parser.stringify(root);
|
const markdown = parser.stringify(root);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
error,
|
||||||
root,
|
root,
|
||||||
markdown,
|
markdown,
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
export { execute, type ExecutionHandler };
|
export { execute, type ExecutionHandler };
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import Handlebars from "handlebars";
|
import Handlebars from 'handlebars';
|
||||||
import { ExecutionHandler } from "../execution.js";
|
|
||||||
|
|
||||||
const codeHandler: ExecutionHandler = ({
|
import { ExecutionHandler } from '../execution.js';
|
||||||
node,
|
|
||||||
addStep,
|
const codeHandler: ExecutionHandler = ({ node, addStep }) => {
|
||||||
}) => {
|
|
||||||
if (node.type !== 'code' || node.lang === 'http' || node.lang === 'javascript') {
|
if (node.type !== 'code' || node.lang === 'http' || node.lang === 'javascript') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -13,7 +11,7 @@ const codeHandler: ExecutionHandler = ({
|
|||||||
optionParts.filter(Boolean).map((option) => {
|
optionParts.filter(Boolean).map((option) => {
|
||||||
const [key, value] = option.split('=');
|
const [key, value] = option.split('=');
|
||||||
return [key.trim(), value?.trim() || true];
|
return [key.trim(), value?.trim() || true];
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
addStep({
|
addStep({
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import Handlebars from "handlebars";
|
import Handlebars from 'handlebars';
|
||||||
import YAML from "yaml";
|
import YAML from 'yaml';
|
||||||
import { ExecutionHandler } from "../execution.js";
|
|
||||||
|
|
||||||
const httpHandler: ExecutionHandler = ({
|
import { ExecutionHandler } from '../execution.js';
|
||||||
node,
|
|
||||||
addStep,
|
const httpHandler: ExecutionHandler = ({ node, addStep }) => {
|
||||||
}) => {
|
|
||||||
if (node.type !== 'code' || node.lang !== 'http') {
|
if (node.type !== 'code' || node.lang !== 'http') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -14,7 +12,7 @@ const httpHandler: ExecutionHandler = ({
|
|||||||
optionParts.filter(Boolean).map((option) => {
|
optionParts.filter(Boolean).map((option) => {
|
||||||
const [key, value] = option.split('=');
|
const [key, value] = option.split('=');
|
||||||
return [key.trim(), value?.trim() || true];
|
return [key.trim(), value?.trim() || true];
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
let id = options.id?.toString();
|
let id = options.id?.toString();
|
||||||
const idPart = optionParts.find((option) => option.startsWith('#'));
|
const idPart = optionParts.find((option) => option.startsWith('#'));
|
||||||
@@ -40,7 +38,7 @@ const httpHandler: ExecutionHandler = ({
|
|||||||
headerItems.map((header) => {
|
headerItems.map((header) => {
|
||||||
const [key, value] = header.split(':');
|
const [key, value] = header.split(':');
|
||||||
return [key.trim(), value?.trim() || ''];
|
return [key.trim(), value?.trim() || ''];
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
let parsedBody = body;
|
let parsedBody = body;
|
||||||
@@ -56,7 +54,7 @@ const httpHandler: ExecutionHandler = ({
|
|||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
body
|
body,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rawBody = await response.text();
|
const rawBody = await response.text();
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { toString } from 'mdast-util-to-string';
|
import { toString } from 'mdast-util-to-string';
|
||||||
import { type ExecutionHandler } from '../execution.js';
|
|
||||||
|
|
||||||
const inputHandler: ExecutionHandler = ({
|
import { type ExecutionHandler } from '../execution.js';
|
||||||
addStep,
|
import { ParsingError, RequiredError } from '../../utils/errors.js';
|
||||||
node,
|
|
||||||
parent,
|
const inputHandler: ExecutionHandler = ({ addStep, node, parent, index }) => {
|
||||||
index,
|
|
||||||
}) => {
|
|
||||||
if (node.type === 'leafDirective' && node.name === 'input') {
|
if (node.type === 'leafDirective' && node.name === 'input') {
|
||||||
addStep({
|
addStep({
|
||||||
type: 'input',
|
type: 'input',
|
||||||
@@ -15,7 +12,7 @@ const inputHandler: ExecutionHandler = ({
|
|||||||
const name = toString(node);
|
const name = toString(node);
|
||||||
|
|
||||||
if (node.attributes?.required === '' && context.input[name] === undefined) {
|
if (node.attributes?.required === '' && context.input[name] === undefined) {
|
||||||
throw new Error(`Input "${name}" is required`);
|
throw new RequiredError(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.attributes?.default !== undefined && context.input[name] === undefined) {
|
if (node.attributes?.default !== undefined && context.input[name] === undefined) {
|
||||||
@@ -27,7 +24,7 @@ const inputHandler: ExecutionHandler = ({
|
|||||||
if (format === 'number') {
|
if (format === 'number') {
|
||||||
context.input[name] = Number(context.input[name]);
|
context.input[name] = Number(context.input[name]);
|
||||||
if (context.input[name] !== undefined && isNaN(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]}"`);
|
throw new ParsingError(`Input "${name}" must be a number, but got "${context.input[name]}"`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (format === 'boolean') {
|
if (format === 'boolean') {
|
||||||
@@ -39,15 +36,16 @@ const inputHandler: ExecutionHandler = ({
|
|||||||
if (format === 'json') {
|
if (format === 'json') {
|
||||||
try {
|
try {
|
||||||
context.input[name] = JSON.parse(String(context.input[name]));
|
context.input[name] = JSON.parse(String(context.input[name]));
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Input "${name}" must be a valid JSON, but got "${context.input[name]}"`);
|
throw new ParsingError(`Input "${name}" must be a valid JSON, but got "${context.input[name]}"`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (format === 'date') {
|
if (format === 'date') {
|
||||||
const date = new Date(context.input[name] as string);
|
const date = new Date(context.input[name] as string);
|
||||||
if (isNaN(date.getTime())) {
|
if (isNaN(date.getTime())) {
|
||||||
throw new Error(`Input "${name}" must be a valid date, but got "${context.input[name]}"`);
|
throw new ParsingError(`Input "${name}" must be a valid date, but got "${context.input[name]}"`);
|
||||||
}
|
}
|
||||||
context.input[name] = date;
|
context.input[name] = date;
|
||||||
}
|
}
|
||||||
@@ -67,14 +65,10 @@ const inputHandler: ExecutionHandler = ({
|
|||||||
value: `${name}=${context.input[name] ?? '[undefined]'}`,
|
value: `${name}=${context.input[name] ?? '[undefined]'}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
parent.children?.splice(
|
parent.children?.splice(index, 1, newNode as ExpectedAny);
|
||||||
index,
|
|
||||||
1,
|
|
||||||
newNode as any,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export { inputHandler };
|
export { inputHandler };
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import Handlebars from "handlebars";
|
import Handlebars from 'handlebars';
|
||||||
import YAML from "yaml";
|
import YAML from 'yaml';
|
||||||
import { should, expect, assert } from 'chai';
|
import { should, expect, assert } from 'chai';
|
||||||
import { ExecutionHandler } from "../execution.js";
|
|
||||||
|
|
||||||
const javascriptHandler: ExecutionHandler = ({
|
import { ExecutionHandler } from '../execution.js';
|
||||||
node,
|
import { ScriptError } from '../../utils/errors.js';
|
||||||
parent,
|
|
||||||
index,
|
const javascriptHandler: ExecutionHandler = ({ node, parent, index, addStep }) => {
|
||||||
addStep,
|
|
||||||
}) => {
|
|
||||||
if (node.type !== 'code' || node.lang !== 'javascript') {
|
if (node.type !== 'code' || node.lang !== 'javascript') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -18,7 +15,7 @@ const javascriptHandler: ExecutionHandler = ({
|
|||||||
optionParts.filter(Boolean).map((option) => {
|
optionParts.filter(Boolean).map((option) => {
|
||||||
const [key, value] = option.split('=');
|
const [key, value] = option.split('=');
|
||||||
return [key.trim(), value?.trim() || true];
|
return [key.trim(), value?.trim() || true];
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
addStep({
|
addStep({
|
||||||
@@ -34,13 +31,9 @@ const javascriptHandler: ExecutionHandler = ({
|
|||||||
should,
|
should,
|
||||||
expect,
|
expect,
|
||||||
...context,
|
...context,
|
||||||
}
|
};
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line no-new-func
|
const asyncFunc = new Function(...Object.keys(api), `return (async () => { ${content} })()`);
|
||||||
const asyncFunc = new Function(
|
|
||||||
...Object.keys(api),
|
|
||||||
`return (async () => { ${content} })()`
|
|
||||||
);
|
|
||||||
const result = await asyncFunc(...Object.values(api));
|
const result = await asyncFunc(...Object.values(api));
|
||||||
if (options.output === true && index !== undefined) {
|
if (options.output === true && index !== undefined) {
|
||||||
if (result !== undefined) {
|
if (result !== undefined) {
|
||||||
@@ -60,7 +53,7 @@ const javascriptHandler: ExecutionHandler = ({
|
|||||||
meta: undefined,
|
meta: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
throw error;
|
throw new ScriptError(error instanceof Error ? error.message : String(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (options.hidden === true && parent && index !== undefined) {
|
if (options.hidden === true && parent && index !== undefined) {
|
||||||
|
|||||||
@@ -1,25 +1,20 @@
|
|||||||
import { dirname, resolve } from 'path';
|
import { dirname, resolve } from 'path';
|
||||||
import { toString } from 'mdast-util-to-string'
|
import { existsSync } from 'fs';
|
||||||
import { execute, type ExecutionHandler } from '../execution.js';
|
|
||||||
|
|
||||||
const fileHandler: ExecutionHandler = ({
|
import { toString } from 'mdast-util-to-string';
|
||||||
addStep,
|
|
||||||
node,
|
import { execute, type ExecutionHandler } from '../execution.js';
|
||||||
parent,
|
import { FileNotFoundError } from '../../utils/errors.js';
|
||||||
index,
|
|
||||||
file,
|
const fileHandler: ExecutionHandler = ({ addStep, node, parent, index, file }) => {
|
||||||
}) => {
|
|
||||||
if (node.type === 'leafDirective' && node.name === 'md') {
|
if (node.type === 'leafDirective' && node.name === 'md') {
|
||||||
addStep({
|
addStep({
|
||||||
type: 'file',
|
type: 'file',
|
||||||
node,
|
node,
|
||||||
action: async ({ context }) => {
|
action: async ({ context }) => {
|
||||||
const filePath = resolve(
|
const filePath = resolve(dirname(file), toString(node));
|
||||||
dirname(file),
|
if (!existsSync(filePath)) {
|
||||||
toString(node)
|
throw new FileNotFoundError(filePath);
|
||||||
);
|
|
||||||
if (!filePath) {
|
|
||||||
throw new Error('File path is required');
|
|
||||||
}
|
}
|
||||||
const { root: newRoot } = await execute(filePath, {
|
const { root: newRoot } = await execute(filePath, {
|
||||||
context,
|
context,
|
||||||
@@ -35,10 +30,10 @@ const fileHandler: ExecutionHandler = ({
|
|||||||
parent.children?.splice(index, 1);
|
parent.children?.splice(index, 1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
parent.children?.splice(index, 1, ...newRoot.children as any);
|
parent.children?.splice(index, 1, ...(newRoot.children as ExpectedAny[]));
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export { fileHandler };
|
export { fileHandler };
|
||||||
|
|||||||
@@ -1,25 +1,23 @@
|
|||||||
import { readFile } from 'fs/promises';
|
import { readFile } from 'fs/promises';
|
||||||
import { Context } from '../../context/context.js';
|
|
||||||
import { execute, type ExecutionHandler } from '../execution.js';
|
|
||||||
import { dirname, resolve } from 'path';
|
import { dirname, resolve } from 'path';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
|
||||||
import { toString } from 'mdast-util-to-string';
|
import { toString } from 'mdast-util-to-string';
|
||||||
|
|
||||||
const rawMdHandler: ExecutionHandler = ({
|
import { Context } from '../../context/context.js';
|
||||||
addStep,
|
import { execute, type ExecutionHandler } from '../execution.js';
|
||||||
node,
|
import { FileNotFoundError } from '../../utils/errors.js';
|
||||||
parent,
|
|
||||||
index,
|
const rawMdHandler: ExecutionHandler = ({ addStep, node, parent, index, file }) => {
|
||||||
file,
|
|
||||||
}) => {
|
|
||||||
if (node.type === 'leafDirective' && node.name === 'raw-md') {
|
if (node.type === 'leafDirective' && node.name === 'raw-md') {
|
||||||
addStep({
|
addStep({
|
||||||
type: 'raw-md',
|
type: 'raw-md',
|
||||||
node,
|
node,
|
||||||
action: async ({ context: parentContext }) => {
|
action: async ({ context: parentContext }) => {
|
||||||
const name = resolve(
|
const name = resolve(dirname(file), toString(node));
|
||||||
dirname(file),
|
if (!existsSync(name)) {
|
||||||
toString(node),
|
throw new FileNotFoundError(name);
|
||||||
);
|
}
|
||||||
const context = new Context({
|
const context = new Context({
|
||||||
input: {},
|
input: {},
|
||||||
});
|
});
|
||||||
@@ -46,14 +44,10 @@ const rawMdHandler: ExecutionHandler = ({
|
|||||||
throw new Error('Parent node is required');
|
throw new Error('Parent node is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
parent.children?.splice(
|
parent.children?.splice(index, 1, newNode as ExpectedAny);
|
||||||
index,
|
|
||||||
1,
|
|
||||||
newNode as any,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export { rawMdHandler };
|
export { rawMdHandler };
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
import YAML from 'yaml';
|
import YAML from 'yaml';
|
||||||
|
|
||||||
import { type ExecutionHandler } from '../execution.js';
|
import { type ExecutionHandler } from '../execution.js';
|
||||||
|
|
||||||
const responseHandler: ExecutionHandler = ({
|
const responseHandler: ExecutionHandler = ({ addStep, node, parent, index }) => {
|
||||||
addStep,
|
|
||||||
node,
|
|
||||||
parent,
|
|
||||||
index,
|
|
||||||
}) => {
|
|
||||||
if (node.type === 'leafDirective' && node.name === 'response') {
|
if (node.type === 'leafDirective' && node.name === 'response') {
|
||||||
addStep({
|
addStep({
|
||||||
type: 'file',
|
type: 'file',
|
||||||
node,
|
node,
|
||||||
action: async ({ context }) => {
|
action: async ({ context }) => {
|
||||||
const response = node.attributes?.id ?
|
const response = node.attributes?.id ? context.responses[node.attributes.id] : context.response;
|
||||||
context.responses[node.attributes.id] : context.response
|
|
||||||
|
|
||||||
if (!response) {
|
if (!response) {
|
||||||
return;
|
return;
|
||||||
@@ -60,14 +55,10 @@ const responseHandler: ExecutionHandler = ({
|
|||||||
throw new Error('Parent node is required');
|
throw new Error('Parent node is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
parent.children?.splice(
|
parent.children?.splice(index, 1, codeNode as ExpectedAny);
|
||||||
index,
|
|
||||||
1,
|
|
||||||
codeNode as any,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export { responseHandler };
|
export { responseHandler };
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { type ExecutionHandler } from '../execution.js';
|
import Handlebars from 'handlebars';
|
||||||
import Handlebars from "handlebars";
|
|
||||||
|
|
||||||
const textHandler: ExecutionHandler = ({
|
import { type ExecutionHandler } from '../execution.js';
|
||||||
addStep,
|
|
||||||
node,
|
const textHandler: ExecutionHandler = ({ addStep, node }) => {
|
||||||
}) => {
|
|
||||||
if (node.type === 'text') {
|
if (node.type === 'text') {
|
||||||
addStep({
|
addStep({
|
||||||
type: 'parse-text',
|
type: 'parse-text',
|
||||||
@@ -14,8 +12,8 @@ const textHandler: ExecutionHandler = ({
|
|||||||
const content = template(context);
|
const content = template(context);
|
||||||
node.value = content;
|
node.value = content;
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export { textHandler };
|
export { textHandler };
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import { toc } from 'mdast-util-toc';
|
import { toc } from 'mdast-util-toc';
|
||||||
|
|
||||||
import { type ExecutionHandler } from '../execution.js';
|
import { type ExecutionHandler } from '../execution.js';
|
||||||
|
|
||||||
const tocHandler: ExecutionHandler = ({
|
const tocHandler: ExecutionHandler = ({ addStep, node, root, parent, index }) => {
|
||||||
addStep,
|
|
||||||
node,
|
|
||||||
root,
|
|
||||||
parent,
|
|
||||||
index,
|
|
||||||
}) => {
|
|
||||||
if (node.type === 'leafDirective' && node.name === 'toc') {
|
if (node.type === 'leafDirective' && node.name === 'toc') {
|
||||||
addStep({
|
addStep({
|
||||||
type: 'toc',
|
type: 'toc',
|
||||||
@@ -16,14 +11,14 @@ const tocHandler: ExecutionHandler = ({
|
|||||||
const result = toc(root, {
|
const result = toc(root, {
|
||||||
tight: true,
|
tight: true,
|
||||||
minDepth: 2,
|
minDepth: 2,
|
||||||
})
|
});
|
||||||
if (!parent || !parent.children || index === undefined) {
|
if (!parent || !parent.children || index === undefined) {
|
||||||
throw new Error('Parent node is not valid');
|
throw new Error('Parent node is not valid');
|
||||||
}
|
}
|
||||||
parent.children.splice(index, 1, result.map as any);
|
parent.children.splice(index, 1, result.map as ExpectedAny);
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export { tocHandler };
|
export { tocHandler };
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { ExecutionHandler } from "../execution.js";
|
import { ExecutionHandler } from '../execution.js';
|
||||||
import { fileHandler } from "./handlers.md.js";
|
|
||||||
import { httpHandler } from "./handlers.http.js";
|
import { fileHandler } from './handlers.md.js';
|
||||||
import { inputHandler } from "./handlers.input.js";
|
import { httpHandler } from './handlers.http.js';
|
||||||
import { rawMdHandler } from "./handlers.raw-md.js";
|
import { inputHandler } from './handlers.input.js';
|
||||||
import { responseHandler } from "./handlers.response.js";
|
import { rawMdHandler } from './handlers.raw-md.js';
|
||||||
import { textHandler } from "./handlers.text.js";
|
import { responseHandler } from './handlers.response.js';
|
||||||
import { codeHandler } from "./handlers.code.js";
|
import { textHandler } from './handlers.text.js';
|
||||||
import { tocHandler } from "./handlers.toc.js";
|
import { codeHandler } from './handlers.code.js';
|
||||||
import { javascriptHandler } from "./handlers.javascript.js";
|
import { tocHandler } from './handlers.toc.js';
|
||||||
|
import { javascriptHandler } from './handlers.javascript.js';
|
||||||
|
|
||||||
const handlers = [
|
const handlers = [
|
||||||
fileHandler,
|
fileHandler,
|
||||||
@@ -20,8 +21,6 @@ const handlers = [
|
|||||||
javascriptHandler,
|
javascriptHandler,
|
||||||
] satisfies ExecutionHandler[];
|
] satisfies ExecutionHandler[];
|
||||||
|
|
||||||
const postHandlers = [
|
const postHandlers = [tocHandler] satisfies ExecutionHandler[];
|
||||||
tocHandler,
|
|
||||||
] satisfies ExecutionHandler[];
|
|
||||||
|
|
||||||
export { handlers, postHandlers };
|
export { handlers, postHandlers };
|
||||||
|
|||||||
@@ -1,16 +1,2 @@
|
|||||||
|
export { Context } from './context/context.js';
|
||||||
import { inspect } from "node:util";
|
export { execute } from './execution/execution.js';
|
||||||
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);
|
|
||||||
|
|||||||
2
src/types.d.ts
vendored
Normal file
2
src/types.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||||
|
declare type ExpectedAny = any;
|
||||||
51
src/utils/errors.ts
Normal file
51
src/utils/errors.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
class BaseError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = this.constructor.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FileNotFoundError extends BaseError {
|
||||||
|
constructor(filePath: string) {
|
||||||
|
super(`File not found: ${filePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class InvalidFileError extends BaseError {
|
||||||
|
#baseError?: unknown;
|
||||||
|
|
||||||
|
constructor(filePath: string, baseError?: unknown) {
|
||||||
|
super(`Invalid file: ${filePath}`);
|
||||||
|
this.#baseError = baseError;
|
||||||
|
}
|
||||||
|
|
||||||
|
get baseError() {
|
||||||
|
return this.#baseError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class InvalidFormatError extends BaseError {
|
||||||
|
constructor(format: string) {
|
||||||
|
super(`Invalid format: ${format}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScriptError extends BaseError {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(`Script error: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ParsingError extends BaseError {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(`Parsing error: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RequiredError extends BaseError {
|
||||||
|
constructor(name: string) {
|
||||||
|
super(`Required input "${name}" is missing`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { BaseError, FileNotFoundError, InvalidFileError, InvalidFormatError, ScriptError, ParsingError, RequiredError };
|
||||||
@@ -1,23 +1,24 @@
|
|||||||
import { EventEmitter } from "eventemitter3";
|
import { FSWatcher, watch } from 'node:fs';
|
||||||
import { FSWatcher, watch } from "node:fs";
|
|
||||||
|
import { EventEmitter } from 'eventemitter3';
|
||||||
|
|
||||||
type WatcherEvent = {
|
type WatcherEvent = {
|
||||||
changed: () => void;
|
changed: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
class Watcher extends EventEmitter<WatcherEvent> {
|
class Watcher extends EventEmitter<WatcherEvent> {
|
||||||
#watching: Map<string, FSWatcher> = new Map()
|
#watching = new Map<string, FSWatcher>();
|
||||||
public watchFiles = (files: string[]) => {
|
public watchFiles = (files: string[]) => {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (this.#watching.has(file)) {
|
if (this.#watching.has(file)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const watcher = watch(file, () => {
|
const watcher = watch(file, () => {
|
||||||
this.emit("changed");
|
this.emit('changed');
|
||||||
});
|
});
|
||||||
this.#watching.set(file, watcher);
|
this.#watching.set(file, watcher);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Watcher }
|
export { Watcher };
|
||||||
|
|||||||
711
tests/__snapshots__/execute.test.ts.snap
Normal file
711
tests/__snapshots__/execute.test.ts.snap
Normal file
@@ -0,0 +1,711 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`execute > should correctly render the readme file 1`] = `
|
||||||
|
"# 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.
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
* **Programmatic API** Use \`http.md\` inside existing scripts and pipelines
|
||||||
|
* **Environment Varaiables** Support using the runners environment variables in templates
|
||||||
|
* **JavaScript script support** Add JavaScript code blocks with execution, which will allow more advanced use-cases
|
||||||
|
* **Asserts** Add the ability to make HTTP assertions to use the document for testing
|
||||||
|
* **Templates** Write re-usable templates which can be used in documents
|
||||||
|
|
||||||
|
## Content
|
||||||
|
|
||||||
|
* [Key Features](#key-features)
|
||||||
|
* [Use Cases](#use-cases)
|
||||||
|
* [Roadmap](#roadmap)
|
||||||
|
* [Content](#content)
|
||||||
|
* [Installation](#installation)
|
||||||
|
* [Getting Started](#getting-started)
|
||||||
|
* [Your First Request](#your-first-request)
|
||||||
|
* [Rendering Document](#rendering-document)
|
||||||
|
* [Core Concepts](#core-concepts)
|
||||||
|
* [HTTP Request Blocks](#http-request-blocks)
|
||||||
|
* [The \`::response\` Directive](#the-response-directive)
|
||||||
|
* [Request IDs](#request-ids)
|
||||||
|
* [Templating with Handlebars](#templating-with-handlebars)
|
||||||
|
* [Available Variables for Templating](#available-variables-for-templating)
|
||||||
|
* [Templating Examples](#templating-examples)
|
||||||
|
* [Managing Documents](#managing-documents)
|
||||||
|
* [Embedding Other Documents (\`::md\`)](#embedding-other-documents-md)
|
||||||
|
* [Advanced Usage](#advanced-usage)
|
||||||
|
* [Using Input Variables](#using-input-variables)
|
||||||
|
* [JavaScript Execution](#javascript-execution)
|
||||||
|
* [HTTP Block Configuration Options](#http-block-configuration-options)
|
||||||
|
* [Directive Options](#directive-options)
|
||||||
|
* [\`::response\` Directive Options](#response-directive-options)
|
||||||
|
* [\`::md[{file}]\` Directive Options](#mdfile-directive-options)
|
||||||
|
* [\`::input[{name}]\` Directive Options](#inputname-directive-options)
|
||||||
|
* [Command-Line Interface (CLI)](#command-line-interface-cli)
|
||||||
|
* [\`http.md dev <source_file.md>\`](#httpmd-dev-source_filemd)
|
||||||
|
* [\`http.md build <source_file.md> <output_file.md>\`](#httpmd-build-source_filemd-output_filemd)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Install \`http.md\` globally using npm:
|
||||||
|
|
||||||
|
\`\`\`shell
|
||||||
|
npm i -g @morten-olsen/http.md
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## 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 Document
|
||||||
|
|
||||||
|
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
|
||||||
|
http.md dev example.md
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
With watch mode:
|
||||||
|
|
||||||
|
\`\`\`shell
|
||||||
|
http.md 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
|
||||||
|
http.md build example.md output.md
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
With watch mode:
|
||||||
|
|
||||||
|
\`\`\`shell
|
||||||
|
http.md 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
|
||||||
|
HTTP/200 OK
|
||||||
|
content-length: 161
|
||||||
|
content-type: text/plain
|
||||||
|
|
||||||
|
{
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"data": "{\\"greeting\\": \\"Hello, http.md!\\"}",
|
||||||
|
"json": {
|
||||||
|
"greeting": "Hello, http.md!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
\`\`\`\`
|
||||||
|
|
||||||
|
*(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 \`#yourUniqueId\` or \`id=yourUniqueId\` in the \`http\` block's info string:
|
||||||
|
|
||||||
|
\`\`\`\`markdown
|
||||||
|
# Document with Multiple Requests
|
||||||
|
|
||||||
|
First, create a resource:
|
||||||
|
|
||||||
|
\`\`\`http #createUser,yaml,json
|
||||||
|
POST https://httpbin.org/post
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
username: alpha
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Then, fetch a different resource:
|
||||||
|
|
||||||
|
\`\`\`http #getItem
|
||||||
|
GET https://httpbin.org/get?item=123
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Response from creating the user:
|
||||||
|
::response{#createUser}
|
||||||
|
|
||||||
|
Response from getting the item:
|
||||||
|
::response{#getItem}
|
||||||
|
|
||||||
|
\`\`\`\`
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Output</summary>
|
||||||
|
|
||||||
|
\`\`\`\`markdown
|
||||||
|
# Document with Multiple Requests
|
||||||
|
|
||||||
|
First, create a resource:
|
||||||
|
|
||||||
|
\`\`\`http
|
||||||
|
POST https://httpbin.org/post
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"username":"alpha"}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Then, fetch a different resource:
|
||||||
|
|
||||||
|
\`\`\`http
|
||||||
|
GET https://httpbin.org/get?item=123
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Response from creating the user:
|
||||||
|
|
||||||
|
\`\`\`http
|
||||||
|
HTTP/200 OK
|
||||||
|
content-length: 90
|
||||||
|
content-type: text/plain
|
||||||
|
|
||||||
|
{
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"data": "username: alpha"
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Response from getting the item:
|
||||||
|
|
||||||
|
\`\`\`http
|
||||||
|
HTTP/200 OK
|
||||||
|
content-length: 33
|
||||||
|
content-type: text/plain
|
||||||
|
|
||||||
|
{
|
||||||
|
"headers": {},
|
||||||
|
"data": ""
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
\`\`\`\`
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## 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 \`http.md 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 #createItem,json
|
||||||
|
POST https://httpbin.org/post
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"name": "My New Item"}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
The new item ID is: {{response.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}
|
||||||
|
|
||||||
|
\`\`\`\`
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Output</summary>
|
||||||
|
|
||||||
|
\`\`\`\`markdown
|
||||||
|
\`\`\`http
|
||||||
|
POST https://httpbin.org/post
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"name": "My New Item"}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
The new item ID is: My New Item
|
||||||
|
|
||||||
|
Now, let's fetch the item using a (mocked) ID from the response:
|
||||||
|
|
||||||
|
\`\`\`http
|
||||||
|
GET https://httpbin.org/anything/My New Item
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
\`\`\`http
|
||||||
|
HTTP/200 OK
|
||||||
|
content-length: 33
|
||||||
|
content-type: text/plain
|
||||||
|
|
||||||
|
{
|
||||||
|
"headers": {},
|
||||||
|
"data": ""
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
\`\`\`\`
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
*(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: ****.
|
||||||
|
\`\`\`\`
|
||||||
|
|
||||||
|
## 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 #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: {{response.statusText}}
|
||||||
|
|
||||||
|
Now, a request specific to this document:
|
||||||
|
|
||||||
|
\`\`\`http
|
||||||
|
POST https://httpbin.org/post
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"dataFromMain": "someValue", "sharedUrl": "{{requests.sharedGetRequest.url}}"}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
::response
|
||||||
|
|
||||||
|
\`\`\`\`
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Output</summary>
|
||||||
|
|
||||||
|
\`\`\`\`markdown
|
||||||
|
# Main Document
|
||||||
|
|
||||||
|
Let's include some shared requests:
|
||||||
|
|
||||||
|
\`\`\`http
|
||||||
|
GET https://httpbin.org/get
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
The shared GET request returned: OK
|
||||||
|
|
||||||
|
Now, a request specific to this document:
|
||||||
|
|
||||||
|
\`\`\`http
|
||||||
|
POST https://httpbin.org/post
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"dataFromMain": "someValue", "sharedUrl": "https://httpbin.org/get"}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
\`\`\`http
|
||||||
|
HTTP/200 OK
|
||||||
|
content-length: 245
|
||||||
|
content-type: text/plain
|
||||||
|
|
||||||
|
{
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"data": "{\\"dataFromMain\\": \\"someValue\\", \\"sharedUrl\\": \\"https://httpbin.org/get\\"}",
|
||||||
|
"json": {
|
||||||
|
"dataFromMain": "someValue",
|
||||||
|
"sharedUrl": "https://httpbin.org/get"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
\`\`\`\`
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
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
|
||||||
|
http.md build mydoc.md output.md -i baseUrl=https://api.production.example.com -i apiKey=YOUR_SECRET_KEY
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
**Markdown Usage (\`mydoc.md\`):**
|
||||||
|
|
||||||
|
\`\`\`\`markdown
|
||||||
|
\`\`\`http
|
||||||
|
GET /users/1
|
||||||
|
Authorization: Bearer
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
::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.
|
||||||
|
|
||||||
|
### JavaScript Execution
|
||||||
|
|
||||||
|
You can execute \`javascript\` blocks by adding a \`run\` option which allows programmatically changing the context, making request assertions and solve other more advanced use cases
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
\`\`\`\`markdown
|
||||||
|
\`\`\`javascript run
|
||||||
|
input.test = "Hello World";
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
::input[test]
|
||||||
|
|
||||||
|
\`\`\`http json
|
||||||
|
POST https://httpbin.org/post
|
||||||
|
|
||||||
|
{"input": "{{input.test}}"}
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
\`\`\`javascript run,hidden
|
||||||
|
// Use chai's \`expect\`, \`assert\` or \`should\` to make assumptions
|
||||||
|
expect(response.body.json.input).to.equal("Hello World");
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
\`\`\`\`
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Output</summary>
|
||||||
|
|
||||||
|
\`\`\`\`markdown
|
||||||
|
\`\`\`javascript
|
||||||
|
input.test = "Hello World";
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
test=Hello World
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
\`\`\`http
|
||||||
|
POST https://httpbin.org/post
|
||||||
|
|
||||||
|
{"input": "Hello World"}
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
\`\`\`\`
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
|
||||||
|
* \`run\`: If present the code block will be executed
|
||||||
|
|
||||||
|
* Example: \` \`\`\`javascript run \`
|
||||||
|
|
||||||
|
* \`hidden\`: If present the code block will not be included in the resulting output
|
||||||
|
|
||||||
|
* Example: \` \`\`\`javascript hidden \`
|
||||||
|
|
||||||
|
* \`output\`: If present the code blocks return value will be rendered as a \`yaml\` code block
|
||||||
|
|
||||||
|
### 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 /data
|
||||||
|
X-API-Key:
|
||||||
|
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}\`
|
||||||
|
|
||||||
|
#### \`::input[{name}]\` Directive Options
|
||||||
|
|
||||||
|
The \`::input\` directive is used to declare expected input variables
|
||||||
|
|
||||||
|
* **Variable Name:** The first argument (required) is the name of the variable
|
||||||
|
* Example: \`::input[myVariable]\` will define \`input.myVariable\`
|
||||||
|
* \`required\`: If present it will require that the variable is provided
|
||||||
|
* \`default={value}\`: Defines the default value if no value has been provided
|
||||||
|
* \`format=string|number|bool|json|date\`: If provided the value will be parsed using the specified format
|
||||||
|
* \\\`\\\`
|
||||||
|
|
||||||
|
## Command-Line Interface (CLI)
|
||||||
|
|
||||||
|
The \`http.md\` tool provides the following commands:
|
||||||
|
|
||||||
|
### \`http.md 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
|
||||||
|
http.md dev api_tests.md --watch -i host=localhost:3000
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### \`http.md 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
|
||||||
|
http.md build official_api_docs.md public/api_docs_v1.md -i version=v1.0
|
||||||
|
\`\`\`
|
||||||
|
"
|
||||||
|
`;
|
||||||
23
tests/execute.test.ts
Normal file
23
tests/execute.test.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest';
|
||||||
|
|
||||||
|
import { Context, execute } from '../src/exports.js';
|
||||||
|
|
||||||
|
import { server } from './mocks/node.js';
|
||||||
|
|
||||||
|
describe('execute', () => {
|
||||||
|
beforeAll(() => server.listen());
|
||||||
|
afterEach(() => server.resetHandlers());
|
||||||
|
afterAll(() => server.close());
|
||||||
|
|
||||||
|
it('should correctly render the readme file', async () => {
|
||||||
|
const context = new Context();
|
||||||
|
const filePath = resolve(__dirname, '..', 'docs', 'README.md');
|
||||||
|
const result = await execute(filePath, {
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.markdown).toMatchSnapshot();
|
||||||
|
}, 30000);
|
||||||
|
});
|
||||||
25
tests/mocks/handlers.ts
Normal file
25
tests/mocks/handlers.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
|
||||||
|
export const handlers = [
|
||||||
|
http.all('https://httpbin.org/*', async (req) => {
|
||||||
|
const bodyRaw = await req.request.text();
|
||||||
|
let bodyJson: unknown = undefined;
|
||||||
|
try {
|
||||||
|
bodyJson = JSON.parse(bodyRaw);
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore error
|
||||||
|
}
|
||||||
|
return HttpResponse.text(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
headers: Object.fromEntries(req.request.headers.entries()),
|
||||||
|
data: bodyRaw,
|
||||||
|
json: bodyJson,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
];
|
||||||
5
tests/mocks/node.ts
Normal file
5
tests/mocks/node.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { setupServer } from 'msw/node';
|
||||||
|
|
||||||
|
import { handlers } from './handlers.js';
|
||||||
|
|
||||||
|
export const server = setupServer(...handlers);
|
||||||
@@ -10,7 +10,11 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"outDir": "dist"
|
"outDir": "dist",
|
||||||
|
"jsx": "react-jsx"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"include": [
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/cli/ui/ui.tsx"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
15
vitest.config.ts
Normal file
15
vitest.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
const config = defineConfig({
|
||||||
|
test: {
|
||||||
|
coverage: {
|
||||||
|
reporter: ['text', 'json', 'json-summary', 'html'],
|
||||||
|
all: true,
|
||||||
|
reportOnFailure: true,
|
||||||
|
include: ['src/**/*.ts'],
|
||||||
|
exclude: ['src/cli/**/*.ts'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default config;
|
||||||
Reference in New Issue
Block a user