@vladpazych/dexter 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.
Files changed (68) hide show
  1. package/bin/dexter +6 -0
  2. package/package.json +43 -0
  3. package/src/claude/index.ts +6 -0
  4. package/src/cli.ts +39 -0
  5. package/src/env/define.ts +190 -0
  6. package/src/env/index.ts +10 -0
  7. package/src/env/loader.ts +61 -0
  8. package/src/env/print.ts +98 -0
  9. package/src/env/validate.ts +46 -0
  10. package/src/index.ts +16 -0
  11. package/src/meta/adapters/fs.ts +22 -0
  12. package/src/meta/adapters/git.ts +29 -0
  13. package/src/meta/adapters/glob.ts +14 -0
  14. package/src/meta/adapters/index.ts +24 -0
  15. package/src/meta/adapters/process.ts +40 -0
  16. package/src/meta/cli.ts +340 -0
  17. package/src/meta/domain/bisect.ts +126 -0
  18. package/src/meta/domain/blame.ts +136 -0
  19. package/src/meta/domain/commit.ts +135 -0
  20. package/src/meta/domain/commits.ts +23 -0
  21. package/src/meta/domain/constraints/registry.ts +49 -0
  22. package/src/meta/domain/constraints/types.ts +30 -0
  23. package/src/meta/domain/diff.ts +34 -0
  24. package/src/meta/domain/eval.ts +57 -0
  25. package/src/meta/domain/format.ts +34 -0
  26. package/src/meta/domain/lint.ts +88 -0
  27. package/src/meta/domain/pickaxe.ts +99 -0
  28. package/src/meta/domain/quality.ts +145 -0
  29. package/src/meta/domain/rules.ts +21 -0
  30. package/src/meta/domain/scope-context.ts +63 -0
  31. package/src/meta/domain/service.ts +68 -0
  32. package/src/meta/domain/setup.ts +34 -0
  33. package/src/meta/domain/test.ts +72 -0
  34. package/src/meta/domain/transcripts.ts +88 -0
  35. package/src/meta/domain/typecheck.ts +41 -0
  36. package/src/meta/domain/workspace.ts +78 -0
  37. package/src/meta/errors.ts +19 -0
  38. package/src/meta/hooks/on-post-read.ts +61 -0
  39. package/src/meta/hooks/on-post-write.ts +65 -0
  40. package/src/meta/hooks/on-pre-bash.ts +69 -0
  41. package/src/meta/hooks/stubs.ts +51 -0
  42. package/src/meta/index.ts +36 -0
  43. package/src/meta/lib/actor.ts +53 -0
  44. package/src/meta/lib/eslint.ts +58 -0
  45. package/src/meta/lib/format.ts +55 -0
  46. package/src/meta/lib/paths.ts +36 -0
  47. package/src/meta/lib/present.ts +231 -0
  48. package/src/meta/lib/spec-links.ts +83 -0
  49. package/src/meta/lib/stdin.ts +56 -0
  50. package/src/meta/ports.ts +50 -0
  51. package/src/meta/types.ts +113 -0
  52. package/src/output/build.ts +56 -0
  53. package/src/output/index.ts +24 -0
  54. package/src/output/output.test.ts +374 -0
  55. package/src/output/render-cli.ts +55 -0
  56. package/src/output/render-json.ts +80 -0
  57. package/src/output/render-md.ts +43 -0
  58. package/src/output/render-xml.ts +55 -0
  59. package/src/output/render.ts +23 -0
  60. package/src/output/types.ts +44 -0
  61. package/src/pipe/format.ts +167 -0
  62. package/src/pipe/index.ts +4 -0
  63. package/src/pipe/parse.ts +131 -0
  64. package/src/pipe/spawn.ts +205 -0
  65. package/src/pipe/types.ts +27 -0
  66. package/src/terminal/colors.ts +95 -0
  67. package/src/terminal/index.ts +16 -0
  68. package/src/version.ts +1 -0
