holon-md 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lukas Brückner
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,176 @@
1
- # Temporary Holding Version
1
+ # Holon
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Holon compiles a folder of small Markdown files into the documents people read.
4
+
5
+ ## The idea
6
+
7
+ In a folder of Markdown files, the file you maintain is the file someone reads. The two jobs pull in different directions. Maintaining wants small files that each hold one thing in one place. Reading wants a few complete documents, shaped for whoever opens them.
8
+
9
+ Holon separates the two. You write a tree of small files. Functions you write project that tree into the documents you publish. Holon runs the functions, fits the heading levels together and keeps every link pointing at the right place.
10
+
11
+ A product described in three files:
12
+
13
+ ```text
14
+ docs/
15
+ holon.config.mjs
16
+ index.md
17
+ billing.md
18
+ login.md
19
+ ```
20
+
21
+ `index.md` says where it goes and pulls in its children:
22
+
23
+ ```md
24
+ ---
25
+ render_to: README.md
26
+ ---
27
+ # Product
28
+
29
+ ## Features
30
+
31
+ `holon sections()`
32
+ ```
33
+
34
+ `holon.config.mjs` defines what `sections` means:
35
+
36
+ ```js
37
+ export default ({ node, render }) => ({
38
+ sections: () => node.children.map(render).join("\n\n"),
39
+ });
40
+ ```
41
+
42
+ `billing.md` and `login.md` are plain Markdown, each starting with its own `#` heading. Holon writes one `README.md`:
43
+
44
+ ```md
45
+ # Product
46
+
47
+ ## Features
48
+
49
+ ### Billing
50
+
51
+ ...
52
+
53
+ ### Login
54
+
55
+ ...
56
+ ```
57
+
58
+ An include would paste the two files in as they are. Holon moves their headings to where they now belong and keeps every link pointing where it pointed in the source, so a file reads right wherever it lands: as a section here, as a chapter of another document, or as a file of its own.
59
+
60
+ The whole [language](#language) fits on one page.
61
+
62
+ ## Language
63
+
64
+ A source folder is GitHub Flavored Markdown with front matter. Holon gives meaning to four things in it: code that starts with `holon`, the front matter field `render_to`, links, and heading levels. Everything else passes through as written.
65
+
66
+ ### Nodes
67
+
68
+ Every folder and every `.md` file in the source folder is a node. A folder's `index.md` holds the folder's own text, so from outside a folder looks like a file. Names starting with `.` are skipped, and so are folders without Markdown.
69
+
70
+ A node is plain data:
71
+
72
+ | Field | Meaning |
73
+ | :-- | :-- |
74
+ | `path` | Path from the source folder: `/guides/setup.md`, or `/guides` for a folder |
75
+ | `meta` | The front matter |
76
+ | `title` | The text of the first heading as written, or `null` |
77
+ | `children` | The child nodes, sorted by name |
78
+ | `artifact` | The node's own output file from `render_to`, or `null` |
79
+ | `backlinks` | The nodes whose text links to this node |
80
+ | `at(path)` | The node at a path relative to this node's file, or from the source folder with a leading `/` |
81
+
82
+ Front matter other than `render_to` means nothing to Holon. It is there for your functions to read.
83
+
84
+ ### Calls
85
+
86
+ Code that starts with the word `holon` is a call. Holon evaluates the rest as a JavaScript expression and puts the resulting string in its place. A code span is a call on one line, a code block tagged `holon` is a call on several:
87
+
88
+ ````md
89
+ See `holon ref("./setup.md")`.
90
+
91
+ ```holon
92
+ node.children
93
+ .map((child) => `- ${child.title}: ${child.meta.summary}`)
94
+ .join("\n")
95
+ ```
96
+ ````
97
+
98
+ An expression sees `node`, the node whose text contains the call, `render`, and the functions from `holon.config.mjs` in the source folder. That file exports one function. Holon calls it for every call with the same `node` and `render`, so the functions it returns know where they are used:
99
+
100
+ ```js
101
+ export default ({ node, render }) => ({
102
+ ref: (path) => `[${node.at(path).title}](${node.at(path).path})`,
103
+ });
104
+ ```
105
+
106
+ `render(child)` returns a node's text with its own calls evaluated. Each node renders once. Rendering a node from inside itself is an error.
107
+
108
+ The text comes between two marker lines. Holon reads them to see where the text ends up and removes them at the end. A function may put a prefix in front of every line, like `> ` or the indent of a list item, but the marker lines must stay whole and on lines of their own. A node without text renders to an empty string without markers.
109
+
110
+ A call that stands alone in a paragraph, and every `holon` block, may return whole blocks with headings. A call inside text must return a single line. To show a call without running it, put it in a fenced code block, as on this page.
111
+
112
+ ### Files
113
+
114
+ A node with `render_to` in its front matter becomes a file at that path inside the output folder:
115
+
116
+ ```yaml
117
+ render_to: guides/setup.md
118
+ ```
119
+
120
+ Holon renders these nodes and nothing else. Every other node appears only where a function renders it, which can be several places or none.
121
+
122
+ ### Links
123
+
124
+ A link points to the same target in the output as in the source. Write it relative to its file, or from the source folder with a leading `/`. A link that a function writes resolves like a link in the node that contains the call.
125
+
126
+ - A link to a node points to where the node landed. That is its own file if it has `render_to`. Otherwise it is the file its text ended up in, at the node's own first heading. The text must be in exactly one place. Text that a function rendered and then dropped does not count.
127
+ - A link to any other file points to that file in the source folder. Holon copies nothing.
128
+ - A link to a `.md` file that is not a node is an error.
129
+
130
+ A fragment names one of the target node's own headings, by the anchor the heading has when the node stands alone. A link that is only a fragment, like `#setup`, points to a heading of the node it is written in. Reference links come out as inline links, each with the definition from its own node, and the definitions go.
131
+
132
+ The links in a node's text are the [backlinks](#nodes) of their targets. Links that only a function writes do not count, because backlinks are read before anything runs.
133
+
134
+ ### Headings
135
+
136
+ Write every file as if it stood alone, starting at `#`. A heading underlined with `===` or `---` is an error. Holon fits the levels together with one rule: text put into other text starts one level below the last heading that other text wrote above it.
137
+
138
+ - `render(node)` returns the node with its top headings at level one.
139
+ - A call's result starts one level below the heading above the call.
140
+ - What a function got from `render()` starts one level below the heading the function wrote above it, so a function can group what it renders under headings of its own.
141
+
142
+ Every heading ends up exactly one level below the heading it belongs to, so gaps close: a file using only `##` and `####` comes out as `#` and `##`. Markdown has six levels. Nesting deeper is an error.
143
+
144
+ ## Essentials
145
+
146
+ Holon ships no functions. The ones this README is built with are in [holon.config.mjs](docs/holon.config.mjs). Copy what you need:
147
+
148
+ - `sections()` puts the children one after another, skipping those with their own file.
149
+ - `ref(path)` links to a node with its title as the text.
150
+ - `file(path)` prints the output path of a node with its own file.
151
+ - `backlinks()` lists the nodes that link here.
152
+ - `tree(path)` draws the folder below a path with each node's `summary`.
153
+
154
+ ## Usage
155
+
156
+ ```sh
157
+ npm install --save-dev holon-md
158
+ npx holon-md docs .
159
+ ```
160
+
161
+ The first argument is the source folder, the second the output folder. Every file lands inside the output folder and none inside the source folder. Holon prints every file as it writes it and stops at the first error, saying where it happened.
162
+
163
+ This README is compiled from:
164
+
165
+ ```yaml
166
+ /:
167
+ 1-idea.md: # Why the files you write and the files you read should differ.
168
+ 2-language: # Nodes, calls, files, links and headings.
169
+ 1-nodes.md: # What functions see.
170
+ 2-calls.md: # Code that runs.
171
+ 3-files.md: # Which nodes become files.
172
+ 4-links.md: # Where links point after compiling.
173
+ 5-headings.md: # How heading levels fit together.
174
+ 3-essentials.md: # Functions worth copying.
175
+ 4-usage.md: # Install and run.
176
+ ```
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
3
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
4
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
5
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
6
+ });
7
+ }
8
+ return path;
9
+ };
10
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
11
+ import { dirname, join, resolve } from "node:path";
12
+ import { pathToFileURL } from "node:url";
13
+ import { compile } from "./compile.js";
14
+ import { message } from "./tree.js";
15
+ const args = process.argv.slice(2);
16
+ if (args.length !== 2) {
17
+ console.error("usage: holon <source folder> <output folder>");
18
+ process.exit(1);
19
+ }
20
+ const [source, output] = args;
21
+ try {
22
+ for (const artifact of compile(source, output, await loadConfig(source))) {
23
+ const file = join(output, artifact.path);
24
+ mkdirSync(dirname(file), { recursive: true });
25
+ writeFileSync(file, artifact.content);
26
+ console.log(artifact.path);
27
+ }
28
+ }
29
+ catch (error) {
30
+ console.error(`holon: ${message(error)}`);
31
+ process.exit(1);
32
+ }
33
+ /** The default export of `holon.config.mjs` in `sourceDir`, or undefined without one. */
34
+ async function loadConfig(sourceDir) {
35
+ const file = resolve(sourceDir, "holon.config.mjs");
36
+ if (!existsSync(file))
37
+ return undefined;
38
+ let module;
39
+ try {
40
+ module = await import(__rewriteRelativeImportExtension(pathToFileURL(file).href));
41
+ }
42
+ catch (error) {
43
+ throw new Error(`holon.config.mjs: ${message(error)}`);
44
+ }
45
+ if (typeof module.default !== "function")
46
+ throw new Error("holon.config.mjs must export a default function");
47
+ return module.default;
48
+ }
@@ -0,0 +1,8 @@
1
+ import { type Config } from "./render.ts";
2
+ /** A file to write: its path inside the output folder and its content. */
3
+ export type Artifact = {
4
+ path: string;
5
+ content: string;
6
+ };
7
+ /** Compile `sourceDir` for `outputDir` without writing anything. */
8
+ export declare function compile(sourceDir: string, outputDir: string, config?: Config): Artifact[];
@@ -0,0 +1,120 @@
1
+ import { dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
2
+ import { anchors, applyEdits, destinations, formatUrl, headings, parse, start } from "./markdown.js";
3
+ import { contains, createRender } from "./render.js";
4
+ import { loadTree, message, preorder, resolveLink } from "./tree.js";
5
+ /** Compile `sourceDir` for `outputDir` without writing anything. */
6
+ export function compile(sourceDir, outputDir, config) {
7
+ const source = resolve(sourceDir);
8
+ const output = resolve(outputDir);
9
+ const tree = loadTree(source);
10
+ const { render, markers } = createRender(tree, config ?? null);
11
+ const files = new Map();
12
+ for (const node of preorder(tree.root)) {
13
+ if (!node.artifact)
14
+ continue;
15
+ const other = files.get(node.artifact);
16
+ if (other)
17
+ throw new Error(`${other.path} and ${node.path} both render_to ${node.artifact}`);
18
+ const target = resolve(output, node.artifact);
19
+ if (!within(output, target))
20
+ throw new Error(`${node.path} would render_to ${node.artifact}, which is not inside the output folder`);
21
+ if (within(source, target))
22
+ throw new Error(`${node.path} would render_to ${node.artifact}, which is inside the source folder`);
23
+ files.set(node.artifact, node);
24
+ }
25
+ if (files.size === 0)
26
+ throw new Error("no node has render_to, so there is nothing to write");
27
+ const texts = new Map();
28
+ for (const [path, node] of files)
29
+ texts.set(path, render(node));
30
+ // Read from the markers where each node's text ended up and which headings are its own.
31
+ const places = new Map();
32
+ const marked = new Map();
33
+ for (const [file, text] of texts) {
34
+ const list = headings(parse(text));
35
+ const names = anchors(list);
36
+ let found;
37
+ try {
38
+ found = markers(text);
39
+ }
40
+ catch (error) {
41
+ throw new Error(`${file}: ${message(error)}`);
42
+ }
43
+ const owners = list.map((heading) => innermost(found.regions, start(heading)));
44
+ for (const region of found.regions) {
45
+ const own = owners.flatMap((owner, i) => (owner === region ? [i] : []));
46
+ const alone = anchors(own.map((i) => list[i]));
47
+ const place = { file, anchors: new Map(alone.map((anchor, j) => [anchor, names[own[j]]])) };
48
+ places.set(region.path, [...(places.get(region.path) ?? []), place]);
49
+ }
50
+ marked.set(file, found);
51
+ }
52
+ /** The file a node's text is in, and the anchors of its headings there. */
53
+ const landing = (node) => {
54
+ const found = places.get(node.path) ?? [];
55
+ if (node.artifact)
56
+ return found.find((place) => place.file === node.artifact) ?? { file: node.artifact, anchors: new Map() };
57
+ if (found.length === 1)
58
+ return found[0];
59
+ const where = found.length === 0 ? "no file" : `${found.length} places (${[...new Set(found.map((place) => place.file))].join(", ")})`;
60
+ throw new Error(`${node.path} has no render_to and its text is in ${where}, so links to it have no target`);
61
+ };
62
+ /** Where a link from `from` to `node` points. */
63
+ const nodeUrl = (node, fragment, from) => {
64
+ const place = landing(node);
65
+ let anchor = null;
66
+ if (fragment !== null) {
67
+ anchor = place.anchors.get(fragment) ?? null;
68
+ if (anchor === null)
69
+ throw new Error(`${node.path} has no heading with the anchor #${fragment}`);
70
+ }
71
+ else if (!node.artifact) {
72
+ anchor = place.anchors.values().next().value ?? null;
73
+ if (anchor === null)
74
+ throw new Error(`${node.path} has no heading in ${place.file} to link to`);
75
+ }
76
+ const path = place.file === from ? (anchor === null ? posix.basename(place.file) : "") : posix.relative(posix.dirname(from), place.file);
77
+ return formatUrl(path, anchor);
78
+ };
79
+ /** Where a link to a file that is not a node points: the file in the source folder. */
80
+ const fileUrl = (path, fragment, from) => {
81
+ const target = join(source, ...path.split("/"));
82
+ return formatUrl(relative(dirname(join(output, from)), target).split(sep).join("/"), fragment);
83
+ };
84
+ return [...texts].map(([path, text]) => {
85
+ const { regions, removals } = marked.get(path);
86
+ const edits = [...removals];
87
+ // Every link Holon handles is an absolute source path by now, see render().
88
+ for (const found of destinations(text, parse(text))) {
89
+ const target = resolveLink(tree, found.url, tree.sources.get(files.get(path)), path);
90
+ if (!target)
91
+ continue;
92
+ let url;
93
+ try {
94
+ url = target.node ? nodeUrl(target.node, target.fragment, path) : fileUrl(target.path, target.fragment, path);
95
+ }
96
+ catch (error) {
97
+ const owner = innermost(regions, found.start).path;
98
+ const file = tree.sources.get(tree.nodes.get(owner))?.file ?? owner;
99
+ throw new Error(`${path}: link in ${file}: ${message(error)}`);
100
+ }
101
+ edits.push({ start: found.start, end: found.end, text: url });
102
+ }
103
+ // Removing the last marker line leaves the line break before it behind.
104
+ return { path, content: `${applyEdits(text, edits).replace(/\n+$/, "")}\n` };
105
+ });
106
+ }
107
+ /** Whether `path` lies inside `folder`, the folder itself excluded. */
108
+ function within(folder, path) {
109
+ const inside = relative(folder, path);
110
+ return inside !== "" && inside !== ".." && !inside.startsWith(`..${sep}`) && !isAbsolute(inside);
111
+ }
112
+ /** The innermost region around `offset`. */
113
+ function innermost(regions, offset) {
114
+ let found;
115
+ for (const region of regions) {
116
+ if (contains(region, offset) && (!found || region.start > found.start))
117
+ found = region;
118
+ }
119
+ return found;
120
+ }
@@ -0,0 +1,3 @@
1
+ export { compile, type Artifact } from "./compile.ts";
2
+ export type { Config, Context } from "./render.ts";
3
+ export type { Node } from "./tree.ts";
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { compile } from "./compile.js";
@@ -0,0 +1,38 @@
1
+ import type { Heading, Nodes, Root } from "mdast";
2
+ /** Replace `text.slice(start, end)` with `text`. */
3
+ export type Edit = {
4
+ start: number;
5
+ end: number;
6
+ text: string;
7
+ };
8
+ /** Where a link, image or definition points, and the span of its destination in the text. */
9
+ export type Destination = {
10
+ url: string;
11
+ start: number;
12
+ end: number;
13
+ };
14
+ export declare function parse(text: string): Root;
15
+ export declare function walk(node: Nodes, visit: (node: Nodes, ancestors: Nodes[]) => void, ancestors?: Nodes[]): void;
16
+ export declare function start(node: Nodes): number;
17
+ export declare function end(node: Nodes): number;
18
+ export declare function applyEdits(text: string, edits: Edit[]): string;
19
+ export declare function headings(root: Root): Heading[];
20
+ export declare function plainText(node: Nodes): string;
21
+ /** The anchor GitHub derives from a heading's text. */
22
+ export declare function slug(text: string): string;
23
+ /** The anchors of all headings in order, with GitHub's `-1`, `-2` suffixes for repeats. */
24
+ export declare function anchors(list: Heading[]): string[];
25
+ /**
26
+ * The level of each heading such that it sits exactly one level below the
27
+ * heading it belongs to, with the top level at `base`.
28
+ */
29
+ export declare function levels(list: Heading[], base: number): number[];
30
+ /** The change that moves `heading` to level `depth`, or null if it is there already. A heading with `#` stands on one line. */
31
+ export declare function levelEdit(heading: Heading, depth: number): Edit | null;
32
+ /** The change that removes the lines from the one holding `from` to the one holding `to`. A block between blank lines takes one of them along. */
33
+ export declare function removeLines(text: string, from: number, to: number): Edit;
34
+ export declare function destinations(text: string, root: Root): Destination[];
35
+ /** The changes that turn reference links and images into inline ones, each with the destination and title of the definition it uses, and remove the definitions. */
36
+ export declare function referenceEdits(text: string, root: Root): Edit[];
37
+ /** A link destination: the path with every segment percent-encoded, then the fragment. */
38
+ export declare function formatUrl(path: string, fragment: string | null): string;
@@ -0,0 +1,171 @@
1
+ import { fromMarkdown } from "mdast-util-from-markdown";
2
+ import { gfmFromMarkdown } from "mdast-util-gfm";
3
+ import { parse as tokenize, postprocess, preprocess } from "micromark";
4
+ import { gfm } from "micromark-extension-gfm";
5
+ /** Holon reads GitHub Flavored Markdown, the dialect whose anchors it writes. */
6
+ const extensions = [gfm()];
7
+ export function parse(text) {
8
+ return fromMarkdown(text, { extensions, mdastExtensions: [gfmFromMarkdown()] });
9
+ }
10
+ export function walk(node, visit, ancestors = []) {
11
+ visit(node, ancestors);
12
+ if ("children" in node) {
13
+ for (const child of node.children)
14
+ walk(child, visit, [...ancestors, node]);
15
+ }
16
+ }
17
+ export function start(node) {
18
+ return node.position.start.offset;
19
+ }
20
+ export function end(node) {
21
+ return node.position.end.offset;
22
+ }
23
+ export function applyEdits(text, edits) {
24
+ let result = "";
25
+ let at = 0;
26
+ for (const edit of [...edits].sort((a, b) => a.start - b.start)) {
27
+ if (edit.start < at)
28
+ throw new Error("two changes overlap");
29
+ result += text.slice(at, edit.start) + edit.text;
30
+ at = edit.end;
31
+ }
32
+ return result + text.slice(at);
33
+ }
34
+ export function headings(root) {
35
+ const found = [];
36
+ walk(root, (node) => {
37
+ if (node.type === "heading")
38
+ found.push(node);
39
+ });
40
+ return found;
41
+ }
42
+ export function plainText(node) {
43
+ if (node.type === "text" || node.type === "inlineCode")
44
+ return node.value;
45
+ if ("children" in node)
46
+ return node.children.map(plainText).join("");
47
+ return "";
48
+ }
49
+ /** The anchor GitHub derives from a heading's text. */
50
+ export function slug(text) {
51
+ return text
52
+ .toLowerCase()
53
+ .replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, "")
54
+ .replace(/ /g, "-");
55
+ }
56
+ /** The anchors of all headings in order, with GitHub's `-1`, `-2` suffixes for repeats. */
57
+ export function anchors(list) {
58
+ const used = new Map();
59
+ const result = [];
60
+ for (const heading of list) {
61
+ const base = slug(plainText(heading));
62
+ let anchor = base;
63
+ while (used.has(anchor)) {
64
+ const count = used.get(base) + 1;
65
+ used.set(base, count);
66
+ anchor = `${base}-${count}`;
67
+ }
68
+ used.set(anchor, 0);
69
+ result.push(anchor);
70
+ }
71
+ return result;
72
+ }
73
+ /**
74
+ * The level of each heading such that it sits exactly one level below the
75
+ * heading it belongs to, with the top level at `base`.
76
+ */
77
+ export function levels(list, base) {
78
+ const open = [];
79
+ return list.map((heading) => {
80
+ while (open.length > 0 && open[open.length - 1] >= heading.depth)
81
+ open.pop();
82
+ open.push(heading.depth);
83
+ const depth = base + open.length - 1;
84
+ if (depth > 6)
85
+ throw new Error(`heading "${plainText(heading)}" would need level ${depth}, Markdown stops at 6`);
86
+ return depth;
87
+ });
88
+ }
89
+ /** The change that moves `heading` to level `depth`, or null if it is there already. A heading with `#` stands on one line. */
90
+ export function levelEdit(heading, depth) {
91
+ const { start: from, end: to } = heading.position;
92
+ if (from.line !== to.line)
93
+ throw new Error(`heading "${plainText(heading)}" is underlined with === or ---, write it with #`);
94
+ if (heading.depth === depth)
95
+ return null;
96
+ return { start: from.offset, end: from.offset + heading.depth, text: "#".repeat(depth) };
97
+ }
98
+ /** The change that removes the lines from the one holding `from` to the one holding `to`. A block between blank lines takes one of them along. */
99
+ export function removeLines(text, from, to) {
100
+ const start = text.lastIndexOf("\n", from - 1) + 1;
101
+ const lineEnd = text.indexOf("\n", to);
102
+ const end = lineEnd < 0 ? text.length : lineEnd + 1;
103
+ return { start, end: text.endsWith("\n\n", start) && text.startsWith("\n", end) ? end + 1 : end, text: "" };
104
+ }
105
+ /** The syntax of every link, image and definition in `text`, by the node's start. */
106
+ function syntax(text) {
107
+ const found = new Map();
108
+ const open = [];
109
+ for (const [kind, token] of postprocess(tokenize({ extensions }).document().write(preprocess()(text, "utf8", true)))) {
110
+ const span = { start: token.start.offset, end: token.end.offset };
111
+ if (token.type === "link" || token.type === "image" || token.type === "definition") {
112
+ if (kind === "exit") {
113
+ open.pop();
114
+ }
115
+ else {
116
+ const current = { end: span.end, label: span.start, destination: null, title: null };
117
+ found.set(span.start, current);
118
+ open.push(current);
119
+ }
120
+ }
121
+ else if (kind === "exit" && open.length > 0) {
122
+ const current = open[open.length - 1];
123
+ if (token.type === "label")
124
+ current.label = span.end;
125
+ else if (token.type === "resourceDestination" || token.type === "definitionDestination")
126
+ current.destination = span;
127
+ else if (token.type === "resourceTitle" || token.type === "definitionTitle")
128
+ current.title = span;
129
+ }
130
+ }
131
+ return found;
132
+ }
133
+ export function destinations(text, root) {
134
+ const spans = syntax(text);
135
+ const found = [];
136
+ walk(root, (node) => {
137
+ if (node.type !== "link" && node.type !== "image" && node.type !== "definition")
138
+ return;
139
+ const destination = spans.get(start(node))?.destination;
140
+ if (destination)
141
+ found.push({ url: node.url, ...destination });
142
+ });
143
+ return found;
144
+ }
145
+ /** The changes that turn reference links and images into inline ones, each with the destination and title of the definition it uses, and remove the definitions. */
146
+ export function referenceEdits(text, root) {
147
+ const spans = syntax(text);
148
+ const definitions = new Map();
149
+ const references = [];
150
+ const edits = [];
151
+ walk(root, (node) => {
152
+ if (node.type === "definition") {
153
+ if (!definitions.has(node.identifier))
154
+ definitions.set(node.identifier, spans.get(start(node)));
155
+ edits.push(removeLines(text, start(node), end(node)));
156
+ }
157
+ else if (node.type === "linkReference" || node.type === "imageReference")
158
+ references.push(node);
159
+ });
160
+ for (const node of references) {
161
+ const reference = spans.get(start(node));
162
+ const { destination, title } = definitions.get(node.identifier);
163
+ edits.push({ start: reference.label, end: reference.end, text: `(${text.slice(destination.start, (title ?? destination).end)})` });
164
+ }
165
+ return edits;
166
+ }
167
+ /** A link destination: the path with every segment percent-encoded, then the fragment. */
168
+ export function formatUrl(path, fragment) {
169
+ const encoded = path.split("/").map(encodeURIComponent).join("/");
170
+ return fragment === null ? encoded : `${encoded}#${fragment}`;
171
+ }
@@ -0,0 +1,30 @@
1
+ import { type Edit } from "./markdown.ts";
2
+ import { type Node, type Tree } from "./tree.ts";
3
+ /** The default export of `holon.config.mjs`: called for every holon call with the node that contains it. */
4
+ export type Config = (context: Context) => Record<string, (...args: any[]) => unknown>;
5
+ export type Context = {
6
+ node: Node;
7
+ render: (node: Node) => string;
8
+ };
9
+ /** The span of a text between the markers `render()` put around a node. */
10
+ export type Region = {
11
+ path: string;
12
+ start: number;
13
+ end: number;
14
+ };
15
+ /**
16
+ * `render(node)` returns a node's text with its calls evaluated, its links
17
+ * pointing at source paths and its top headings at level 1, between two
18
+ * marker lines that tell Holon where the text ends up. `markers(text)` reads
19
+ * those lines back. Markers carry an id that is new for every compile, so no
20
+ * source text can contain one.
21
+ */
22
+ export declare function createRender(tree: Tree, config: Config | null): {
23
+ render: (node: Node) => string;
24
+ markers: (text: string) => {
25
+ regions: Region[];
26
+ removals: Edit[];
27
+ };
28
+ };
29
+ /** Whether `offset` lies in `region`. */
30
+ export declare function contains(region: Region, offset: number): boolean;
package/dist/render.js ADDED
@@ -0,0 +1,224 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { applyEdits, destinations, end, formatUrl, headings, levelEdit, levels, parse, referenceEdits, removeLines, start, walk, } from "./markdown.js";
3
+ import { location, message, resolveLink } from "./tree.js";
4
+ /**
5
+ * `render(node)` returns a node's text with its calls evaluated, its links
6
+ * pointing at source paths and its top headings at level 1, between two
7
+ * marker lines that tell Holon where the text ends up. `markers(text)` reads
8
+ * those lines back. Markers carry an id that is new for every compile, so no
9
+ * source text can contain one.
10
+ */
11
+ export function createRender(tree, config) {
12
+ const done = new Map();
13
+ const active = [];
14
+ const id = randomUUID().slice(0, 8);
15
+ function render(node) {
16
+ const source = tree.sources.get(node);
17
+ if (!source)
18
+ throw new Error(`render() expects a node, got ${describe(node)}`);
19
+ const cached = done.get(node);
20
+ if (cached !== undefined)
21
+ return cached;
22
+ if (active.includes(node)) {
23
+ const cycle = [...active.slice(active.indexOf(node)), node].map((n) => n.path);
24
+ throw new Error(`render cycle: ${cycle.join(" -> ")}`);
25
+ }
26
+ active.push(node);
27
+ try {
28
+ const text = renderSource(source);
29
+ done.set(node, text);
30
+ return text;
31
+ }
32
+ finally {
33
+ active.pop();
34
+ }
35
+ }
36
+ function renderSource(source) {
37
+ const list = headings(source.root);
38
+ const depths = levels(list, 1);
39
+ let edits;
40
+ try {
41
+ edits = list.flatMap((heading, i) => levelEdit(heading, depths[i]) ?? []);
42
+ }
43
+ catch (error) {
44
+ throw new Error(`${source.file}: ${message(error)}`);
45
+ }
46
+ edits.push(...referenceEdits(source.body, source.root));
47
+ for (const call of findCalls(source, list, depths)) {
48
+ const text = expand(call, source);
49
+ // An empty result leaves no block behind.
50
+ edits.push(call.block && text === "" ? removeLines(source.body, call.start, call.end) : { start: call.start, end: call.end, text });
51
+ }
52
+ return mark(source.node, linkToSources(applyEdits(source.body, edits), source));
53
+ }
54
+ function expand(call, source) {
55
+ try {
56
+ const text = evaluate(call.expression, source.node);
57
+ if (!call.block) {
58
+ if (/[\r\n]/.test(text))
59
+ throw new Error("a call inside text must return a single line");
60
+ return text;
61
+ }
62
+ return place(text, call.level + 1);
63
+ }
64
+ catch (error) {
65
+ throw new Error(`${call.where}: ${message(error)}`);
66
+ }
67
+ }
68
+ /**
69
+ * Fit the headings of a call's result in. Those the function wrote itself
70
+ * start at `base`. Each text it got from render() starts one level below the
71
+ * last heading the function wrote above it.
72
+ */
73
+ function place(text, base) {
74
+ const list = headings(parse(text));
75
+ const all = markers(text).regions;
76
+ const outer = all.filter((region) => !all.some((other) => other !== region && other.start <= region.start && region.end <= other.end));
77
+ const own = list.filter((heading) => !outer.some((region) => contains(region, start(heading))));
78
+ const depths = levels(own, base);
79
+ const edits = own.flatMap((heading, i) => levelEdit(heading, depths[i]) ?? []);
80
+ for (const region of outer) {
81
+ const inside = list.filter((heading) => contains(region, start(heading)));
82
+ const fitted = levels(inside, levelBefore(own, depths, region.start, base - 1) + 1);
83
+ edits.push(...inside.flatMap((heading, i) => levelEdit(heading, fitted[i]) ?? []));
84
+ }
85
+ return applyEdits(text, edits);
86
+ }
87
+ function evaluate(expression, node) {
88
+ const functions = config ? config({ node, render }) : {};
89
+ if (typeof functions !== "object" || functions === null)
90
+ throw new Error("holon.config.mjs must return an object of functions");
91
+ for (const name of Object.keys(functions)) {
92
+ if (name === "node" || name === "render")
93
+ throw new Error(`holon.config.mjs: "${name}" is taken`);
94
+ if (!usable(name))
95
+ throw new Error(`holon.config.mjs: "${name}" cannot be the name of a function`);
96
+ }
97
+ const scope = { ...functions, node, render };
98
+ const run = new Function(...Object.keys(scope), `"use strict";\nreturn (\n${expression}\n);`);
99
+ const value = run(...Object.values(scope));
100
+ if (typeof value !== "string")
101
+ throw new Error(`a call must produce a string, got ${describe(value)}`);
102
+ return value;
103
+ }
104
+ /** Rewrite links to absolute source paths, so they keep their meaning wherever the text lands. */
105
+ function linkToSources(text, source) {
106
+ const where = source.file ?? source.node.path;
107
+ const edits = [];
108
+ for (const found of destinations(text, parse(text))) {
109
+ const target = resolveLink(tree, found.url, source, where);
110
+ if (target)
111
+ edits.push({ start: found.start, end: found.end, text: formatUrl(target.path, target.fragment) });
112
+ }
113
+ return applyEdits(text, edits);
114
+ }
115
+ /** Wrap a node's text in marker lines. They stand on lines of their own, so blank lines around the text go. Empty text gets no markers. */
116
+ function mark(node, text) {
117
+ const body = text.replace(/^(?:[ \t]*\n)+/, "").trimEnd();
118
+ if (body === "")
119
+ return "";
120
+ const path = encodeURI(node.path);
121
+ return `<!-- holon ${id} start ${path} -->\n${body}\n<!-- holon ${id} end ${path} -->`;
122
+ }
123
+ /**
124
+ * The regions the marker lines in `text` enclose, and the changes that remove
125
+ * those lines. A marker line may carry a prefix that a function added to every
126
+ * line, like `> ` or spaces, but nothing else.
127
+ */
128
+ function markers(text) {
129
+ const regions = [];
130
+ const removals = [];
131
+ const open = [];
132
+ for (const found of text.matchAll(new RegExp(`<!-- holon ${id} (start|end) (\\S+) -->`, "g"))) {
133
+ const path = decodeURI(found[2]);
134
+ const removal = removeLines(text, found.index, found.index + found[0].length);
135
+ const before = text.slice(removal.start, found.index);
136
+ const after = text.slice(found.index + found[0].length, removal.end);
137
+ if (!/^[ \t>]*$/.test(before) || !/^[ \t]*\n?$/.test(after)) {
138
+ throw new Error(`a function wrote text next to a marker line of ${path}; the text render() returns must stay on lines of its own`);
139
+ }
140
+ removals.push(removal);
141
+ if (found[1] === "start") {
142
+ open.push({ path, start: removal.end });
143
+ continue;
144
+ }
145
+ const index = open.map((region) => region.path).lastIndexOf(path);
146
+ if (index < 0)
147
+ throw cutApart(path);
148
+ if (index < open.length - 1)
149
+ throw cutApart(open[open.length - 1].path);
150
+ regions.push({ path, start: open.pop().start, end: removal.start });
151
+ }
152
+ if (open.length > 0)
153
+ throw cutApart(open[open.length - 1].path);
154
+ return { regions, removals };
155
+ }
156
+ return { render, markers };
157
+ }
158
+ function cutApart(path) {
159
+ return new Error(`a function cut the rendered text of ${path} apart; keep the marker lines render() puts around it`);
160
+ }
161
+ /** Whether `offset` lies in `region`. */
162
+ export function contains(region, offset) {
163
+ return region.start <= offset && offset < region.end;
164
+ }
165
+ /** The level of the last heading in `list` before `offset`, or `none` if there is no heading before it. */
166
+ function levelBefore(list, depths, offset, none) {
167
+ let level = none;
168
+ for (let i = 0; i < list.length && start(list[i]) < offset; i++)
169
+ level = depths[i];
170
+ return level;
171
+ }
172
+ /** Whether a call can use `name`: JavaScript accepts it as the name of a function. */
173
+ function usable(name) {
174
+ try {
175
+ new Function(`"use strict"; function ${name}() {}`);
176
+ return true;
177
+ }
178
+ catch {
179
+ return false;
180
+ }
181
+ }
182
+ function findCalls(source, list, depths) {
183
+ const calls = [];
184
+ walk(source.root, (node, ancestors) => {
185
+ const expression = holonExpression(node, source);
186
+ if (expression === null)
187
+ return;
188
+ const parent = ancestors[ancestors.length - 1];
189
+ const where = location(source, start(node));
190
+ let block;
191
+ if (node.type === "code") {
192
+ if (parent?.type !== "root")
193
+ throw new Error(`${where}: a holon block must stand at the top level, not inside a ${parent?.type}`);
194
+ block = true;
195
+ }
196
+ else {
197
+ block = parent?.type === "paragraph" && parent.children.length === 1 && ancestors[ancestors.length - 2]?.type === "root";
198
+ }
199
+ // The call sits below the last heading before it, at that heading's level in the finished text.
200
+ const level = levelBefore(list, depths, start(node), 0);
201
+ calls.push({ expression, start: start(node), end: end(node), block, level, where });
202
+ });
203
+ return calls;
204
+ }
205
+ /** The expression of a holon call, or null if the node is not one. */
206
+ function holonExpression(node, source) {
207
+ if (node.type === "code" && node.lang === "holon") {
208
+ if (node.meta)
209
+ throw new Error(`${location(source, start(node))}: unexpected "${node.meta}" after holon`);
210
+ return node.value;
211
+ }
212
+ if (node.type === "inlineCode") {
213
+ const match = /^holon\s+(\S[\s\S]*)$/.exec(node.value);
214
+ return match ? match[1] : null;
215
+ }
216
+ return null;
217
+ }
218
+ function describe(value) {
219
+ if (typeof value === "function")
220
+ return "a function (call it to get its text)";
221
+ if (value === null || value === undefined)
222
+ return String(value);
223
+ return typeof value === "object" ? "an object" : `the ${typeof value} ${String(value)}`;
224
+ }
package/dist/tree.d.ts ADDED
@@ -0,0 +1,48 @@
1
+ import type { Root } from "mdast";
2
+ /** A folder or a Markdown file, as functions see it. */
3
+ export type Node = {
4
+ /** Path from the source folder: `/` for the root, `/guides` for a folder, `/guides/setup.md` for a file. */
5
+ path: string;
6
+ /** The front matter. */
7
+ meta: Record<string, unknown>;
8
+ /** Text of the first heading as written, or null. */
9
+ title: string | null;
10
+ /** Output path from `render_to`, or null. */
11
+ artifact: string | null;
12
+ children: readonly Node[];
13
+ /** Nodes whose text links to this node. */
14
+ backlinks: readonly Node[];
15
+ /** The node at `path`, relative to this node's folder or, with a leading `/`, to the source folder. */
16
+ at(path: string): Node;
17
+ };
18
+ /** What Holon keeps about a node besides what functions see. */
19
+ export type Source = {
20
+ node: Node;
21
+ /** The file holding the node's text, for messages. Null for a folder without index.md. */
22
+ file: string | null;
23
+ /** The folder that relative paths in the text start from. */
24
+ dir: string;
25
+ /** The text after the front matter. */
26
+ body: string;
27
+ root: Root;
28
+ /** Line of the file on which `body` starts. */
29
+ line: number;
30
+ };
31
+ export type Tree = {
32
+ dir: string;
33
+ root: Node;
34
+ nodes: Map<string, Node>;
35
+ sources: Map<Node, Source>;
36
+ };
37
+ /** A link target in source terms, or null for links Holon leaves alone. */
38
+ export type Target = {
39
+ path: string;
40
+ fragment: string | null;
41
+ node: Node | null;
42
+ };
43
+ export declare function loadTree(dir: string): Tree;
44
+ export declare function preorder(node: Node): Node[];
45
+ export declare function location(source: Source, offset: number): string;
46
+ /** The target of `url` in `source`'s text, or null for links Holon leaves alone. */
47
+ export declare function resolveLink(tree: Tree, url: string, source: Source, where: string): Target | null;
48
+ export declare function message(error: unknown): string;
package/dist/tree.js ADDED
@@ -0,0 +1,150 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { join, posix } from "node:path";
3
+ import { parse as parseYaml } from "yaml";
4
+ import { destinations, headings, parse, plainText } from "./markdown.js";
5
+ export function loadTree(dir) {
6
+ const tree = { dir, root: undefined, nodes: new Map(), sources: new Map() };
7
+ tree.root = loadFolder(tree, "/") ?? loadNode(tree, "/", null, "/", []);
8
+ for (const node of preorder(tree.root)) {
9
+ const source = tree.sources.get(node);
10
+ for (const { url, start } of destinations(source.body, source.root)) {
11
+ const target = resolveLink(tree, url, source, location(source, start))?.node;
12
+ if (target && target !== node && !target.backlinks.includes(node))
13
+ target.backlinks.push(node);
14
+ }
15
+ }
16
+ // Functions share these nodes, so nobody may change them.
17
+ for (const node of tree.nodes.values()) {
18
+ freeze(node.meta);
19
+ Object.freeze(node.children);
20
+ Object.freeze(node.backlinks);
21
+ Object.freeze(node);
22
+ }
23
+ return tree;
24
+ }
25
+ /** Freeze `value` and everything in it. */
26
+ function freeze(value) {
27
+ if (Object.isFrozen(value))
28
+ return;
29
+ Object.freeze(value);
30
+ for (const inner of Object.values(value))
31
+ freeze(inner);
32
+ }
33
+ export function preorder(node) {
34
+ return [node, ...node.children.flatMap(preorder)];
35
+ }
36
+ export function location(source, offset) {
37
+ const line = source.line + source.body.slice(0, offset).split("\n").length - 1;
38
+ return `${source.file ?? source.node.path}:${line}`;
39
+ }
40
+ /** Resolve `target` against folder `dir`. Paths leaving the source folder keep their leading `..` segments. */
41
+ function resolvePath(dir, target) {
42
+ const parts = target.startsWith("/") ? [] : dir.split("/").filter(Boolean);
43
+ for (const segment of target.split("/")) {
44
+ if (segment === "" || segment === ".")
45
+ continue;
46
+ if (segment === ".." && parts.length > 0 && parts[parts.length - 1] !== "..")
47
+ parts.pop();
48
+ else
49
+ parts.push(segment);
50
+ }
51
+ return `/${parts.join("/")}`;
52
+ }
53
+ function lookup(tree, path) {
54
+ return tree.nodes.get(path.endsWith("/index.md") ? path.slice(0, -"/index.md".length) || "/" : path);
55
+ }
56
+ /** The target of `url` in `source`'s text, or null for links Holon leaves alone. */
57
+ export function resolveLink(tree, url, source, where) {
58
+ if (url === "" || /^(\?|\/\/|[a-z][a-z0-9+.-]*:)/i.test(url))
59
+ return null;
60
+ const hash = url.indexOf("#");
61
+ const path = hash === 0 ? source.node.path : resolvePath(source.dir, decode(hash < 0 ? url : url.slice(0, hash)));
62
+ const fragment = hash < 0 || hash === url.length - 1 ? null : url.slice(hash + 1);
63
+ const node = lookup(tree, path) ?? null;
64
+ if (!node && path.endsWith(".md") && !path.startsWith("/..")) {
65
+ throw new Error(`${where}: link to ${path}, which is not a Markdown file in the source folder`);
66
+ }
67
+ return { path, fragment, node };
68
+ }
69
+ export function message(error) {
70
+ return error instanceof Error ? error.message : String(error);
71
+ }
72
+ function decode(path) {
73
+ try {
74
+ return decodeURIComponent(path);
75
+ }
76
+ catch {
77
+ return path;
78
+ }
79
+ }
80
+ function loadFolder(tree, path) {
81
+ const entries = readdirSync(join(tree.dir, path), { withFileTypes: true })
82
+ .filter((entry) => !entry.name.startsWith("."))
83
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
84
+ const children = [];
85
+ let index = null;
86
+ for (const entry of entries) {
87
+ const childPath = path === "/" ? `/${entry.name}` : `${path}/${entry.name}`;
88
+ if (entry.isDirectory()) {
89
+ const child = loadFolder(tree, childPath);
90
+ if (child)
91
+ children.push(child);
92
+ }
93
+ else if (entry.name === "index.md") {
94
+ index = childPath;
95
+ }
96
+ else if (entry.name.endsWith(".md")) {
97
+ children.push(loadNode(tree, childPath, childPath, path, []));
98
+ }
99
+ }
100
+ if (!index && children.length === 0)
101
+ return null;
102
+ return loadNode(tree, path, index, path, children);
103
+ }
104
+ function loadNode(tree, path, file, dir, children) {
105
+ const text = file ? readFileSync(join(tree.dir, file), "utf8").replace(/^/, "").replace(/\r\n?/g, "\n") : "";
106
+ const { meta, body, line } = splitFrontMatter(text, file);
107
+ const root = parse(body);
108
+ const first = headings(root)[0];
109
+ const node = {
110
+ path,
111
+ meta,
112
+ title: first ? plainText(first).trim() : null,
113
+ artifact: renderTo(meta, file),
114
+ children,
115
+ backlinks: [],
116
+ at: (target) => {
117
+ const found = lookup(tree, resolvePath(dir, target));
118
+ if (!found)
119
+ throw new Error(`${path}: at("${target}") matches no Markdown file or folder`);
120
+ return found;
121
+ },
122
+ };
123
+ tree.nodes.set(path, node);
124
+ tree.sources.set(node, { node, file, dir, body, root, line });
125
+ return node;
126
+ }
127
+ function splitFrontMatter(text, file) {
128
+ const match = /^---[ \t]*\n(?:([\s\S]*?)\n)?---[ \t]*(?:\n|$)/.exec(text);
129
+ if (!match)
130
+ return { meta: {}, body: text, line: 1 };
131
+ let meta;
132
+ try {
133
+ meta = parseYaml(match[1] ?? "") ?? {};
134
+ }
135
+ catch (error) {
136
+ throw new Error(`${file}: front matter is not valid YAML: ${message(error)}`);
137
+ }
138
+ if (typeof meta !== "object" || Array.isArray(meta))
139
+ throw new Error(`${file}: front matter must be a mapping`);
140
+ return { meta: meta, body: text.slice(match[0].length), line: match[0].split("\n").length };
141
+ }
142
+ function renderTo(meta, file) {
143
+ const value = meta.render_to;
144
+ if (value === undefined)
145
+ return null;
146
+ if (typeof value !== "string" || value.includes("\\") || value.endsWith("/")) {
147
+ throw new Error(`${file}: render_to must be a file path with forward slashes, like guides/setup.md`);
148
+ }
149
+ return posix.normalize(value);
150
+ }
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "holon-md",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Compile a folder of small Markdown files into the documents people read.",
5
+ "keywords": [
6
+ "markdown",
7
+ "documentation",
8
+ "docs-as-code",
9
+ "readme",
10
+ "include",
11
+ "transclusion",
12
+ "single-source",
13
+ "compiler",
14
+ "cli"
15
+ ],
16
+ "license": "MIT",
17
+ "author": "Lukas Brückner",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/LukasB97/holon.git"
21
+ },
22
+ "homepage": "https://github.com/LukasB97/holon#readme",
23
+ "bugs": "https://github.com/LukasB97/holon/issues",
24
+ "type": "module",
25
+ "bin": {
26
+ "holon": "dist/cli.js"
27
+ },
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "default": "./dist/index.js"
32
+ }
33
+ },
34
+ "files": [
35
+ "dist"
36
+ ],
37
+ "engines": {
38
+ "node": ">=22.18"
39
+ },
40
+ "dependencies": {
41
+ "mdast-util-from-markdown": "^2.1.0",
42
+ "mdast-util-gfm": "^3.1.0",
43
+ "micromark": "^4.0.3",
44
+ "micromark-extension-gfm": "^3.0.0",
45
+ "yaml": "^2.9.1"
46
+ },
47
+ "devDependencies": {
48
+ "@types/mdast": "^4.0.4",
49
+ "@types/node": "^22.20.5",
50
+ "typescript": "^7.0.2"
51
+ },
52
+ "scripts": {
53
+ "build": "tsc",
54
+ "test": "node --test test/holon.test.ts",
55
+ "docs": "node src/cli.ts docs ."
56
+ }
6
57
  }