@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.
- package/bin/dexter +6 -0
- package/package.json +43 -0
- package/src/claude/index.ts +6 -0
- package/src/cli.ts +39 -0
- package/src/env/define.ts +190 -0
- package/src/env/index.ts +10 -0
- package/src/env/loader.ts +61 -0
- package/src/env/print.ts +98 -0
- package/src/env/validate.ts +46 -0
- package/src/index.ts +16 -0
- package/src/meta/adapters/fs.ts +22 -0
- package/src/meta/adapters/git.ts +29 -0
- package/src/meta/adapters/glob.ts +14 -0
- package/src/meta/adapters/index.ts +24 -0
- package/src/meta/adapters/process.ts +40 -0
- package/src/meta/cli.ts +340 -0
- package/src/meta/domain/bisect.ts +126 -0
- package/src/meta/domain/blame.ts +136 -0
- package/src/meta/domain/commit.ts +135 -0
- package/src/meta/domain/commits.ts +23 -0
- package/src/meta/domain/constraints/registry.ts +49 -0
- package/src/meta/domain/constraints/types.ts +30 -0
- package/src/meta/domain/diff.ts +34 -0
- package/src/meta/domain/eval.ts +57 -0
- package/src/meta/domain/format.ts +34 -0
- package/src/meta/domain/lint.ts +88 -0
- package/src/meta/domain/pickaxe.ts +99 -0
- package/src/meta/domain/quality.ts +145 -0
- package/src/meta/domain/rules.ts +21 -0
- package/src/meta/domain/scope-context.ts +63 -0
- package/src/meta/domain/service.ts +68 -0
- package/src/meta/domain/setup.ts +34 -0
- package/src/meta/domain/test.ts +72 -0
- package/src/meta/domain/transcripts.ts +88 -0
- package/src/meta/domain/typecheck.ts +41 -0
- package/src/meta/domain/workspace.ts +78 -0
- package/src/meta/errors.ts +19 -0
- package/src/meta/hooks/on-post-read.ts +61 -0
- package/src/meta/hooks/on-post-write.ts +65 -0
- package/src/meta/hooks/on-pre-bash.ts +69 -0
- package/src/meta/hooks/stubs.ts +51 -0
- package/src/meta/index.ts +36 -0
- package/src/meta/lib/actor.ts +53 -0
- package/src/meta/lib/eslint.ts +58 -0
- package/src/meta/lib/format.ts +55 -0
- package/src/meta/lib/paths.ts +36 -0
- package/src/meta/lib/present.ts +231 -0
- package/src/meta/lib/spec-links.ts +83 -0
- package/src/meta/lib/stdin.ts +56 -0
- package/src/meta/ports.ts +50 -0
- package/src/meta/types.ts +113 -0
- package/src/output/build.ts +56 -0
- package/src/output/index.ts +24 -0
- package/src/output/output.test.ts +374 -0
- package/src/output/render-cli.ts +55 -0
- package/src/output/render-json.ts +80 -0
- package/src/output/render-md.ts +43 -0
- package/src/output/render-xml.ts +55 -0
- package/src/output/render.ts +23 -0
- package/src/output/types.ts +44 -0
- package/src/pipe/format.ts +167 -0
- package/src/pipe/index.ts +4 -0
- package/src/pipe/parse.ts +131 -0
- package/src/pipe/spawn.ts +205 -0
- package/src/pipe/types.ts +27 -0
- package/src/terminal/colors.ts +95 -0
- package/src/terminal/index.ts +16 -0
- 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 < b & 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 "b" 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """)
|
|
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"
|