@ferax564/noma-cli 0.11.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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +199 -0
  3. package/bin/noma.mjs +8 -0
  4. package/dist/ast.d.ts +111 -0
  5. package/dist/ast.js +23 -0
  6. package/dist/ast.js.map +1 -0
  7. package/dist/book.d.ts +56 -0
  8. package/dist/book.js +120 -0
  9. package/dist/book.js.map +1 -0
  10. package/dist/cli.d.ts +2 -0
  11. package/dist/cli.js +573 -0
  12. package/dist/cli.js.map +1 -0
  13. package/dist/diff.d.ts +29 -0
  14. package/dist/diff.js +77 -0
  15. package/dist/diff.js.map +1 -0
  16. package/dist/fmt.d.ts +1 -0
  17. package/dist/fmt.js +105 -0
  18. package/dist/fmt.js.map +1 -0
  19. package/dist/ids.d.ts +15 -0
  20. package/dist/ids.js +27 -0
  21. package/dist/ids.js.map +1 -0
  22. package/dist/index.d.ts +20 -0
  23. package/dist/index.js +12 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/inline.d.ts +14 -0
  26. package/dist/inline.js +83 -0
  27. package/dist/inline.js.map +1 -0
  28. package/dist/loader.d.ts +12 -0
  29. package/dist/loader.js +59 -0
  30. package/dist/loader.js.map +1 -0
  31. package/dist/parser.d.ts +7 -0
  32. package/dist/parser.js +434 -0
  33. package/dist/parser.js.map +1 -0
  34. package/dist/patch.d.ts +61 -0
  35. package/dist/patch.js +530 -0
  36. package/dist/patch.js.map +1 -0
  37. package/dist/renderer-html.d.ts +44 -0
  38. package/dist/renderer-html.js +929 -0
  39. package/dist/renderer-html.js.map +1 -0
  40. package/dist/renderer-json.d.ts +5 -0
  41. package/dist/renderer-json.js +4 -0
  42. package/dist/renderer-json.js.map +1 -0
  43. package/dist/renderer-llm.d.ts +29 -0
  44. package/dist/renderer-llm.js +275 -0
  45. package/dist/renderer-llm.js.map +1 -0
  46. package/dist/renderer-noma.d.ts +10 -0
  47. package/dist/renderer-noma.js +179 -0
  48. package/dist/renderer-noma.js.map +1 -0
  49. package/dist/renderer-site.d.ts +11 -0
  50. package/dist/renderer-site.js +175 -0
  51. package/dist/renderer-site.js.map +1 -0
  52. package/dist/validator.d.ts +24 -0
  53. package/dist/validator.js +699 -0
  54. package/dist/validator.js.map +1 -0
  55. package/dist/verify.d.ts +10 -0
  56. package/dist/verify.js +141 -0
  57. package/dist/verify.js.map +1 -0
  58. package/package.json +83 -0
  59. package/schemas/ast.schema.json +187 -0
  60. package/schemas/capability.schema.json +70 -0
  61. package/schemas/patch-op.schema.json +92 -0
  62. package/schemas/patch-transaction.schema.json +28 -0
  63. package/schemas/transcript.schema.json +95 -0
  64. package/src/ast.ts +152 -0
  65. package/src/book.ts +162 -0
  66. package/src/cli.ts +595 -0
  67. package/src/diff.ts +108 -0
  68. package/src/fmt.ts +126 -0
  69. package/src/ids.ts +42 -0
  70. package/src/index.ts +20 -0
  71. package/src/inline.ts +92 -0
  72. package/src/loader.ts +55 -0
  73. package/src/parser.ts +501 -0
  74. package/src/patch.ts +646 -0
  75. package/src/renderer-html.ts +1047 -0
  76. package/src/renderer-json.ts +9 -0
  77. package/src/renderer-llm.ts +320 -0
  78. package/src/renderer-noma.ts +220 -0
  79. package/src/renderer-site.ts +245 -0
  80. package/src/validator.ts +733 -0
  81. package/src/verify.ts +157 -0
  82. package/themes/dark.css +382 -0
  83. package/themes/default.css +537 -0
