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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

16
src/exports.ts Normal file
View File

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

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

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