mirror of
https://github.com/morten-olsen/http.md.git
synced 2026-02-08 00:46:28 +01:00
init
This commit is contained in:
115
src/cli/cli.ts
Normal file
115
src/cli/cli.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { program } from 'commander';
|
||||
import { resolve } from 'node:path';
|
||||
import { marked } from 'marked';
|
||||
import { markedTerminal } from 'marked-terminal';
|
||||
import { execute } from '../execution/execution.js';
|
||||
import { Context } from '../context/context.js';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { Watcher } from '../watcher/watcher.js';
|
||||
|
||||
|
||||
marked.use(markedTerminal() as any);
|
||||
|
||||
program
|
||||
.command('dev')
|
||||
.argument('<name>', 'http.md file name')
|
||||
.description('Run a http.md document')
|
||||
.option('-w, --watch', 'watch for changes')
|
||||
.option('-i, --input <input...>', 'input variables (-i foo=bar -i baz=qux)')
|
||||
.action(async (name, options) => {
|
||||
const {
|
||||
watch = false,
|
||||
input: i = [],
|
||||
} = options;
|
||||
|
||||
const input = Object.fromEntries(
|
||||
i.map((item: string) => {
|
||||
const [key, value] = item.split('=');
|
||||
return [key, value];
|
||||
})
|
||||
);
|
||||
const filePath = resolve(process.cwd(), name);
|
||||
|
||||
const build = async () => {
|
||||
const context = new Context({
|
||||
input,
|
||||
})
|
||||
const result = await execute(filePath, {
|
||||
context,
|
||||
});
|
||||
|
||||
const markdown = await marked.parse(result.markdown);
|
||||
console.log(markdown);
|
||||
|
||||
return {
|
||||
...result,
|
||||
context,
|
||||
}
|
||||
}
|
||||
|
||||
const result = await build();
|
||||
|
||||
if (watch) {
|
||||
const watcher = new Watcher();
|
||||
watcher.watchFiles(Array.from(result.context.files));
|
||||
watcher.on('changed', async () => {
|
||||
const result = await build();
|
||||
watcher.watchFiles(Array.from(result.context.files));
|
||||
});
|
||||
} else {
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('build')
|
||||
.argument('<name>', 'http.md file name')
|
||||
.argument('<output>', 'output file name')
|
||||
.description('Run a http.md document')
|
||||
.option('-w, --watch', 'watch for changes')
|
||||
.option('-i, --input <input...>', 'input variables (-i foo=bar -i baz=qux)')
|
||||
.action(async (name, output, options) => {
|
||||
const {
|
||||
watch = false,
|
||||
input: i = [],
|
||||
} = options;
|
||||
|
||||
|
||||
const input = Object.fromEntries(
|
||||
i.map((item: string) => {
|
||||
const [key, value] = item.split('=');
|
||||
return [key, value];
|
||||
})
|
||||
);
|
||||
const filePath = resolve(process.cwd(), name);
|
||||
|
||||
const build = async () => {
|
||||
const context = new Context({
|
||||
input,
|
||||
})
|
||||
const result = await execute(filePath, {
|
||||
context,
|
||||
});
|
||||
|
||||
await writeFile(output, result.markdown);
|
||||
return {
|
||||
...result,
|
||||
context,
|
||||
}
|
||||
}
|
||||
|
||||
const result = await build();
|
||||
|
||||
if (watch) {
|
||||
const watcher = new Watcher();
|
||||
watcher.watchFiles(Array.from(result.context.files));
|
||||
watcher.on('changed', async () => {
|
||||
const result = await build();
|
||||
watcher.watchFiles(Array.from(result.context.files));
|
||||
});
|
||||
} else {
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
|
||||
await program.parseAsync(process.argv);
|
||||
55
src/context/context.ts
Normal file
55
src/context/context.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
type Request = {
|
||||
method: string;
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
body?: string;
|
||||
};
|
||||
|
||||
type Response = {
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
body?: string;
|
||||
};
|
||||
|
||||
type AddRequestOptios = {
|
||||
request: Request;
|
||||
response: Response;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
type ContextOptions = {
|
||||
input?: Record<string, unknown>;
|
||||
env?: Record<string, unknown>;
|
||||
requests?: Record<string, Request>;
|
||||
responses?: Record<string, Response>;
|
||||
};
|
||||
|
||||
class Context {
|
||||
input: Record<string, unknown> = {};
|
||||
env: Record<string, unknown> = {};
|
||||
files: Set<string> = new Set();
|
||||
requests: Record<string, Request> = {};
|
||||
responses: Record<string, Response> = {};
|
||||
request?: Request;
|
||||
response?: Response;
|
||||
|
||||
constructor(options: ContextOptions = {}) {
|
||||
this.input = options.input || {};
|
||||
this.env = options.env || {};
|
||||
this.requests = options.requests || {};
|
||||
this.responses = options.responses || {};
|
||||
}
|
||||
|
||||
public addRequest(options: AddRequestOptios) {
|
||||
const { request, response, id } = options;
|
||||
if (id) {
|
||||
this.requests[id] = request;
|
||||
this.responses[id] = response;
|
||||
}
|
||||
this.request = request;
|
||||
this.response = response;
|
||||
}
|
||||
}
|
||||
|
||||
export { Context };
|
||||
99
src/execution/execution.ts
Normal file
99
src/execution/execution.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { Root } from "mdast";
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkParse from 'remark-parse'
|
||||
import remarkRehype from 'remark-rehype'
|
||||
import remarkDirective from 'remark-directive'
|
||||
import remarkStringify from 'remark-stringify'
|
||||
import { unified } from 'unified'
|
||||
import { visit } from 'unist-util-visit'
|
||||
|
||||
import { Context } from "../context/context.js";
|
||||
import { handlers } from './handlers/handlers.js';
|
||||
|
||||
const parser = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkDirective)
|
||||
.use(remarkStringify)
|
||||
.use(remarkRehype);
|
||||
|
||||
type BaseNode = {
|
||||
type: string;
|
||||
name?: string;
|
||||
children?: BaseNode[];
|
||||
attributes?: Record<string, string>;
|
||||
meta?: string;
|
||||
lang?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
type ExecutionStepOptions = {
|
||||
file: string;
|
||||
input?: {};
|
||||
context: Context;
|
||||
root: Root;
|
||||
node: BaseNode;
|
||||
}
|
||||
|
||||
type ExecutionStep = {
|
||||
type: string;
|
||||
node: BaseNode;
|
||||
action: (options: ExecutionStepOptions) => Promise<void>;
|
||||
}
|
||||
|
||||
type ExecutionHandler = (options: {
|
||||
file: string;
|
||||
addStep: (step: ExecutionStep) => void;
|
||||
node: BaseNode;
|
||||
parent?: BaseNode;
|
||||
root: Root;
|
||||
index?: number;
|
||||
}) => void;
|
||||
|
||||
type ExexutionExecuteOptions = {
|
||||
context: Context;
|
||||
}
|
||||
|
||||
const execute = async (file: string, options: ExexutionExecuteOptions) => {
|
||||
const { context } = options;
|
||||
context.files.add(file);
|
||||
const content = await readFile(file, 'utf-8');
|
||||
const steps: Set<ExecutionStep> = new Set();
|
||||
|
||||
const root = parser.parse(content);
|
||||
|
||||
visit(root, (node, index, parent) => {
|
||||
for (const handler of handlers) {
|
||||
handler({
|
||||
addStep: (step) => steps.add(step),
|
||||
node: node as BaseNode,
|
||||
root,
|
||||
parent: parent as BaseNode | undefined,
|
||||
index,
|
||||
file,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
for (const step of steps) {
|
||||
const { node, action } = step;
|
||||
const options: ExecutionStepOptions = {
|
||||
file,
|
||||
input: {},
|
||||
context,
|
||||
node,
|
||||
root,
|
||||
};
|
||||
await action(options);
|
||||
}
|
||||
|
||||
const markdown = parser.stringify(root);
|
||||
|
||||
return {
|
||||
root,
|
||||
markdown,
|
||||
};
|
||||
}
|
||||
|
||||
export { execute, type ExecutionHandler };
|
||||
43
src/execution/handlers/handlers.file.ts
Normal file
43
src/execution/handlers/handlers.file.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { dirname, resolve } from 'path';
|
||||
import { toString } from 'mdast-util-to-string'
|
||||
import { execute, type ExecutionHandler } from '../execution.js';
|
||||
|
||||
const fileHandler: ExecutionHandler = ({
|
||||
addStep,
|
||||
node,
|
||||
parent,
|
||||
index,
|
||||
file,
|
||||
}) => {
|
||||
if (node.type === 'leafDirective' && node.name === 'md') {
|
||||
addStep({
|
||||
type: 'file',
|
||||
node,
|
||||
action: async ({ context }) => {
|
||||
const filePath = resolve(
|
||||
dirname(file),
|
||||
toString(node)
|
||||
);
|
||||
if (!filePath) {
|
||||
throw new Error('File path is required');
|
||||
}
|
||||
const { root: newRoot } = await execute(filePath, {
|
||||
context,
|
||||
});
|
||||
if (!parent) {
|
||||
throw new Error('Parent node is required');
|
||||
}
|
||||
if (index === undefined) {
|
||||
throw new Error('Index is required');
|
||||
}
|
||||
if (node.attributes?.hidden === '') {
|
||||
parent.children?.splice(index, 1);
|
||||
return;
|
||||
}
|
||||
parent.children?.splice(index, 1, ...newRoot.children as any);
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export { fileHandler };
|
||||
90
src/execution/handlers/handlers.http.ts
Normal file
90
src/execution/handlers/handlers.http.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import Handlebars from "handlebars";
|
||||
import YAML from "yaml";
|
||||
import { ExecutionHandler } from "../execution.js";
|
||||
|
||||
const httpHandler: ExecutionHandler = ({
|
||||
node,
|
||||
addStep,
|
||||
}) => {
|
||||
if (node.type === 'code') {
|
||||
if (node.lang !== 'http') {
|
||||
return;
|
||||
}
|
||||
const optionParts = node.meta?.split(',') || [];
|
||||
const options = Object.fromEntries(
|
||||
optionParts.filter(Boolean).map((option) => {
|
||||
const [key, value] = option.split('=');
|
||||
return [key.trim(), value?.trim() || true];
|
||||
})
|
||||
);
|
||||
|
||||
addStep({
|
||||
type: 'http',
|
||||
node,
|
||||
action: async ({ context }) => {
|
||||
if (options.disable === true) {
|
||||
return;
|
||||
}
|
||||
const template = Handlebars.compile(node.value);
|
||||
const content = template(context);
|
||||
const [head, body] = content.split('\n\n');
|
||||
const [top, ...headerItems] = head.split('\n');
|
||||
const [method, url] = top.split(' ');
|
||||
|
||||
const headers = Object.fromEntries(
|
||||
headerItems.map((header) => {
|
||||
const [key, value] = header.split(':');
|
||||
return [key.trim(), value?.trim() || ''];
|
||||
})
|
||||
);
|
||||
|
||||
let parsedBody = body;
|
||||
if (options.format === 'yaml') {
|
||||
try {
|
||||
const parsed = YAML.parse(body);
|
||||
parsedBody = JSON.stringify(parsed);
|
||||
} catch (error) {
|
||||
parsedBody = `Error parsing YAML: ${error}`;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body
|
||||
});
|
||||
|
||||
let responseText = await response.text();
|
||||
if (options.json) {
|
||||
try {
|
||||
responseText = JSON.parse(responseText);
|
||||
} catch (e) {
|
||||
responseText = `Error parsing JSON: ${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
node.value = content;
|
||||
node.meta = undefined;
|
||||
|
||||
context.addRequest({
|
||||
id: options.id?.toString(),
|
||||
request: {
|
||||
method,
|
||||
url,
|
||||
headers,
|
||||
body,
|
||||
},
|
||||
response: {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
body: responseText,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
export { httpHandler };
|
||||
56
src/execution/handlers/handlers.input.ts
Normal file
56
src/execution/handlers/handlers.input.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { toString } from 'mdast-util-to-string';
|
||||
import { type ExecutionHandler } from '../execution.js';
|
||||
|
||||
const inputHandler: ExecutionHandler = ({
|
||||
addStep,
|
||||
node,
|
||||
parent,
|
||||
index,
|
||||
}) => {
|
||||
if (node.type === 'leafDirective' && node.name === 'input') {
|
||||
addStep({
|
||||
type: 'input',
|
||||
node,
|
||||
action: async ({ context }) => {
|
||||
const name = toString(node);
|
||||
|
||||
if (node.attributes?.required === '' && context.input[name] === undefined) {
|
||||
throw new Error(`Input "${name}" is required`);
|
||||
}
|
||||
|
||||
if (node.attributes?.default !== undefined && context.input[name] === undefined) {
|
||||
context.input[name] = node.attributes.default;
|
||||
}
|
||||
|
||||
if (node.attributes?.format === 'number' && context.input[name] !== undefined) {
|
||||
context.input[name] = Number(context.input[name]);
|
||||
if (context.input[name] !== undefined && isNaN(Number(context.input[name]))) {
|
||||
throw new Error(`Input "${name}" must be a number, but got "${context.input[name]}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!parent || !('children' in parent) || index === undefined) {
|
||||
throw new Error('Parent node is required');
|
||||
}
|
||||
|
||||
if (node.attributes?.hidden === '') {
|
||||
parent?.children?.splice(index, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const newNode = {
|
||||
type: 'code',
|
||||
value: `${name}=${context.input[name] ?? '[undefined]'}`,
|
||||
};
|
||||
|
||||
parent.children?.splice(
|
||||
index,
|
||||
1,
|
||||
newNode as any,
|
||||
);
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export { inputHandler };
|
||||
59
src/execution/handlers/handlers.raw-md.ts
Normal file
59
src/execution/handlers/handlers.raw-md.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { readFile } from 'fs/promises';
|
||||
import { Context } from '../../context/context.js';
|
||||
import { execute, type ExecutionHandler } from '../execution.js';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { toString } from 'mdast-util-to-string';
|
||||
|
||||
const rawMdHandler: ExecutionHandler = ({
|
||||
addStep,
|
||||
node,
|
||||
parent,
|
||||
index,
|
||||
file,
|
||||
}) => {
|
||||
if (node.type === 'leafDirective' && node.name === 'raw-md') {
|
||||
addStep({
|
||||
type: 'raw-md',
|
||||
node,
|
||||
action: async ({ context: parentContext }) => {
|
||||
const name = resolve(
|
||||
dirname(file),
|
||||
toString(node),
|
||||
);
|
||||
const context = new Context({
|
||||
input: {},
|
||||
});
|
||||
let markdown = '';
|
||||
if (node.attributes?.render === '') {
|
||||
const result = await execute(name, {
|
||||
context,
|
||||
});
|
||||
markdown = result.markdown;
|
||||
for (const file of context.files) {
|
||||
parentContext.files.add(file);
|
||||
}
|
||||
} else {
|
||||
markdown = await readFile(name, 'utf-8');
|
||||
parentContext.files.add(name);
|
||||
}
|
||||
const newNode = {
|
||||
type: 'code',
|
||||
lang: 'markdown',
|
||||
value: markdown,
|
||||
};
|
||||
|
||||
if (!parent || !('children' in parent) || index === undefined) {
|
||||
throw new Error('Parent node is required');
|
||||
}
|
||||
|
||||
parent.children?.splice(
|
||||
index,
|
||||
1,
|
||||
newNode as any,
|
||||
);
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export { rawMdHandler };
|
||||
72
src/execution/handlers/handlers.response.ts
Normal file
72
src/execution/handlers/handlers.response.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import YAML from 'yaml';
|
||||
import { type ExecutionHandler } from '../execution.js';
|
||||
|
||||
const responseHandler: ExecutionHandler = ({
|
||||
addStep,
|
||||
node,
|
||||
parent,
|
||||
index,
|
||||
}) => {
|
||||
if (node.type === 'leafDirective' && node.name === 'response') {
|
||||
addStep({
|
||||
type: 'file',
|
||||
node,
|
||||
action: async ({ context }) => {
|
||||
const response = node.attributes?.id ?
|
||||
context.responses[node.attributes.id] : context.response
|
||||
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
let body = '';
|
||||
|
||||
if (response.body) {
|
||||
body = response.body;
|
||||
}
|
||||
if (typeof response.body === 'object') {
|
||||
body = JSON.stringify(response.body, null, 2);
|
||||
}
|
||||
if (node.attributes?.format === 'yaml') {
|
||||
try {
|
||||
const parsed = YAML.parse(body);
|
||||
body = YAML.stringify(parsed);
|
||||
} catch (error) {
|
||||
body = `Error parsing YAML: ${error}`;
|
||||
}
|
||||
}
|
||||
if (node.attributes?.truncate) {
|
||||
const maxLength = parseInt(node.attributes.truncate);
|
||||
if (body.length > maxLength) {
|
||||
body = body.slice(0, maxLength) + '...';
|
||||
}
|
||||
}
|
||||
|
||||
const responseContent = [
|
||||
`HTTP/${response.status} ${response.statusText}`,
|
||||
...Object.entries(response.headers).map(([key, value]) => {
|
||||
return `${key}: ${value}`;
|
||||
}),
|
||||
'',
|
||||
body || '[empty]',
|
||||
].join('\n');
|
||||
|
||||
const codeNode = {
|
||||
type: 'code',
|
||||
value: responseContent,
|
||||
};
|
||||
if (!parent || !('children' in parent) || index === undefined) {
|
||||
throw new Error('Parent node is required');
|
||||
}
|
||||
|
||||
parent.children?.splice(
|
||||
index,
|
||||
1,
|
||||
codeNode as any,
|
||||
);
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export { responseHandler };
|
||||
21
src/execution/handlers/handlers.text.ts
Normal file
21
src/execution/handlers/handlers.text.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { type ExecutionHandler } from '../execution.js';
|
||||
import Handlebars from "handlebars";
|
||||
|
||||
const textHandler: ExecutionHandler = ({
|
||||
addStep,
|
||||
node,
|
||||
}) => {
|
||||
if (node.type === 'text') {
|
||||
addStep({
|
||||
type: 'parse-text',
|
||||
node,
|
||||
action: async ({ context }) => {
|
||||
const template = Handlebars.compile(node.value);
|
||||
const content = template(context);
|
||||
node.value = content;
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export { textHandler };
|
||||
18
src/execution/handlers/handlers.ts
Normal file
18
src/execution/handlers/handlers.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ExecutionHandler } from "../execution.js";
|
||||
import { fileHandler } from "./handlers.file.js";
|
||||
import { httpHandler } from "./handlers.http.js";
|
||||
import { inputHandler } from "./handlers.input.js";
|
||||
import { rawMdHandler } from "./handlers.raw-md.js";
|
||||
import { responseHandler } from "./handlers.response.js";
|
||||
import { textHandler } from "./handlers.text.js";
|
||||
|
||||
const handlers = [
|
||||
fileHandler,
|
||||
httpHandler,
|
||||
responseHandler,
|
||||
textHandler,
|
||||
inputHandler,
|
||||
rawMdHandler,
|
||||
] satisfies ExecutionHandler[];
|
||||
|
||||
export { handlers };
|
||||
16
src/exports.ts
Normal file
16
src/exports.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
import { inspect } from "node:util";
|
||||
import { Context } from "./context/context.js";
|
||||
import { execute } from "./execution/execution.js";
|
||||
|
||||
const context = new Context({
|
||||
input: {
|
||||
foo: '10',
|
||||
},
|
||||
});
|
||||
const result = await execute('./demo.md', {
|
||||
context,
|
||||
});
|
||||
|
||||
console.log(result.markdown);
|
||||
console.log(context.files);
|
||||
23
src/watcher/watcher.ts
Normal file
23
src/watcher/watcher.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { EventEmitter } from "eventemitter3";
|
||||
import { FSWatcher, watch } from "node:fs";
|
||||
|
||||
type WatcherEvent = {
|
||||
changed: () => void;
|
||||
};
|
||||
|
||||
class Watcher extends EventEmitter<WatcherEvent> {
|
||||
#watching: Map<string, FSWatcher> = new Map()
|
||||
public watchFiles = (files: string[]) => {
|
||||
for (const file of files) {
|
||||
if (this.#watching.has(file)) {
|
||||
continue;
|
||||
}
|
||||
const watcher = watch(file, () => {
|
||||
this.emit("changed");
|
||||
});
|
||||
this.#watching.set(file, watcher);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { Watcher }
|
||||
Reference in New Issue
Block a user