diff --git a/docs/README.md b/docs/README.md index 0bbd3c7..2fe2c09 100644 --- a/docs/README.md +++ b/docs/README.md @@ -211,7 +211,7 @@ _(Note: `httpbin.org/post` wraps the JSON sent in a "json" field in its response GET https://httpbin.org/status/201 ``` -The request to `/status/201` completed with status code: **{{response.status}}**. +The request to `/status/201` completed with status code: **{{{response.status}}}**. ```` ## Managing Documents @@ -259,8 +259,8 @@ http.md build mydoc.md output.md -i baseUrl=https://api.production.example.com - ````markdown ```http -GET {{input.baseUrl}}/users/1 -Authorization: Bearer {{input.apiKey}} +GET {{{input.baseUrl}}}/users/1 +Authorization: Bearer {{{input.apiKey}}} ``` ::response @@ -268,6 +268,33 @@ Authorization: Bearer {{input.apiKey}} **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:** + +::raw-md[./examples/with-javascript.md] + +
+ Output + +::raw-md[./examples/with-javascript.md]{run} + +
+ +**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. @@ -309,8 +336,8 @@ You can configure the behavior of each `http` code block by adding options to it ````markdown ```http id=complexRequest,json,yaml,hidden -POST {{input.apiEndpoint}}/data -X-API-Key: {{input.apiKey}} +POST {{{input.apiEndpoint}}}/data +X-API-Key: {{{input.apiKey}}} Content-Type: application/json # Request body written in YAML, will be converted to JSON diff --git a/docs/examples/with-javascript.md b/docs/examples/with-javascript.md new file mode 100644 index 0000000..a9ae31b --- /dev/null +++ b/docs/examples/with-javascript.md @@ -0,0 +1,17 @@ +```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"); +``` diff --git a/package.json b/package.json index b999c82..91f7ec5 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "devDependencies": { "@pnpm/find-workspace-packages": "^6.0.9", "@types/blessed": "^0.1.25", + "@types/chai": "^5.2.2", "@types/marked-terminal": "^6.1.1", "@types/mdast": "^4.0.4", "@types/node": "^22.15.18", @@ -36,6 +37,7 @@ }, "dependencies": { "blessed": "^0.1.81", + "chai": "^5.2.0", "chalk": "^5.4.1", "commander": "^14.0.0", "dotenv": "^16.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de29f63..aac4e83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: blessed: specifier: ^0.1.81 version: 0.1.81 + chai: + specifier: ^5.2.0 + version: 5.2.0 chalk: specifier: ^5.4.1 version: 5.4.1 @@ -84,6 +87,9 @@ importers: '@types/blessed': specifier: ^0.1.25 version: 0.1.25 + '@types/chai': + specifier: ^5.2.2 + version: 5.2.2 '@types/marked-terminal': specifier: ^6.1.1 version: 6.1.1 @@ -437,9 +443,15 @@ packages: '@types/cardinal@2.1.1': resolution: {integrity: sha512-/xCVwg8lWvahHsV2wXZt4i64H1sdL+sN1Uoq7fAc8/FA6uYHjuIveDwPwvGUYp4VZiv85dVl6J/Bum3NDAOm8g==} + '@types/chai@5.2.2': + resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -522,6 +534,10 @@ packages: as-table@1.0.55: resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -564,6 +580,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@5.2.0: + resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==} + engines: {node: '>=12'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -588,6 +608,10 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + check-error@2.1.1: + resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + engines: {node: '>= 16'} + chroma-js@2.6.0: resolution: {integrity: sha512-BLHvCB9s8Z1EV4ethr6xnkl/P2YRFOGqfgvuMG/MyCbZPrTA+NeiByY6XvgF0zP4/2deU2CXnWyMa3zu1LqQ3A==} @@ -658,6 +682,10 @@ packages: decode-named-character-reference@1.1.0: resolution: {integrity: sha512-Wy+JTSbFThEOXQIR2L6mxJvEs+veIzpmqD7ynWxMXGpnk3smkHQOp6forLdHsKpAMW9iJpaBBIxz285t1n1C3w==} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} @@ -911,6 +939,9 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loupe@3.1.3: + resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==} + map-age-cleaner@0.1.3: resolution: {integrity: sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==} engines: {node: '>=6'} @@ -1194,6 +1225,10 @@ packages: resolution: {integrity: sha512-cMMJTAZlion/RWRRC48UbrDymEIt+/YSD/l8NqjneyDw2rDOBQcP5yRkMB4CYGn47KMhZvbblBP7Z79OsMw72w==} engines: {node: '>=8.15'} + pathval@2.0.0: + resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1916,10 +1951,16 @@ snapshots: '@types/cardinal@2.1.1': {} + '@types/chai@5.2.2': + dependencies: + '@types/deep-eql': 4.0.2 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -2000,6 +2041,8 @@ snapshots: dependencies: printable-characters: 1.0.42 + assertion-error@2.0.1: {} + bail@2.0.2: {} better-path-resolve@1.0.0: @@ -2044,6 +2087,14 @@ snapshots: ccount@2.0.1: {} + chai@5.2.0: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.1.3 + pathval: 2.0.0 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -2061,6 +2112,8 @@ snapshots: character-reference-invalid@2.0.1: {} + check-error@2.1.1: {} + chroma-js@2.6.0: {} cli-boxes@2.2.1: {} @@ -2130,6 +2183,8 @@ snapshots: dependencies: character-entities: 2.0.2 + deep-eql@5.0.2: {} + defaults@1.0.4: dependencies: clone: 1.0.4 @@ -2377,6 +2432,8 @@ snapshots: longest-streak@3.1.0: {} + loupe@3.1.3: {} + map-age-cleaner@0.1.3: dependencies: p-defer: 1.0.0 @@ -2859,6 +2916,8 @@ snapshots: dependencies: unique-string: 2.0.0 + pathval@2.0.0: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} diff --git a/src/execution/handlers/handlers.code.ts b/src/execution/handlers/handlers.code.ts index f397a3c..ef0cdcd 100644 --- a/src/execution/handlers/handlers.code.ts +++ b/src/execution/handlers/handlers.code.ts @@ -5,7 +5,7 @@ const codeHandler: ExecutionHandler = ({ node, addStep, }) => { - if (node.type !== 'code' || node.lang === 'http') { + if (node.type !== 'code' || node.lang === 'http' || node.lang === 'javascript') { return; } const optionParts = node.meta?.split(',') || []; diff --git a/src/execution/handlers/handlers.javascript.ts b/src/execution/handlers/handlers.javascript.ts new file mode 100644 index 0000000..3c6623b --- /dev/null +++ b/src/execution/handlers/handlers.javascript.ts @@ -0,0 +1,73 @@ +import Handlebars from "handlebars"; +import YAML from "yaml"; +import { should, expect, assert } from 'chai'; +import { ExecutionHandler } from "../execution.js"; + +const javascriptHandler: ExecutionHandler = ({ + node, + parent, + index, + addStep, +}) => { + if (node.type !== 'code' || node.lang !== 'javascript') { + return; + } + const optionParts = node.meta?.split(',') || []; + node.meta = undefined; + const options = Object.fromEntries( + optionParts.filter(Boolean).map((option) => { + const [key, value] = option.split('='); + return [key.trim(), value?.trim() || true]; + }) + ); + + addStep({ + type: 'code', + node, + action: async ({ context }) => { + const template = Handlebars.compile(node.value); + const content = template(context); + node.value = content; + if (options['run'] === true) { + const api = { + assert, + should, + expect, + ...context, + } + try { + // eslint-disable-next-line no-new-func + const asyncFunc = new Function( + ...Object.keys(api), + `return (async () => { ${content} })()` + ); + const result = await asyncFunc(...Object.values(api)); + if (options.output === true && index !== undefined) { + if (result !== undefined) { + parent?.children?.splice(index + 1, 0, { + type: 'code', + lang: 'yaml', + value: YAML.stringify(result, null, 2), + meta: undefined, + }); + } + } + } catch (error) { + if (index !== undefined) { + parent?.children?.splice(index + 1, 0, { + type: 'code', + value: `Error: ${error instanceof Error ? error.message : String(error)}`, + meta: undefined, + }); + } + throw error; + } + } + if (options.hidden === true && parent && index !== undefined) { + parent.children?.splice(index, 1); + } + }, + }); +}; + +export { javascriptHandler }; diff --git a/src/execution/handlers/handlers.ts b/src/execution/handlers/handlers.ts index 1abd7eb..0c69f99 100644 --- a/src/execution/handlers/handlers.ts +++ b/src/execution/handlers/handlers.ts @@ -7,6 +7,7 @@ import { responseHandler } from "./handlers.response.js"; import { textHandler } from "./handlers.text.js"; import { codeHandler } from "./handlers.code.js"; import { tocHandler } from "./handlers.toc.js"; +import { javascriptHandler } from "./handlers.javascript.js"; const handlers = [ fileHandler, @@ -16,6 +17,7 @@ const handlers = [ inputHandler, rawMdHandler, codeHandler, + javascriptHandler, ] satisfies ExecutionHandler[]; const postHandlers = [