@@ -0,0 +1,9 @@
1
+ import type { DocumentNode } from "./ast.js";
2
+
3
+ export interface JsonRenderOptions {
4
+ pretty?: boolean;
5
+ }
6
+
7
+ export function renderJson(doc: DocumentNode, options: JsonRenderOptions = {}): string {
8
+ return JSON.stringify(doc, null, options.pretty === false ? 0 : 2);
9
+ }
@@ -0,0 +1,320 @@
1
+ import type { DirectiveNode, DocumentNode, Node, SectionNode } from "./ast.js";
2
+ import { walk } from "./ast.js";
3
+ import { inlineToPlain } from "./inline.js";
4
+
5
+ export interface RenderLlmOptions {
6
+ /**
7
+ * When set, ::memory directives whose `last_seen` attribute is older than
8
+ * `days` from `now` are omitted from the output, AND ::memory_index body
9
+ * lines whose [[wikilinks]] resolve only to omitted memories are dropped
10
+ * (no dangling references in the LLM context).
11
+ *
12
+ * Durable memory types (`user`, `feedback`) are kept regardless of
13
+ * `last_seen` unless they carry `expired=true`. Time-window staleness
14
+ * applies only to `project` and `reference` memories by default, since
15
+ * those are the types whose facts go stale over calendar time.
16
+ */
17
+ excludeStale?: { now: Date; days: number };
18
+ /** Include only matching AST node types or directive names, plus ancestors. */
19
+ select?: string[];
20
+ /** Omit matching AST node types or directive names and their children. */
21
+ exclude?: string[];
22
+ /** Maximum output length in characters, trimmed at a line boundary when possible. */
23
+ budget?: number;
24
+ }
25
+
26
+ interface RenderCtx extends RenderLlmOptions {
27
+ excludedMemoryIds: Set<string>;
28
+ selectSet: Set<string>;
29
+ excludeSet: Set<string>;
30
+ }
31
+
32
+ const STALE_OPT_IN_TYPES = new Set(["project", "reference"]);
33
+
34
+ /**
35
+ * Deterministic plain-text export designed for LLM context windows.
36
+ * Stable line ordering, explicit semantic tags, no HTML noise.
37
+ */
38
+ export function renderLlm(doc: DocumentNode, options: RenderLlmOptions = {}): string {
39
+ const ctx: RenderCtx = {
40
+ ...options,
41
+ excludedMemoryIds: computeExcludedMemoryIds(doc, options),
42
+ selectSet: normalizeSelectors(options.select),
43
+ excludeSet: normalizeSelectors(options.exclude),
44
+ };
45
+ const out: string[] = [];
46
+ if (doc.meta.title) out.push(`# ${String(doc.meta.title)}`);
47
+ for (const child of doc.children) {
48
+ if (shouldEmit(child, ctx, false)) emit(child, out, 0, ctx, false);
49
+ }
50
+ const rendered = out.join("\n").replace(/\n{3,}/g, "\n\n").trim() + "\n";
51
+ return applyBudget(rendered, options.budget);
52
+ }
53
+
54
+ function normalizeSelectors(values: string[] | undefined): Set<string> {
55
+ const out = new Set<string>();
56
+ for (const raw of values ?? []) {
57
+ for (const part of raw.split(/[,\s]+/)) {
58
+ const clean = part.trim().toLowerCase();
59
+ if (clean) out.add(clean);
60
+ }
61
+ }
62
+ return out;
63
+ }
64
+
65
+ function applyBudget(text: string, budget: number | undefined): string {
66
+ if (budget === undefined || !Number.isFinite(budget) || budget <= 0) return text;
67
+ if (text.length <= budget) return text;
68
+ const marker = `\n\n[LLM context truncated at ${budget} characters]\n`;
69
+ if (budget <= marker.length) return marker.slice(0, budget);
70
+ const limit = budget - marker.length;
71
+ const cut = text.lastIndexOf("\n", limit);
72
+ return text.slice(0, cut > 0 ? cut : limit).trimEnd() + marker;
73
+ }
74
+
75
+ function computeExcludedMemoryIds(
76
+ doc: DocumentNode,
77
+ options: RenderLlmOptions,
78
+ ): Set<string> {
79
+ const excluded = new Set<string>();
80
+ if (!options.excludeStale) return excluded;
81
+ for (const node of walk(doc)) {
82
+ if (node.type !== "directive") continue;
83
+ if (node.name !== "memory") continue;
84
+ if (!node.id) continue;
85
+ if (isStale(node, options.excludeStale)) excluded.add(node.id);
86
+ }
87
+ return excluded;
88
+ }
89
+
90
+ function emit(
91
+ node: Node,
92
+ out: string[],
93
+ depth: number,
94
+ opts: RenderCtx,
95
+ forceSubtree: boolean,
96
+ ): void {
97
+ switch (node.type) {
98
+ case "document":
99
+ for (const child of node.children) {
100
+ if (shouldEmit(child, opts, forceSubtree)) emit(child, out, depth, opts, forceSubtree);
101
+ }
102
+ return;
103
+ case "section":
104
+ emitSection(node, out, depth, opts, forceSubtree);
105
+ return;
106
+ case "paragraph":
107
+ out.push(inlineToPlain(node.content));
108
+ out.push("");
109
+ return;
110
+ case "code":
111
+ out.push("```" + (node.lang ?? ""));
112
+ out.push(node.content);
113
+ out.push("```");
114
+ out.push("");
115
+ return;
116
+ case "list":
117
+ for (const item of node.items) {
118
+ out.push(`- ${inlineToPlain(item.content)}`);
119
+ }
120
+ out.push("");
121
+ return;
122
+ case "list_item":
123
+ out.push(`- ${inlineToPlain(node.content)}`);
124
+ return;
125
+ case "quote":
126
+ for (const line of node.content.split("\n")) out.push(`> ${line}`);
127
+ out.push("");
128
+ return;
129
+ case "thematic_break":
130
+ out.push("---");
131
+ out.push("");
132
+ return;
133
+ case "table": {
134
+ const widths = node.header.map((h, i) =>
135
+ Math.max(
136
+ h.length,
137
+ ...node.rows.map((r) => (r[i] ?? "").length),
138
+ 3,
139
+ ),
140
+ );
141
+ const fmt = (cells: string[]) =>
142
+ "| " +
143
+ cells.map((c, i) => c.padEnd(widths[i] ?? c.length)).join(" | ") +
144
+ " |";
145
+ out.push(fmt(node.header));
146
+ out.push(
147
+ "| " +
148
+ widths.map((w, i) => {
149
+ const a = node.align[i];
150
+ const dashes = "-".repeat(Math.max(3, w));
151
+ if (a === "center") return `:${dashes.slice(0, -2)}-:`;
152
+ if (a === "right") return `${dashes.slice(0, -1)}:`;
153
+ if (a === "left") return `:${dashes.slice(0, -1)}`;
154
+ return dashes;
155
+ }).join(" | ") +
156
+ " |",
157
+ );
158
+ for (const row of node.rows) out.push(fmt(row));
159
+ out.push("");
160
+ return;
161
+ }
162
+ case "directive":
163
+ emitDirective(node, out, depth, opts, forceSubtree);
164
+ return;
165
+ case "frontmatter":
166
+ return;
167
+ default: {
168
+ const _exhaustive: never = node;
169
+ void _exhaustive;
170
+ }
171
+ }
172
+ }
173
+
174
+ function emitSection(
175
+ node: SectionNode,
176
+ out: string[],
177
+ depth: number,
178
+ opts: RenderCtx,
179
+ forceSubtree: boolean,
180
+ ): void {
181
+ const hashes = "#".repeat(node.level);
182
+ out.push(`${hashes} ${node.title}${node.id ? ` [#${node.id}]` : ""}`);
183
+ out.push("");
184
+ const childForce = forceSubtree || matchesSelector(node, opts.selectSet);
185
+ for (const child of node.children) {
186
+ if (shouldEmit(child, opts, childForce)) emit(child, out, depth, opts, childForce);
187
+ }
188
+ }
189
+
190
+ const VERBATIM_BODY = new Set(["diagram", "plotly", "math"]);
191
+
192
+ function emitDirective(
193
+ node: DirectiveNode,
194
+ out: string[],
195
+ depth: number,
196
+ opts: RenderCtx,
197
+ forceSubtree: boolean,
198
+ ): void {
199
+ if (node.name === "html" || node.name === "svg" || node.name === "script") {
200
+ out.push(`[${node.name.toUpperCase()} escape-hatch block omitted from LLM context]`);
201
+ out.push("");
202
+ return;
203
+ }
204
+ if (node.name === "memory" && opts.excludeStale && isStale(node, opts.excludeStale)) {
205
+ return;
206
+ }
207
+ const tag = node.name.toUpperCase();
208
+ const attrs = Object.entries(node.attrs)
209
+ .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
210
+ .join(" ");
211
+ out.push(`[${tag}${attrs ? " " + attrs : ""}]`);
212
+ const isIndexWithExclusions =
213
+ node.name === "memory_index" && opts.excludedMemoryIds.size > 0;
214
+ const childForce = forceSubtree || matchesSelector(node, opts.selectSet);
215
+ if (VERBATIM_BODY.has(node.name) && node.body !== undefined) {
216
+ out.push(node.body);
217
+ } else if (node.children.length === 0 && node.body !== undefined) {
218
+ const body = isIndexWithExclusions
219
+ ? filterMemoryIndexBody(node.body, opts.excludedMemoryIds)
220
+ : node.body;
221
+ out.push(inlineToPlain(body));
222
+ } else if (isIndexWithExclusions) {
223
+ for (const child of node.children)
224
+ emitFilteredIndexChild(child, out, depth + 1, opts);
225
+ } else {
226
+ for (const child of node.children) {
227
+ if (shouldEmit(child, opts, childForce)) emit(child, out, depth + 1, opts, childForce);
228
+ }
229
+ }
230
+ out.push(`[/${tag}]`);
231
+ out.push("");
232
+ }
233
+
234
+ function shouldEmit(node: Node, opts: RenderCtx, forceSubtree: boolean): boolean {
235
+ if (matchesSelector(node, opts.excludeSet)) return false;
236
+ if (forceSubtree || opts.selectSet.size === 0) return true;
237
+ if (matchesSelector(node, opts.selectSet)) return true;
238
+ return hasSelectableDescendant(node, opts);
239
+ }
240
+
241
+ function hasSelectableDescendant(node: Node, opts: RenderCtx): boolean {
242
+ for (const child of childrenOf(node)) {
243
+ if (matchesSelector(child, opts.excludeSet)) continue;
244
+ if (matchesSelector(child, opts.selectSet)) return true;
245
+ if (hasSelectableDescendant(child, opts)) return true;
246
+ }
247
+ return false;
248
+ }
249
+
250
+ function matchesSelector(node: Node, selectors: Set<string>): boolean {
251
+ if (selectors.size === 0) return false;
252
+ if (selectors.has(node.type)) return true;
253
+ return node.type === "directive" && selectors.has(node.name.toLowerCase());
254
+ }
255
+
256
+ function childrenOf(node: Node): Node[] {
257
+ if (node.type === "document" || node.type === "section" || node.type === "directive") {
258
+ return node.children;
259
+ }
260
+ if (node.type === "list") return node.items;
261
+ return [];
262
+ }
263
+
264
+ const WIKILINK_RE = /\[\[([a-zA-Z_][\w\-./:]*)\]\]/g;
265
+
266
+ function filterMemoryIndexBody(body: string, excluded: Set<string>): string {
267
+ if (excluded.size === 0) return body;
268
+ return body
269
+ .split("\n")
270
+ .filter((line) => {
271
+ const matches = [...line.matchAll(WIKILINK_RE)];
272
+ if (matches.length === 0) return true;
273
+ return !matches.every((m) => excluded.has(m[1]!));
274
+ })
275
+ .join("\n");
276
+ }
277
+
278
+ function emitFilteredIndexChild(
279
+ node: Node,
280
+ out: string[],
281
+ depth: number,
282
+ opts: RenderCtx,
283
+ ): void {
284
+ if (node.type === "list") {
285
+ const survivors = node.items.filter((item) => {
286
+ const matches = [...item.content.matchAll(WIKILINK_RE)];
287
+ if (matches.length === 0) return true;
288
+ return !matches.every((m) => opts.excludedMemoryIds.has(m[1]!));
289
+ });
290
+ if (survivors.length === 0) return;
291
+ for (const item of survivors)
292
+ out.push(`- ${inlineToPlain(item.content)}`);
293
+ out.push("");
294
+ return;
295
+ }
296
+ if (node.type === "paragraph") {
297
+ const matches = [...node.content.matchAll(WIKILINK_RE)];
298
+ const allExcluded =
299
+ matches.length > 0 && matches.every((m) => opts.excludedMemoryIds.has(m[1]!));
300
+ if (allExcluded) return;
301
+ out.push(inlineToPlain(node.content));
302
+ out.push("");
303
+ return;
304
+ }
305
+ emit(node, out, depth, opts, false);
306
+ }
307
+
308
+ function isStale(
309
+ node: DirectiveNode,
310
+ cfg: { now: Date; days: number },
311
+ ): boolean {
312
+ const ls = node.attrs.last_seen;
313
+ if (typeof ls !== "string" || !ls) return false;
314
+ const t = Date.parse(ls);
315
+ if (Number.isNaN(t)) return false;
316
+ const type = typeof node.attrs.type === "string" ? node.attrs.type : "";
317
+ const expired = node.attrs.expired === true;
318
+ if (!STALE_OPT_IN_TYPES.has(type) && !expired) return false;
319
+ return cfg.now.getTime() - t > cfg.days * 24 * 60 * 60 * 1000;
320
+ }
@@ -0,0 +1,220 @@
1
+ import yaml from "js-yaml";
2
+ import { slugify } from "./parser.js";
3
+ import type {
4
+ Attrs,
5
+ AttrValue,
6
+ CodeNode,
7
+ DirectiveNode,
8
+ DocumentNode,
9
+ FrontmatterNode,
10
+ ListNode,
11
+ Node,
12
+ ParagraphNode,
13
+ QuoteNode,
14
+ SectionNode,
15
+ TableNode,
16
+ ThematicBreakNode,
17
+ } from "./ast.js";
18
+
19
+ export interface NomaRenderOptions {
20
+ /** Drop internal meta keys (filename, pos) from frontmatter. Default: true. */
21
+ stripInternal?: boolean;
22
+ }
23
+
24
+ const INTERNAL_META_KEYS = new Set(["filename"]);
25
+
26
+ /**
27
+ * AST → .noma source. Designed for roundtrip: `parse(renderNoma(doc))` should
28
+ * yield a structurally equal AST (modulo positions). Foundation for `noma patch`.
29
+ */
30
+ export function renderNoma(doc: DocumentNode, options: NomaRenderOptions = {}): string {
31
+ const stripInternal = options.stripInternal !== false;
32
+ const out: string[] = [];
33
+ const ctx = buildContext(doc);
34
+
35
+ const hasFrontmatterNode = doc.children[0]?.type === "frontmatter";
36
+ if (!hasFrontmatterNode) {
37
+ const metaEntries = Object.entries(doc.meta).filter(
38
+ ([k]) => !stripInternal || !INTERNAL_META_KEYS.has(k),
39
+ );
40
+ if (metaEntries.length > 0) {
41
+ out.push("---");
42
+ out.push(yaml.dump(Object.fromEntries(metaEntries)).trimEnd());
43
+ out.push("---");
44
+ out.push("");
45
+ }
46
+ }
47
+
48
+ for (const child of doc.children) {
49
+ out.push(renderNode(child, 2, ctx));
50
+ out.push("");
51
+ }
52
+
53
+ return out.join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
54
+ }
55
+
56
+ interface RenderCtx {
57
+ /** Aliases that the parser/loader will re-derive on parse and so don't
58
+ * need to be emitted on the heading. Filename slug + frontmatter list. */
59
+ regenAliases: Set<string>;
60
+ }
61
+
62
+ function buildContext(doc: DocumentNode): RenderCtx {
63
+ const regenAliases = new Set<string>();
64
+ if (Array.isArray(doc.meta.aliases)) {
65
+ for (const a of doc.meta.aliases) {
66
+ if (typeof a === "string" && a.trim()) regenAliases.add(a.trim());
67
+ }
68
+ }
69
+ if (typeof doc.meta.filename === "string") {
70
+ const base = doc.meta.filename.replace(/\\/g, "/").split("/").pop() ?? "";
71
+ const stem = base.replace(/\.noma$/i, "").replace(/^\d+[-_]/, "");
72
+ const slug = slugify(stem);
73
+ if (slug) regenAliases.add(slug);
74
+ }
75
+ return { regenAliases };
76
+ }
77
+
78
+ function renderNode(node: Node, colons: number, ctx: RenderCtx): string {
79
+ switch (node.type) {
80
+ case "document":
81
+ return node.children.map((c) => renderNode(c, colons, ctx)).join("\n\n");
82
+ case "section":
83
+ return renderSection(node, colons, ctx);
84
+ case "paragraph":
85
+ return renderParagraph(node);
86
+ case "code":
87
+ return renderCode(node);
88
+ case "list":
89
+ return renderList(node);
90
+ case "list_item":
91
+ return `- ${node.content}`;
92
+ case "quote":
93
+ return renderQuote(node);
94
+ case "thematic_break":
95
+ return renderThematicBreak(node);
96
+ case "table":
97
+ return renderTable(node);
98
+ case "directive":
99
+ return renderDirective(node, colons, ctx);
100
+ case "frontmatter":
101
+ return `---\n${node.raw}\n---`;
102
+ default: {
103
+ const _exhaustive: never = node;
104
+ void _exhaustive;
105
+ return "";
106
+ }
107
+ }
108
+ }
109
+
110
+ function renderSection(node: SectionNode, colons: number, ctx: RenderCtx): string {
111
+ const hashes = "#".repeat(Math.max(1, Math.min(6, node.level)));
112
+ const attrs = headingAttrs(node, ctx);
113
+ const head = attrs ? `${hashes} ${node.title} ${attrs}` : `${hashes} ${node.title}`;
114
+ if (node.children.length === 0) return head;
115
+ const inner = node.children.map((c) => renderNode(c, colons, ctx)).join("\n\n");
116
+ return `${head}\n\n${inner}`;
117
+ }
118
+
119
+ function headingAttrs(node: SectionNode, ctx: RenderCtx): string {
120
+ const explicitId =
121
+ node.id && node.id !== slugify(node.title) ? node.id : undefined;
122
+ // Drop aliases the parser/loader will re-derive (frontmatter list, filename
123
+ // slug). Anything else came from explicit `{aliases="..."}` in source and
124
+ // must be kept to round-trip.
125
+ const aliases = (node.aliases ?? []).filter((a) => !ctx.regenAliases.has(a));
126
+ const parts: string[] = [];
127
+ if (explicitId) parts.push(`id="${explicitId}"`);
128
+ if (aliases.length > 0) {
129
+ parts.push(`aliases="${aliases.join(",")}"`);
130
+ }
131
+ return parts.length > 0 ? `{${parts.join(" ")}}` : "";
132
+ }
133
+
134
+ function renderParagraph(node: ParagraphNode): string {
135
+ return node.content;
136
+ }
137
+
138
+ function renderCode(node: CodeNode): string {
139
+ return "```" + (node.lang ?? "") + "\n" + node.content + "\n```";
140
+ }
141
+
142
+ function renderList(node: ListNode): string {
143
+ if (node.ordered) {
144
+ return node.items.map((it, i) => `${i + 1}. ${it.content}`).join("\n");
145
+ }
146
+ return node.items.map((it) => `- ${it.content}`).join("\n");
147
+ }
148
+
149
+ function renderQuote(node: QuoteNode): string {
150
+ return node.content
151
+ .split("\n")
152
+ .map((l) => (l ? `> ${l}` : ">"))
153
+ .join("\n");
154
+ }
155
+
156
+ function renderThematicBreak(_node: ThematicBreakNode): string {
157
+ return "---";
158
+ }
159
+
160
+ function renderTable(node: TableNode): string {
161
+ const widths = node.header.map((h, i) =>
162
+ Math.max(h.length, ...node.rows.map((r) => (r[i] ?? "").length), 3),
163
+ );
164
+ const fmtRow = (cells: string[]) =>
165
+ "| " +
166
+ cells.map((c, i) => c.padEnd(widths[i] ?? c.length)).join(" | ") +
167
+ " |";
168
+ const sep =
169
+ "| " +
170
+ widths
171
+ .map((w, i) => {
172
+ const a = node.align[i];
173
+ if (a === "center") return ":" + "-".repeat(Math.max(3, w - 2)) + ":";
174
+ if (a === "right") return "-".repeat(Math.max(3, w - 1)) + ":";
175
+ if (a === "left") return ":" + "-".repeat(Math.max(3, w - 1));
176
+ return "-".repeat(Math.max(3, w));
177
+ })
178
+ .join(" | ") +
179
+ " |";
180
+ return [fmtRow(node.header), sep, ...node.rows.map(fmtRow)].join("\n");
181
+ }
182
+
183
+ function renderDirective(node: DirectiveNode, colons: number, ctx: RenderCtx): string {
184
+ const fence = ":".repeat(colons);
185
+ const attrs = serializeAttrs(node.attrs);
186
+ const open = `${fence}${node.name}${attrs ? attrs : ""}`;
187
+ const close = fence;
188
+
189
+ if (node.children.length === 0) {
190
+ if (node.body !== undefined && node.body !== "") {
191
+ return `${open}\n${node.body}\n${close}`;
192
+ }
193
+ return `${open}\n${close}`;
194
+ }
195
+
196
+ const childColons = colons + 1;
197
+ const inner = node.children.map((c) => renderNode(c, childColons, ctx)).join("\n\n");
198
+ return `${open}\n${inner}\n${close}`;
199
+ }
200
+
201
+ function serializeAttrs(attrs: Attrs): string {
202
+ const entries = Object.entries(attrs);
203
+ if (entries.length === 0) return "";
204
+ const parts = entries.map(([k, v]) => serializeAttr(k, v));
205
+ return `{${parts.join(" ")}}`;
206
+ }
207
+
208
+ function serializeAttr(key: string, value: AttrValue): string {
209
+ if (value === true) return key;
210
+ if (value === false) return `${key}=false`;
211
+ if (typeof value === "number") return `${key}=${value}`;
212
+ const s = String(value);
213
+ if (s.includes('"')) {
214
+ if (s.includes("'")) {
215
+ return `${key}="${s.replace(/"/g, '\\"')}"`;
216
+ }
217
+ return `${key}='${s}'`;
218
+ }
219
+ return `${key}="${s}"`;
220
+ }