@@ -0,0 +1,374 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { block, field, heading, list, render, text, toValue } from "./index.ts"
3
+ import { setColorEnabled } from "../terminal/colors.ts"
4
+
5
+ // Disable colors for deterministic CLI output in tests
6
+ setColorEnabled(false)
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Fixtures
10
+ // ---------------------------------------------------------------------------
11
+
12
+ const commitDoc = block(
13
+ "commit",
14
+ field("hash", "abc123"),
15
+ field("intent", "impl: add feature"),
16
+ field("files", list("file", text("src/a.ts"), text("src/b.ts"))),
17
+ )
18
+
19
+ const scopeDoc = block(
20
+ "scope",
21
+ { path: "meta/src" },
22
+ heading("meta/src"),
23
+ field("status", "clean"),
24
+ field("changes", 0),
25
+ )
26
+
27
+ const mixedDoc = block("result", field("count", 3), text("some note"))
28
+
29
+ const simpleList = list(text("alpha"), text("beta"), text("gamma"))
30
+
31
+ const nestedDoc = block(
32
+ "report",
33
+ heading("Quality Report"),
34
+ block("lint", field("errors", 0), field("warnings", 2)),
35
+ block("typecheck", field("errors", 0)),
36
+ )
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Builders
40
+ // ---------------------------------------------------------------------------
41
+
42
+ describe("builders", () => {
43
+ test("text creates a text node", () => {
44
+ const n = text("hello")
45
+ expect(n).toEqual({ kind: "text", value: "hello" })
46
+ })
47
+
48
+ test("text with style", () => {
49
+ const n = text("error", "red")
50
+ expect(n).toEqual({ kind: "text", value: "error", style: "red" })
51
+ })
52
+
53
+ test("field with primitive", () => {
54
+ const n = field("count", 42)
55
+ expect(n).toEqual({ kind: "field", label: "count", value: 42 })
56
+ })
57
+
58
+ test("field with node value", () => {
59
+ const n = field("items", list(text("a")))
60
+ expect(n.kind).toBe("field")
61
+ expect(typeof n.value).toBe("object")
62
+ })
63
+
64
+ test("block without attrs", () => {
65
+ const n = block("commit", field("hash", "abc"))
66
+ expect(n.kind).toBe("block")
67
+ expect(n.tag).toBe("commit")
68
+ expect(n.attrs).toBeUndefined()
69
+ expect(n.children).toHaveLength(1)
70
+ })
71
+
72
+ test("block with attrs", () => {
73
+ const n = block("scope", { path: "meta" }, field("status", "ok"))
74
+ expect(n.attrs).toEqual({ path: "meta" })
75
+ expect(n.children).toHaveLength(1)
76
+ })
77
+
78
+ test("list without tag", () => {
79
+ const n = list(text("a"), text("b"))
80
+ expect(n.kind).toBe("list")
81
+ expect(n.tag).toBeUndefined()
82
+ expect(n.items).toHaveLength(2)
83
+ })
84
+
85
+ test("list with tag", () => {
86
+ const n = list("file", text("a"), text("b"))
87
+ expect(n.tag).toBe("file")
88
+ expect(n.items).toHaveLength(2)
89
+ })
90
+
91
+ test("heading", () => {
92
+ const n = heading("Title")
93
+ expect(n).toEqual({ kind: "heading", text: "Title" })
94
+ })
95
+ })
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // JSON renderer
99
+ // ---------------------------------------------------------------------------
100
+
101
+ describe("json", () => {
102
+ test("text → string", () => {
103
+ expect(toValue(text("hello"))).toBe("hello")
104
+ })
105
+
106
+ test("field preserves number type", () => {
107
+ expect(toValue(field("count", 42))).toEqual({ count: 42 })
108
+ })
109
+
110
+ test("field preserves boolean type", () => {
111
+ expect(toValue(field("ok", true))).toEqual({ ok: true })
112
+ })
113
+
114
+ test("field preserves string type", () => {
115
+ expect(toValue(field("name", "alice"))).toEqual({ name: "alice" })
116
+ })
117
+
118
+ test("block with fields → object", () => {
119
+ const value = toValue(commitDoc) as Record<string, unknown>
120
+ expect(value.hash).toBe("abc123")
121
+ expect(value.intent).toBe("impl: add feature")
122
+ expect(value.files).toEqual(["src/a.ts", "src/b.ts"])
123
+ })
124
+
125
+ test("block with attrs merges into object", () => {
126
+ const value = toValue(scopeDoc) as Record<string, unknown>
127
+ expect(value.path).toBe("meta/src")
128
+ expect(value.status).toBe("clean")
129
+ expect(value.changes).toBe(0)
130
+ })
131
+
132
+ test("heading omitted from JSON", () => {
133
+ const value = toValue(scopeDoc) as Record<string, unknown>
134
+ expect("text" in value).toBe(false)
135
+ })
136
+
137
+ test("list → array", () => {
138
+ expect(toValue(simpleList)).toEqual(["alpha", "beta", "gamma"])
139
+ })
140
+
141
+ test("block with non-field children → array", () => {
142
+ const doc = block("items", text("one"), text("two"))
143
+ expect(toValue(doc)).toEqual(["one", "two"])
144
+ })
145
+
146
+ test("single non-field child unwraps", () => {
147
+ const doc = block("wrapper", text("only"))
148
+ expect(toValue(doc)).toBe("only")
149
+ })
150
+
151
+ test("mixed block → object with content", () => {
152
+ const value = toValue(mixedDoc) as Record<string, unknown>
153
+ expect(value.count).toBe(3)
154
+ expect(value.content).toEqual(["some note"])
155
+ })
156
+
157
+ test("nested blocks", () => {
158
+ const value = toValue(nestedDoc) as Record<string, unknown>
159
+ expect(value).toEqual({
160
+ lint: { errors: 0, warnings: 2 },
161
+ typecheck: { errors: 0 },
162
+ })
163
+ })
164
+
165
+ test("render json produces valid JSON string", () => {
166
+ const str = render(commitDoc, "json")
167
+ const parsed = JSON.parse(str)
168
+ expect(parsed.hash).toBe("abc123")
169
+ })
170
+ })
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // XML renderer
174
+ // ---------------------------------------------------------------------------
175
+
176
+ describe("xml", () => {
177
+ test("text → escaped content", () => {
178
+ expect(render(text("a < b & c"), "xml")).toBe("a &lt; b &amp; c")
179
+ })
180
+
181
+ test("text style ignored", () => {
182
+ expect(render(text("bold", "bold"), "xml")).toBe("bold")
183
+ })
184
+
185
+ test("field → element", () => {
186
+ expect(render(field("hash", "abc"), "xml")).toBe("<hash>abc</hash>")
187
+ })
188
+
189
+ test("field with number", () => {
190
+ expect(render(field("count", 42), "xml")).toBe("<count>42</count>")
191
+ })
192
+
193
+ test("block → tag wrapping", () => {
194
+ const xml = render(block("commit", field("hash", "abc")), "xml")
195
+ expect(xml).toBe("<commit>\n<hash>abc</hash>\n</commit>")
196
+ })
197
+
198
+ test("block with attrs", () => {
199
+ const xml = render(block("scope", { path: "meta/src" }, field("status", "ok")), "xml")
200
+ expect(xml).toContain('<scope path="meta/src">')
201
+ expect(xml).toContain("<status>ok</status>")
202
+ })
203
+
204
+ test("attrs escaped", () => {
205
+ const xml = render(block("scope", { path: 'a "b" c' }), "xml")
206
+ expect(xml).toContain('path="a &quot;b&quot; c"')
207
+ })
208
+
209
+ test("empty block → self-closing", () => {
210
+ expect(render(block("empty"), "xml")).toBe("<empty />")
211
+ })
212
+
213
+ test("heading omitted", () => {
214
+ const xml = render(block("sec", heading("Title"), field("x", 1)), "xml")
215
+ expect(xml).not.toContain("Title")
216
+ expect(xml).toContain("<x>1</x>")
217
+ })
218
+
219
+ test("list without tag → concatenated", () => {
220
+ const xml = render(list(text("a"), text("b")), "xml")
221
+ expect(xml).toBe("a\nb")
222
+ })
223
+
224
+ test("list with tag → wrapped items", () => {
225
+ const xml = render(list("file", text("a.ts"), text("b.ts")), "xml")
226
+ expect(xml).toBe("<file>a.ts</file>\n<file>b.ts</file>")
227
+ })
228
+
229
+ test("nested structure", () => {
230
+ const xml = render(commitDoc, "xml")
231
+ expect(xml).toContain("<commit>")
232
+ expect(xml).toContain("<hash>abc123</hash>")
233
+ expect(xml).toContain("<file>src/a.ts</file>")
234
+ expect(xml).toContain("</commit>")
235
+ })
236
+ })
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // CLI renderer
240
+ // ---------------------------------------------------------------------------
241
+
242
+ describe("cli", () => {
243
+ test("text without style", () => {
244
+ expect(render(text("hello"), "cli")).toBe("hello")
245
+ })
246
+
247
+ test("text style applied (colors disabled → plain)", () => {
248
+ // Colors disabled via setColorEnabled(false) at top
249
+ expect(render(text("error", "red"), "cli")).toBe("error")
250
+ })
251
+
252
+ test("field renders label: value", () => {
253
+ const out = render(field("hash", "abc"), "cli")
254
+ expect(out).toContain("hash:")
255
+ expect(out).toContain("abc")
256
+ })
257
+
258
+ test("field with number", () => {
259
+ const out = render(field("count", 42), "cli")
260
+ expect(out).toContain("42")
261
+ })
262
+
263
+ test("block joins children with newlines", () => {
264
+ const out = render(block("test", field("a", 1), field("b", 2)), "cli")
265
+ expect(out).toContain("\n")
266
+ expect(out).toContain("a:")
267
+ expect(out).toContain("b:")
268
+ })
269
+
270
+ test("heading renders with newline prefix", () => {
271
+ const out = render(heading("Title"), "cli")
272
+ expect(out).toContain("Title")
273
+ expect(out.startsWith("\n")).toBe(true)
274
+ })
275
+
276
+ test("list one per line", () => {
277
+ const out = render(simpleList, "cli")
278
+ const lines = out.split("\n")
279
+ expect(lines).toHaveLength(3)
280
+ expect(lines[0]).toBe("alpha")
281
+ expect(lines[1]).toBe("beta")
282
+ expect(lines[2]).toBe("gamma")
283
+ })
284
+ })
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // Markdown renderer
288
+ // ---------------------------------------------------------------------------
289
+
290
+ describe("md", () => {
291
+ test("text plain", () => {
292
+ expect(render(text("hello"), "md")).toBe("hello")
293
+ })
294
+
295
+ test("text bold", () => {
296
+ expect(render(text("important", "bold"), "md")).toBe("**important**")
297
+ })
298
+
299
+ test("text dim → italic", () => {
300
+ expect(render(text("subtle", "dim"), "md")).toBe("*subtle*")
301
+ })
302
+
303
+ test("text color style → plain", () => {
304
+ expect(render(text("red text", "red"), "md")).toBe("red text")
305
+ })
306
+
307
+ test("field → bold label", () => {
308
+ expect(render(field("hash", "abc"), "md")).toBe("**hash:** abc")
309
+ })
310
+
311
+ test("heading → markdown heading", () => {
312
+ expect(render(heading("Title"), "md")).toBe("## Title")
313
+ })
314
+
315
+ test("list → bullet items", () => {
316
+ const out = render(simpleList, "md")
317
+ expect(out).toBe("- alpha\n- beta\n- gamma")
318
+ })
319
+
320
+ test("block separates children with blank lines", () => {
321
+ const out = render(block("sec", field("a", 1), field("b", 2)), "md")
322
+ expect(out).toContain("\n\n")
323
+ expect(out).toContain("**a:** 1")
324
+ expect(out).toContain("**b:** 2")
325
+ })
326
+
327
+ test("full document", () => {
328
+ const doc = block(
329
+ "report",
330
+ heading("Quality Report"),
331
+ field("errors", 0),
332
+ list(text("lint passed"), text("types passed")),
333
+ )
334
+ const out = render(doc, "md")
335
+ expect(out).toContain("## Quality Report")
336
+ expect(out).toContain("**errors:** 0")
337
+ expect(out).toContain("- lint passed")
338
+ expect(out).toContain("- types passed")
339
+ })
340
+ })
341
+
342
+ // ---------------------------------------------------------------------------
343
+ // Cross-mode: same tree, four outputs
344
+ // ---------------------------------------------------------------------------
345
+
346
+ describe("polymorphic render", () => {
347
+ test("same tree produces valid output in all modes", () => {
348
+ const doc = block(
349
+ "result",
350
+ heading("Commit"),
351
+ field("hash", "abc123"),
352
+ field("ok", true),
353
+ field("files", list(text("a.ts"))),
354
+ )
355
+
356
+ const json = render(doc, "json")
357
+ const parsed = JSON.parse(json)
358
+ expect(parsed.hash).toBe("abc123")
359
+ expect(parsed.ok).toBe(true)
360
+ expect(parsed.files).toEqual(["a.ts"])
361
+
362
+ const xml = render(doc, "xml")
363
+ expect(xml).toContain("<hash>abc123</hash>")
364
+ expect(xml).toContain("<ok>true</ok>")
365
+
366
+ const cli = render(doc, "cli")
367
+ expect(cli).toContain("abc123")
368
+ expect(cli).toContain("Commit")
369
+
370
+ const md = render(doc, "md")
371
+ expect(md).toContain("## Commit")
372
+ expect(md).toContain("**hash:** abc123")
373
+ })
374
+ })
@@ -0,0 +1,55 @@
1
+ /**
2
+ * CLI renderer — colored terminal output respecting NO_COLOR/FORCE_COLOR.
3
+ *
4
+ * Uses the existing `c` convenience object from terminal/colors.
5
+ */
6
+
7
+ import type { Node, Style } from "./types.ts"
8
+ import { c } from "../terminal/colors.ts"
9
+
10
+ const styleFn: Record<Style, (text: string) => string> = {
11
+ bold: c.bolded,
12
+ dim: c.dimmed,
13
+ red: c.red,
14
+ green: c.green,
15
+ yellow: c.yellow,
16
+ blue: c.blue,
17
+ cyan: c.cyan,
18
+ magenta: c.magenta,
19
+ gray: c.gray,
20
+ }
21
+
22
+ function renderValue(value: string | number | boolean | Node): string {
23
+ if (typeof value !== "object") return String(value)
24
+ return renderCli(value)
25
+ }
26
+
27
+ /** Render a node tree as colored CLI output. */
28
+ export function renderCli(node: Node): string {
29
+ switch (node.kind) {
30
+ case "text":
31
+ return node.style ? styleFn[node.style](node.value) : node.value
32
+
33
+ case "field": {
34
+ const label = c.dimmed(`${node.label}:`)
35
+ const rendered = renderValue(node.value)
36
+ if (rendered.includes("\n")) return `${label}\n${rendered}`
37
+ return `${label} ${rendered}`
38
+ }
39
+
40
+ case "block": {
41
+ const parts: string[] = []
42
+ for (const child of node.children) {
43
+ const rendered = renderCli(child)
44
+ if (rendered) parts.push(rendered)
45
+ }
46
+ return parts.join("\n")
47
+ }
48
+
49
+ case "list":
50
+ return node.items.map(renderCli).join("\n")
51
+
52
+ case "heading":
53
+ return `\n${c.bolded(node.text)}`
54
+ }
55
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * JSON renderer — converts document tree to structured JavaScript values.
3
+ *
4
+ * `toValue` produces a JS value (object/array/string/number/boolean).
5
+ * `renderJson` stringifies it.
6
+ */
7
+
8
+ import type { Node } from "./types.ts"
9
+
10
+ /** Convert a node tree to a JSON-compatible JavaScript value. */
11
+ export function toValue(node: Node): unknown {
12
+ switch (node.kind) {
13
+ case "text":
14
+ return node.value
15
+
16
+ case "field":
17
+ // Standalone field → single-entry object
18
+ return { [node.label]: fieldValue(node.value) }
19
+
20
+ case "block":
21
+ return blockValue(node)
22
+
23
+ case "list":
24
+ return node.items.map(toValue)
25
+
26
+ case "heading":
27
+ return undefined
28
+ }
29
+ }
30
+
31
+ function fieldValue(value: string | number | boolean | Node): unknown {
32
+ if (typeof value !== "object") return value
33
+ return toValue(value)
34
+ }
35
+
36
+ function blockValue(node: Extract<Node, { kind: "block" }>): unknown {
37
+ const fields: [string, unknown][] = []
38
+ const content: unknown[] = []
39
+
40
+ // Attrs become top-level properties
41
+ if (node.attrs) {
42
+ for (const [k, v] of Object.entries(node.attrs)) {
43
+ fields.push([k, v])
44
+ }
45
+ }
46
+
47
+ for (const child of node.children) {
48
+ if (child.kind === "field") {
49
+ fields.push([child.label, fieldValue(child.value)])
50
+ } else if (child.kind === "heading") {
51
+ // Omitted in JSON
52
+ } else if (child.kind === "block") {
53
+ fields.push([child.tag, blockValue(child)])
54
+ } else {
55
+ content.push(toValue(child))
56
+ }
57
+ }
58
+
59
+ // All fields → object
60
+ if (fields.length > 0 && content.length === 0) {
61
+ return Object.fromEntries(fields)
62
+ }
63
+
64
+ // All non-fields → array (or single value)
65
+ if (fields.length === 0) {
66
+ if (content.length === 1) return content[0]
67
+ return content
68
+ }
69
+
70
+ // Mixed → object with content key
71
+ const obj = Object.fromEntries(fields)
72
+ obj.content = content
73
+ return obj
74
+ }
75
+
76
+ /** Render a node tree as a compact JSON string. */
77
+ export function renderJson(node: Node): string {
78
+ const value = toValue(node)
79
+ return JSON.stringify(value)
80
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Markdown renderer — structured output as GitHub-flavored markdown.
3
+ *
4
+ * Useful for PR descriptions, documentation, and GitHub comments.
5
+ */
6
+
7
+ import type { Node } from "./types.ts"
8
+
9
+ function renderValue(value: string | number | boolean | Node): string {
10
+ if (typeof value !== "object") return String(value)
11
+ return renderMd(value)
12
+ }
13
+
14
+ /** Render a node tree as markdown. */
15
+ export function renderMd(node: Node): string {
16
+ switch (node.kind) {
17
+ case "text":
18
+ if (node.style === "bold") return `**${node.value}**`
19
+ if (node.style === "dim") return `*${node.value}*`
20
+ return node.value
21
+
22
+ case "field": {
23
+ const rendered = renderValue(node.value)
24
+ if (rendered.includes("\n")) return `**${node.label}:**\n${rendered}`
25
+ return `**${node.label}:** ${rendered}`
26
+ }
27
+
28
+ case "block": {
29
+ const parts: string[] = []
30
+ for (const child of node.children) {
31
+ const rendered = renderMd(child)
32
+ if (rendered) parts.push(rendered)
33
+ }
34
+ return parts.join("\n\n")
35
+ }
36
+
37
+ case "list":
38
+ return node.items.map((item) => `- ${renderMd(item)}`).join("\n")
39
+
40
+ case "heading":
41
+ return `## ${node.text}`
42
+ }
43
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * XML renderer — semantic tags for hooks and skill preprocessing.
3
+ *
4
+ * Flat output (no indentation) to keep content compact for LLM consumption.
5
+ */
6
+
7
+ import type { Node } from "./types.ts"
8
+
9
+ export function escapeXml(str: string): string {
10
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;")
11
+ }
12
+
13
+ function renderValue(value: string | number | boolean | Node): string {
14
+ if (typeof value !== "object") return escapeXml(String(value))
15
+ return renderXml(value)
16
+ }
17
+
18
+ function attrs(record: Readonly<Record<string, string>>): string {
19
+ const pairs = Object.entries(record).map(([k, v]) => `${k}="${escapeXml(v)}"`)
20
+ return pairs.length > 0 ? " " + pairs.join(" ") : ""
21
+ }
22
+
23
+ /** Render a node tree as XML. */
24
+ export function renderXml(node: Node): string {
25
+ switch (node.kind) {
26
+ case "text":
27
+ return escapeXml(node.value)
28
+
29
+ case "field": {
30
+ const inner = renderValue(node.value)
31
+ if (inner.includes("\n")) {
32
+ return `<${node.label}>\n${inner}\n</${node.label}>`
33
+ }
34
+ return `<${node.label}>${inner}</${node.label}>`
35
+ }
36
+
37
+ case "block": {
38
+ const attrStr = node.attrs ? attrs(node.attrs) : ""
39
+ const children = node.children.filter((c) => c.kind !== "heading").map(renderXml)
40
+ const inner = children.join("\n")
41
+ if (!inner) return `<${node.tag}${attrStr} />`
42
+ return `<${node.tag}${attrStr}>\n${inner}\n</${node.tag}>`
43
+ }
44
+
45
+ case "list": {
46
+ if (node.tag) {
47
+ return node.items.map((item) => `<${node.tag}>${renderXml(item)}</${node.tag}>`).join("\n")
48
+ }
49
+ return node.items.map(renderXml).join("\n")
50
+ }
51
+
52
+ case "heading":
53
+ return ""
54
+ }
55
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Polymorphic render dispatcher.
3
+ */
4
+
5
+ import type { Node, OutputMode } from "./types.ts"
6
+ import { renderCli } from "./render-cli.ts"
7
+ import { renderJson } from "./render-json.ts"
8
+ import { renderXml } from "./render-xml.ts"
9
+ import { renderMd } from "./render-md.ts"
10
+
11
+ /** Render a document tree to a string in the given output mode. */
12
+ export function render(node: Node, mode: OutputMode): string {
13
+ switch (mode) {
14
+ case "cli":
15
+ return renderCli(node)
16
+ case "json":
17
+ return renderJson(node)
18
+ case "xml":
19
+ return renderXml(node)
20
+ case "md":
21
+ return renderMd(node)
22
+ }
23
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Polymorphic output — build once, render to any format.
3
+ *
4
+ * Five node kinds form a document tree.
5
+ * Four output modes produce strings for different consumers.
6
+ */
7
+
8
+ export type Style = "bold" | "dim" | "red" | "green" | "yellow" | "blue" | "cyan" | "magenta" | "gray"
9
+
10
+ export type Primitive = string | number | boolean
11
+
12
+ export type TextNode = {
13
+ readonly kind: "text"
14
+ readonly value: string
15
+ readonly style?: Style
16
+ }
17
+
18
+ export type FieldNode = {
19
+ readonly kind: "field"
20
+ readonly label: string
21
+ readonly value: Primitive | Node
22
+ }
23
+
24
+ export type BlockNode = {
25
+ readonly kind: "block"
26
+ readonly tag: string
27
+ readonly attrs?: Readonly<Record<string, string>>
28
+ readonly children: readonly Node[]
29
+ }
30
+
31
+ export type ListNode = {
32
+ readonly kind: "list"
33
+ readonly tag?: string
34
+ readonly items: readonly Node[]
35
+ }
36
+
37
+ export type HeadingNode = {
38
+ readonly kind: "heading"
39
+ readonly text: string
40
+ }
41
+
42
+ export type Node = TextNode | FieldNode | BlockNode | ListNode | HeadingNode
43
+
44
+ export type OutputMode = "cli" | "json" | "xml" | "md"