dflow-sdd-ddd 0.12.0 → 0.13.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 (38) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.en.md +83 -17
  3. package/README.md +39 -9
  4. package/TEMPLATE-COVERAGE.md +1 -0
  5. package/bin/dflow.js +58 -2
  6. package/docs/evaluating-dflow.en.md +21 -2
  7. package/docs/evaluating-dflow.md +17 -3
  8. package/docs/using-with-claude-code.en.md +23 -16
  9. package/docs/using-with-claude-code.md +20 -14
  10. package/docs/using-with-codex.en.md +15 -8
  11. package/docs/using-with-codex.md +10 -7
  12. package/docs/using-with-github-copilot.en.md +8 -3
  13. package/docs/using-with-github-copilot.md +6 -3
  14. package/lib/init.js +93 -8
  15. package/lib/render.js +1263 -0
  16. package/package.json +5 -2
  17. package/templates/brownfield/references/init-project-flow.md +43 -1
  18. package/templates/brownfield/scaffolding/AI-AGENT-GUIDE.md +5 -0
  19. package/templates/brownfield/templates/_index.md +2 -0
  20. package/templates/brownfield/templates/context-definition.md +2 -0
  21. package/templates/brownfield/templates/context-map.md +1 -0
  22. package/templates/brownfield/templates/glossary.md +1 -0
  23. package/templates/brownfield/templates/models.md +1 -0
  24. package/templates/brownfield/templates/phase-spec.md +2 -0
  25. package/templates/brownfield/templates/rules.md +1 -0
  26. package/templates/brownfield/templates/tech-debt.md +1 -0
  27. package/templates/greenfield/references/init-project-flow.md +43 -1
  28. package/templates/greenfield/scaffolding/AI-AGENT-GUIDE.md +5 -0
  29. package/templates/greenfield/templates/_index.md +2 -0
  30. package/templates/greenfield/templates/aggregate-design.md +2 -0
  31. package/templates/greenfield/templates/context-definition.md +2 -0
  32. package/templates/greenfield/templates/context-map.md +1 -0
  33. package/templates/greenfield/templates/events.md +1 -0
  34. package/templates/greenfield/templates/glossary.md +1 -0
  35. package/templates/greenfield/templates/models.md +1 -0
  36. package/templates/greenfield/templates/phase-spec.md +2 -0
  37. package/templates/greenfield/templates/rules.md +1 -0
  38. package/templates/greenfield/templates/tech-debt.md +1 -0
package/lib/render.js ADDED
@@ -0,0 +1,1263 @@
1
+ // dflow render — specs Markdown -> human-readable static HTML (PROPOSAL-073).
2
+ //
3
+ // Markdown stays the AI-facing source of truth; this command projects it into
4
+ // a mirrored .html tree plus a root index.html file-tree for human reading.
5
+ // Every run is a full rebuild (the index tree and in-tree autolinks depend on
6
+ // the whole file set, so partial rebuilds would leave stale links).
7
+ //
8
+ // Rendering parity baseline: the retired dev prototype
9
+ // (prototypes/md-html/render_html.py at its 2026-07-09 converged shape),
10
+ // user-acceptance-tested per feature: all-table cardification, frontmatter
11
+ // title card + status pill, phase badges / fill-timing chips, gherkin keyword
12
+ // highlighting, .md link rewriting with GFM-style heading ids (CJK-safe), and
13
+ // in-tree autolinking of inline-code .md mentions.
14
+ //
15
+ // marked ships as ESM-only and `require(esm)` is not on by default until Node
16
+ // 22.12 (engines allows >=22.0.0), so this CommonJS module MUST load it via
17
+ // dynamic import('marked') — never via a require() call.
18
+
19
+ const fs = require('node:fs/promises');
20
+ const path = require('node:path');
21
+
22
+ const DEFAULT_SRC = 'dflow/specs';
23
+ const DEFAULT_OUT = 'dflow-specs-html';
24
+ const DEFAULT_TITLE = 'dflow specs';
25
+
26
+ // Ownership + stale-cleanup ledger for the output directory. The manifest
27
+ // lists every file the previous run generated (mirror .html tree + root
28
+ // index.html; never the manifest itself). Its presence marks the directory as
29
+ // render-owned; its file list is the ONLY set stale cleanup may ever delete.
30
+ const MANIFEST_NAME = '.dflow-render-manifest.json';
31
+ const MANIFEST_TMP_NAME = `${MANIFEST_NAME}.tmp`;
32
+ const MANIFEST_VERSION = 1;
33
+ // Per-file ownership proof, embedded in every rendered page. The manifest
34
+ // alone is a weak proof for mutations — it is an ordinary JSON file that can
35
+ // be copied or hand-written into a directory full of foreign files (cold-eye
36
+ // gate G3 F1) — so deletes and overwrites additionally require this marker
37
+ // in the target file itself. Keep the text stable across versions: outputs
38
+ // of older dflow renders must stay recognized by newer ones.
39
+ const GENERATED_MARK = '<!-- generated by dflow render -->';
40
+
41
+ const PHASE_MARK_STYLE = {
42
+ ADDED: ['ok', '新增'],
43
+ MODIFIED: ['warn', '修改'],
44
+ REMOVED: ['del', '移除'],
45
+ RENAMED: ['info', '改名']
46
+ };
47
+
48
+ const STATUS_PILL = {
49
+ 'in-progress': 'warn',
50
+ completed: 'ok',
51
+ done: 'ok',
52
+ open: 'info',
53
+ draft: 'neutral'
54
+ };
55
+
56
+ const GHERKIN_KEYWORDS = /^(\s*)(Scenario(?: Outline)?:|Background:|Examples:|Given|When|Then|And|But)(?=[ \t]|$)/gm;
57
+
58
+ // All multi-column tables render as cards (user decision 2026-07-09 after two
59
+ // prototype demo rounds; no per-table heuristic). Degenerate single-column
60
+ // tables keep the plain-table rendering. Columns named here are per-record
61
+ // classifiers -> prominent chips under the card title.
62
+ const CAT_HEADERS = new Set([
63
+ 'root entity', 'bounded context', 'aggregate', '所屬 aggregate',
64
+ 'status', 'subdomain type', 'layer', 'severity', 'owner / team', 'producer'
65
+ ]);
66
+
67
+ const CSS = `
68
+ :root {
69
+ --bg:#f6f8f9; --surface:#ffffff; --frame:#eef2f4; --deep:#e7edef;
70
+ --ink:#1c2733; --soft:#5a6b79; --faint:#8a99a6; --line:#dbe4e9;
71
+ --accent:#0e6e63; --accent-soft:#e0f0ed; --accent-line:#b8dcd6;
72
+ --ok-fg:#1e6b34; --ok-bg:#e2f2e6; --ok-line:#bfe0c7;
73
+ --warn-fg:#8a5600; --warn-bg:#f7ecd7; --warn-line:#e8d5ac;
74
+ --del-fg:#9c3838; --del-bg:#f9e9e9; --del-line:#e8c4c4;
75
+ }
76
+ @media (prefers-color-scheme: dark) {
77
+ :root {
78
+ --bg:#10161c; --surface:#171f27; --frame:#0c1217; --deep:#0a0f14;
79
+ --ink:#d9e2ea; --soft:#93a4b1; --faint:#6b7c89; --line:#2a3641;
80
+ --accent:#4cc2b4; --accent-soft:#11302c; --accent-line:#1e4f48;
81
+ --ok-fg:#7bd397; --ok-bg:#142d1b; --ok-line:#235c33;
82
+ --warn-fg:#e2b269; --warn-bg:#2f2510; --warn-line:#59461d;
83
+ --del-fg:#e08f8f; --del-bg:#331a1a; --del-line:#5c2727;
84
+ }
85
+ }
86
+ * { box-sizing: border-box; }
87
+ body {
88
+ background: var(--bg); color: var(--ink); margin: 0;
89
+ font-family: "Segoe UI","Noto Sans TC","Microsoft JhengHei","PingFang TC",
90
+ "Helvetica Neue",Arial,sans-serif;
91
+ font-size: 15.5px; line-height: 1.78; padding: 2.5rem 1.25rem 4rem;
92
+ }
93
+ main { max-width: 54rem; margin: 0 auto; }
94
+ a { color: var(--accent); }
95
+ code, pre { font-family: "Cascadia Code","Cascadia Mono",Consolas,monospace; }
96
+ code {
97
+ background: var(--frame); border: 1px solid var(--line);
98
+ border-radius: 4px; padding: 0.05em 0.35em; font-size: 0.86em;
99
+ }
100
+ pre {
101
+ background: var(--deep); border: 1px solid var(--line); border-radius: 8px;
102
+ padding: 0.85rem 1.1rem; overflow-x: auto; font-size: 12.8px; line-height: 1.85;
103
+ }
104
+ pre code { background: none; border: none; padding: 0; font-size: inherit; }
105
+ h1 { font-size: 25px; line-height: 1.35; margin: 0.2rem 0 1rem; }
106
+ h2 {
107
+ font-size: 19px; margin: 2.2rem 0 0.6rem; padding-top: 1.2rem;
108
+ border-top: 1px solid var(--line);
109
+ }
110
+ h3 { font-size: 16.5px; margin: 1.6rem 0 0.4rem; }
111
+ h4 { font-size: 15px; margin: 1.2rem 0 0.3rem; }
112
+ blockquote {
113
+ margin: 0.8rem 0; padding: 0.55rem 1rem;
114
+ background: var(--accent-soft); border-left: 3px solid var(--accent);
115
+ border-radius: 0 8px 8px 0; color: var(--soft); font-size: 14px;
116
+ }
117
+ blockquote p { margin: 0.2rem 0; }
118
+ hr { border: none; border-top: 1px solid var(--line); margin: 2rem 0; }
119
+ .tblwrap { overflow-x: auto; }
120
+ table { border-collapse: collapse; width: 100%; font-size: 14px; margin: 0.6rem 0 1rem; }
121
+ th {
122
+ text-align: left; font-size: 11.5px; letter-spacing: 0.07em;
123
+ text-transform: uppercase; color: var(--soft); font-weight: 600;
124
+ padding: 0.45rem 0.9rem 0.45rem 0; border-bottom: 1.5px solid var(--line);
125
+ white-space: nowrap;
126
+ }
127
+ td { padding: 0.55rem 0.9rem 0.55rem 0; border-bottom: 1px solid var(--line); vertical-align: top; }
128
+ tr:last-child td { border-bottom: none; }
129
+ .badge {
130
+ display: inline-block; font-size: 11.5px; font-weight: 600; line-height: 1;
131
+ padding: 3px 9px; border-radius: 999px; border: 1px solid; white-space: nowrap;
132
+ vertical-align: 0.15em;
133
+ }
134
+ .badge.ok { color: var(--ok-fg); background: var(--ok-bg); border-color: var(--ok-line); }
135
+ .badge.warn { color: var(--warn-fg); background: var(--warn-bg); border-color: var(--warn-line); }
136
+ .badge.del { color: var(--del-fg); background: var(--del-bg); border-color: var(--del-line); }
137
+ .badge.info, .badge.neutral { color: var(--accent); background: var(--accent-soft); border-color: var(--accent-line); }
138
+ .chip {
139
+ display: inline-block; font-size: 11px; font-weight: 600; color: var(--soft);
140
+ background: var(--frame); border: 1px solid var(--line); border-radius: 999px;
141
+ padding: 2px 8px; vertical-align: 0.2em;
142
+ }
143
+ .cb {
144
+ display: inline-block; width: 14px; height: 14px; border-radius: 4px;
145
+ border: 1.5px solid var(--faint); vertical-align: -0.12em; margin-right: 0.15em;
146
+ }
147
+ .cb.on { background: var(--accent); border-color: var(--accent); position: relative; }
148
+ .cb.on::after {
149
+ content: ""; position: absolute; left: 4px; top: 1px; width: 3px; height: 7px;
150
+ border: solid var(--bg); border-width: 0 2px 2px 0; transform: rotate(45deg);
151
+ }
152
+ .kw { color: var(--accent); font-weight: 700; }
153
+ .cards {
154
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
155
+ gap: 0.9rem; align-items: start; margin: 0.8rem 0 1.4rem;
156
+ }
157
+ .card {
158
+ background: var(--surface); border: 1px solid var(--line);
159
+ border-radius: 10px; overflow: hidden;
160
+ }
161
+ .card-title {
162
+ background: var(--accent-soft); color: var(--accent);
163
+ border-bottom: 1px solid var(--accent-line);
164
+ font-size: 15px; font-weight: 700; padding: 0.55rem 0.95rem;
165
+ }
166
+ .card-chips { display: flex; flex-wrap: wrap; gap: 0.35rem; padding: 0.55rem 0.95rem 0; }
167
+ .chip.cat {
168
+ color: var(--accent); background: transparent; border-color: var(--accent-line);
169
+ font-size: 11.5px; vertical-align: baseline;
170
+ }
171
+ .card-fields { padding: 0.15rem 0 0.55rem; }
172
+ .fld { padding: 0.45rem 0.95rem 0.05rem; }
173
+ .fld-k {
174
+ display: block; font-size: 11px; letter-spacing: 0.07em;
175
+ text-transform: uppercase; color: var(--accent); font-weight: 650;
176
+ margin-bottom: 0.05rem;
177
+ }
178
+ .fld-v { font-size: 14px; line-height: 1.7; overflow-wrap: anywhere; }
179
+ .crumb { font-size: 12px; color: var(--faint); font-family: Consolas, monospace; margin: 0 0 1rem; }
180
+ .crumb a { color: var(--faint); }
181
+ .meta-card {
182
+ background: var(--frame); border: 1px solid var(--line); border-radius: 10px;
183
+ padding: 1rem 1.2rem; margin: 0 0 1.6rem;
184
+ }
185
+ .meta-title { display: flex; flex-wrap: wrap; align-items: center; gap: 0.7rem;
186
+ font-size: 18px; font-weight: 650; margin: 0 0 0.5rem; }
187
+ .meta-grid { display: flex; flex-wrap: wrap; gap: 0.3rem 1.4rem; font-size: 12.8px; color: var(--soft); }
188
+ .meta-grid .k { color: var(--faint); margin-right: 0.35em; }
189
+ .foot {
190
+ margin-top: 3rem; padding-top: 0.9rem; border-top: 1px solid var(--line);
191
+ color: var(--faint); font-size: 12px; font-family: Consolas, monospace;
192
+ }
193
+ ul.tree, ul.tree ul { list-style: none; padding-left: 1.1rem; line-height: 2; }
194
+ ul.tree { padding-left: 0; }
195
+ ul.tree .dir { color: var(--faint); font-weight: 600; font-size: 13.5px; }
196
+ ul.tree a { text-decoration: none; font-family: Consolas, monospace; font-size: 13.5px; }
197
+ ul.tree a:hover { text-decoration: underline; }
198
+ `;
199
+
200
+ // ------------------------------------------------------------------ file IO
201
+ // Windows MAX_PATH is 260 chars; dflow spec directories (SPEC-YYYYMMDD-NNN-slug)
202
+ // plus long spec filenames overflow it easily. path.toNamespacedPath() opts
203
+ // absolute paths into \\?\ extended-length form on win32 (no-op elsewhere).
204
+
205
+ function ioPath(p) {
206
+ return path.toNamespacedPath(p);
207
+ }
208
+
209
+ async function readText(p) {
210
+ return fs.readFile(ioPath(p), 'utf8');
211
+ }
212
+
213
+ // First `bytes` of a file as utf8. The ownership-proof check only needs the
214
+ // head (GENERATED_MARK sits on line 2 of every rendered page), and a forged
215
+ // manifest may list an arbitrarily large foreign file — never slurp it whole.
216
+ async function readHead(p, bytes) {
217
+ const handle = await fs.open(ioPath(p), 'r');
218
+ try {
219
+ const buf = Buffer.alloc(bytes);
220
+ const { bytesRead } = await handle.read(buf, 0, bytes, 0);
221
+ return buf.toString('utf8', 0, bytesRead);
222
+ } finally {
223
+ await handle.close();
224
+ }
225
+ }
226
+
227
+ async function writeText(p, text) {
228
+ await fs.mkdir(ioPath(path.dirname(p)), { recursive: true });
229
+ await fs.writeFile(ioPath(p), text, 'utf8');
230
+ }
231
+
232
+ // -------------------------------------------------------------- html helpers
233
+
234
+ function escapeHtml(text) {
235
+ return String(text)
236
+ .replace(/&/g, '&amp;')
237
+ .replace(/</g, '&lt;')
238
+ .replace(/>/g, '&gt;')
239
+ .replace(/"/g, '&quot;')
240
+ .replace(/'/g, '&#39;');
241
+ }
242
+
243
+ // Only used on our own rendered fragments (for slugs and card classification),
244
+ // so covering numeric references plus the five entities escapeHtml/marked emit
245
+ // is sufficient — this is not a general-purpose HTML entity decoder.
246
+ function unescapeHtml(text) {
247
+ return String(text)
248
+ .replace(/&#(\d+);/g, (m, dec) => String.fromCodePoint(Number(dec)))
249
+ .replace(/&#[xX]([0-9a-fA-F]+);/g, (m, hex) => String.fromCodePoint(parseInt(hex, 16)))
250
+ .replace(/&lt;/g, '<')
251
+ .replace(/&gt;/g, '>')
252
+ .replace(/&quot;/g, '"')
253
+ .replace(/&#39;/g, "'")
254
+ .replace(/&amp;/g, '&');
255
+ }
256
+
257
+ function stripTags(fragment) {
258
+ return unescapeHtml(String(fragment).replace(/<[^>]+>/g, '')).trim();
259
+ }
260
+
261
+ // ------------------------------------------------------------ preprocessing
262
+
263
+ // Parse a leading flat `key: value` frontmatter block, if present.
264
+ function splitFrontmatter(text) {
265
+ if (!text.startsWith('---')) {
266
+ return { meta: {}, body: text };
267
+ }
268
+ const lines = text.split('\n');
269
+ const meta = {};
270
+ for (let i = 1; i < lines.length; i++) {
271
+ const line = lines[i];
272
+ if (line.trim() === '---') {
273
+ return { meta, body: lines.slice(i + 1).join('\n') };
274
+ }
275
+ const colon = line.indexOf(':');
276
+ if (colon !== -1 && !line.trimStart().startsWith('#')) {
277
+ const key = line.slice(0, colon).trim();
278
+ const value = line.slice(colon + 1).replace(/\s+#.*$/, '').trim();
279
+ meta[key] = value;
280
+ }
281
+ }
282
+ return { meta: {}, body: text }; // no closing fence -> treat as content
283
+ }
284
+
285
+ // Rewrite AI-facing markers into human-facing spans before conversion.
286
+ // (Task-list checkboxes need no preprocessing here: marked's GFM parser
287
+ // tokenizes them natively and the checkbox renderer below restyles them.)
288
+ function preprocess(mdText) {
289
+ // structural section markers: drop
290
+ let text = mdText.replace(/<!--\s*dflow:section[^>]*-->\s*\n?/g, '');
291
+
292
+ // phase change markers -> badges
293
+ text = text.replace(
294
+ /<!--\s*phase-(\d+)\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s*-->/g,
295
+ (match, phase, kind) => {
296
+ const [cls, zh] = PHASE_MARK_STYLE[kind];
297
+ return `<span class="badge ${cls}">phase-${phase} ${zh}</span>`;
298
+ }
299
+ );
300
+
301
+ // heading fill-timing comments -> chips
302
+ text = text.replace(
303
+ /^(#{1,6} .*?)\s*<!--\s*Fill timing:\s*(.*?)\s*-->/gm,
304
+ (match, headingText, timing) => `${headingText} <span class="chip">${escapeHtml(timing)}</span>`
305
+ );
306
+
307
+ return text;
308
+ }
309
+
310
+ // ------------------------------------------------------------ marked renderer
311
+
312
+ const CHIP_SPAN = /<span class="chip[^"]*">[\s\S]*?<\/span>/g;
313
+ const ANY_TAG = /<[^>]+>/g;
314
+
315
+ // GitHub-style heading slug so `#anchor` links resolve in the HTML. CJK trap:
316
+ // JavaScript \w is ASCII-only (unlike Python \w), so the character class must
317
+ // use Unicode properties — [\p{L}\p{N}_\- ] with the u flag — or every 中文
318
+ // heading would slug to the empty string.
319
+ function headingSlug(innerHtml, seen) {
320
+ const text = String(innerHtml).replace(CHIP_SPAN, '').replace(ANY_TAG, '');
321
+ let slug = unescapeHtml(text).trim().toLowerCase();
322
+ slug = slug.replace(/[^\p{L}\p{N}_\- ]/gu, '');
323
+ slug = slug.replace(/ +/g, '-');
324
+ if (!slug) {
325
+ return null;
326
+ }
327
+ const n = seen.get(slug) || 0;
328
+ seen.set(slug, n + 1);
329
+ return n ? `${slug}-${n}` : slug;
330
+ }
331
+
332
+ function plainTableHtml(headerCells, rows) {
333
+ const head = headerCells.map((cell) => `<th>${cell}</th>`).join('');
334
+ const body = rows
335
+ .map((row) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join('')}</tr>`)
336
+ .join('\n');
337
+ return `<div class="tblwrap"><table>\n<thead>\n<tr>${head}</tr>\n</thead>\n<tbody>\n${body}\n</tbody>\n</table></div>\n`;
338
+ }
339
+
340
+ // Card anatomy (prototype-converged shape): first column -> accent title bar;
341
+ // classifier columns (CAT_HEADERS) -> chips under the title (status reuses the
342
+ // pill styling); remaining columns -> stacked label+value fields, with in-cell
343
+ // <br> line breaks preserved verbatim. Empty cells are omitted. Degenerate
344
+ // single-column tables keep the plain-table rendering.
345
+ function cardsHtml(headerCells, rows) {
346
+ const cards = rows.map((cells) => {
347
+ const plains = cells.map(stripTags);
348
+ const title = plains[0] ? cells[0] : '(未命名)';
349
+ const chips = [];
350
+ const fields = [];
351
+ for (let i = 1; i < headerCells.length; i++) {
352
+ const cell = cells[i] ?? '';
353
+ const text = plains[i] ?? '';
354
+ if (!text) {
355
+ continue;
356
+ }
357
+ const headerKey = stripTags(headerCells[i]).toLowerCase();
358
+ if (CAT_HEADERS.has(headerKey) && [...text].length <= 40) {
359
+ if (headerKey === 'status') {
360
+ const pill = STATUS_PILL[text] || 'neutral';
361
+ chips.push(`<span class="badge ${pill}">${cell}</span>`);
362
+ } else {
363
+ chips.push(`<span class="chip cat">${headerCells[i]}: ${cell}</span>`);
364
+ }
365
+ } else {
366
+ fields.push(
367
+ `<div class="fld"><span class="fld-k">${headerCells[i]}</span>` +
368
+ `<div class="fld-v">${cell}</div></div>`
369
+ );
370
+ }
371
+ }
372
+ const chipsHtml = chips.length ? `<div class="card-chips">${chips.join('')}</div>` : '';
373
+ return `<article class="card"><div class="card-title">${title}</div>` +
374
+ `${chipsHtml}<div class="card-fields">${fields.join('')}</div></article>`;
375
+ });
376
+ return `<div class="cards">${cards.join('')}</div>\n`;
377
+ }
378
+
379
+ // Per-file renderer overrides. `seen` carries the per-document heading slug
380
+ // dedup state, so a fresh renderer is built for every file.
381
+ function buildRenderer(seen) {
382
+ return {
383
+ heading(token) {
384
+ const inner = this.parser.parseInline(token.tokens);
385
+ const slug = headingSlug(inner, seen);
386
+ const id = slug ? ` id="${slug}"` : '';
387
+ return `<h${token.depth}${id}>${inner}</h${token.depth}>\n`;
388
+ },
389
+
390
+ // Point relative .md links at the mirrored .html files (anchors kept).
391
+ // Only scheme-less hrefs are candidates: a scheme-qualified link
392
+ // (https:, mailto:, file:, …) is not a mirrored tree file, and the
393
+ // proposal scopes rewriting to relative .md links (cold-eye gate G7).
394
+ link(token) {
395
+ const inner = this.parser.parseInline(token.tokens);
396
+ let href = token.href || '';
397
+ if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(href) && !href.startsWith('#')) {
398
+ href = href.replace(/^(.*?)\.md(#.*)?$/, (m, base, anchor) => `${base}.html${anchor || ''}`);
399
+ }
400
+ const title = token.title ? ` title="${escapeHtml(token.title)}"` : '';
401
+ return `<a href="${escapeHtml(href)}"${title}>${inner}</a>`;
402
+ },
403
+
404
+ // Fenced code; gherkin/feature blocks get keyword highlighting.
405
+ code(token) {
406
+ const lang = String(token.lang || '').trim().split(/\s+/)[0];
407
+ let escaped = escapeHtml(token.text);
408
+ if (lang === 'gherkin' || lang === 'feature') {
409
+ escaped = escaped.replace(GHERKIN_KEYWORDS, '$1<span class="kw">$2</span>');
410
+ }
411
+ const cls = lang ? ` class="language-${escapeHtml(lang)}"` : '';
412
+ return `<pre><code${cls}>${escaped}\n</code></pre>\n`;
413
+ },
414
+
415
+ // Task-list checkboxes -> styled spans (same look as the prototype; the
416
+ // trailing space restores the gap marked v18 no longer inserts itself).
417
+ checkbox(token) {
418
+ return `<span class="cb${token.checked ? ' on' : ''}"></span> `;
419
+ },
420
+
421
+ // All multi-column tables render as one card per row; single-column
422
+ // (degenerate) and body-less tables keep plain-table rendering, wrapped
423
+ // for horizontal overflow.
424
+ table(token) {
425
+ const headerCells = token.header.map((cell) => this.parser.parseInline(cell.tokens));
426
+ const rows = token.rows.map((row) => row.map((cell) => this.parser.parseInline(cell.tokens)));
427
+ if (headerCells.length < 2 || rows.length === 0) {
428
+ return plainTableHtml(headerCells, rows);
429
+ }
430
+ return cardsHtml(headerCells, rows);
431
+ }
432
+ };
433
+ }
434
+
435
+ // ----------------------------------------------------------- postprocessing
436
+
437
+ const CODE_MENTION = /<code>([^<>]+?\.md(?:#[^<>]*)?)<\/code>/g;
438
+
439
+ function splitOnce(text, sep) {
440
+ const i = text.indexOf(sep);
441
+ return i === -1 ? [text, ''] : [text.slice(0, i), text.slice(i + 1)];
442
+ }
443
+
444
+ // Pure posix relpath (no cwd involvement, unlike path.posix.relative).
445
+ function posixRelative(fromDir, toPath) {
446
+ const from = fromDir === '.' || fromDir === '' ? [] : fromDir.split('/');
447
+ const to = toPath.split('/');
448
+ let common = 0;
449
+ while (common < from.length && common < to.length && from[common] === to[common]) {
450
+ common += 1;
451
+ }
452
+ const up = new Array(from.length - common).fill('..');
453
+ return [...up, ...to.slice(common)].join('/') || '.';
454
+ }
455
+
456
+ // Link inline-code mentions of .md files to their rendered pages.
457
+ //
458
+ // Only mentions that resolve inside the rendered tree become links; anything
459
+ // else (external files, ambiguous bare names) is left untouched. Mentions
460
+ // inside <pre> blocks (directory trees, command examples) and existing <a>
461
+ // links are skipped. Accepted forms: tree-relative or `dflow/specs/`-prefixed
462
+ // paths, page-relative paths, and bare filenames (same directory first, then
463
+ // a unique match anywhere in the tree).
464
+ function autolinkCodeMentions(htmlText, relPosix, tree, basenames) {
465
+ const dir = path.posix.dirname(relPosix);
466
+ const curDir = dir === '.' ? '' : dir;
467
+
468
+ function resolveMention(pathPart) {
469
+ let p = unescapeHtml(pathPart);
470
+ if (p.startsWith('./')) {
471
+ p = p.slice(2);
472
+ }
473
+ const candidates = [];
474
+ if (p.includes('/')) {
475
+ for (const prefix of ['dflow/specs/', 'specs/']) {
476
+ if (p.startsWith(prefix)) {
477
+ candidates.push(p.slice(prefix.length));
478
+ }
479
+ }
480
+ candidates.push(p);
481
+ candidates.push(curDir ? path.posix.join(curDir, p) : p);
482
+ } else {
483
+ candidates.push(curDir ? path.posix.join(curDir, p) : p);
484
+ const hits = basenames.get(p) || [];
485
+ if (hits.length === 1) {
486
+ candidates.push(hits[0]);
487
+ }
488
+ }
489
+ for (const candidate of candidates) {
490
+ const normalized = path.posix.normalize(candidate);
491
+ if (tree.has(normalized)) {
492
+ return normalized;
493
+ }
494
+ }
495
+ return null;
496
+ }
497
+
498
+ const parts = htmlText.split(/(<pre>[\s\S]*?<\/pre>|<a\b[^>]*>[\s\S]*?<\/a>)/);
499
+ return parts
500
+ .map((part) => {
501
+ if (part.startsWith('<pre') || part.startsWith('<a')) {
502
+ return part;
503
+ }
504
+ return part.replace(CODE_MENTION, (mention, inner) => {
505
+ const [mentionPath, anchor] = splitOnce(inner, '#');
506
+ const target = resolveMention(mentionPath);
507
+ if (target === null) {
508
+ return mention;
509
+ }
510
+ let href = posixRelative(curDir || '.', `${target.slice(0, -'.md'.length)}.html`);
511
+ if (anchor) {
512
+ href += `#${anchor}`;
513
+ }
514
+ return `<a href="${escapeHtml(href)}">${mention}</a>`;
515
+ });
516
+ })
517
+ .join('');
518
+ }
519
+
520
+ function metaCardHtml(meta, fallbackTitle) {
521
+ const keys = Object.keys(meta);
522
+ if (keys.length === 0) {
523
+ return '';
524
+ }
525
+ const title = meta.title !== undefined ? meta.title : fallbackTitle;
526
+ const status = meta.status || '';
527
+ const pill = status
528
+ ? `<span class="badge ${STATUS_PILL[status] || 'neutral'}">${escapeHtml(status)}</span>`
529
+ : '';
530
+ const rows = keys
531
+ .filter((key) => key !== 'title' && key !== 'status' && meta[key])
532
+ .map((key) => `<span><span class="k">${escapeHtml(key)}</span>${escapeHtml(meta[key])}</span>`)
533
+ .join('');
534
+ return (
535
+ '<div class="meta-card">' +
536
+ `<p class="meta-title">${escapeHtml(title)} ${pill}</p>` +
537
+ `<div class="meta-grid">${rows}</div></div>`
538
+ );
539
+ }
540
+
541
+ // ----------------------------------------------------------------- rendering
542
+
543
+ function pageHtml({ title, crumb, metaCard, body, footer }) {
544
+ return `<!DOCTYPE html>
545
+ ${GENERATED_MARK}
546
+ <html lang="zh-Hant">
547
+ <head>
548
+ <meta charset="utf-8">
549
+ <meta name="viewport" content="width=device-width, initial-scale=1">
550
+ <title>${title}</title>
551
+ <style>${CSS}</style>
552
+ </head>
553
+ <body>
554
+ <main>
555
+ <p class="crumb">${crumb}</p>
556
+ ${metaCard}
557
+ ${body}
558
+ <p class="foot">${footer}</p>
559
+ </main>
560
+ </body>
561
+ </html>
562
+ `;
563
+ }
564
+
565
+ async function renderFile({ MarkedCtor, srcRoot, outRoot, relPosix, stamp, tree, basenames }) {
566
+ const relHtml = `${relPosix.slice(0, -'.md'.length)}.html`;
567
+ const mdPath = path.join(srcRoot, ...relPosix.split('/'));
568
+ const outPath = path.join(outRoot, ...relHtml.split('/'));
569
+
570
+ const { meta, body } = splitFrontmatter(await readText(mdPath));
571
+ const seen = new Map();
572
+ const marked = new MarkedCtor({ gfm: true, renderer: buildRenderer(seen) });
573
+ let bodyHtml = marked.parse(preprocess(body));
574
+ bodyHtml = autolinkCodeMentions(bodyHtml, relPosix, tree, basenames);
575
+
576
+ const parts = relPosix.split('/');
577
+ const stem = parts[parts.length - 1].slice(0, -'.md'.length);
578
+ const indexHref = '../'.repeat(parts.length - 1) + 'index.html';
579
+ const crumb = `<a href="${indexHref}">specs</a> / ` + parts.map(escapeHtml).join(' / ');
580
+ const footer = `由 ${escapeHtml(relPosix)} 生成 · ${stamp} · 來源檔較新時請重新執行 dflow render`;
581
+
582
+ await writeText(outPath, pageHtml({
583
+ title: escapeHtml(meta.title !== undefined ? meta.title : stem),
584
+ crumb,
585
+ metaCard: metaCardHtml(meta, stem),
586
+ body: bodyHtml,
587
+ footer
588
+ }));
589
+ return relHtml;
590
+ }
591
+
592
+ // Nested <ul> file tree with links to the mirrored .html files.
593
+ function buildTreeHtml(relFiles) {
594
+ const root = { dirs: new Map(), files: [] };
595
+ for (const rel of [...relFiles].sort()) {
596
+ const parts = rel.split('/');
597
+ let node = root;
598
+ for (const part of parts.slice(0, -1)) {
599
+ if (!node.dirs.has(part)) {
600
+ node.dirs.set(part, { dirs: new Map(), files: [] });
601
+ }
602
+ node = node.dirs.get(part);
603
+ }
604
+ node.files.push(rel);
605
+ }
606
+
607
+ function emit(node) {
608
+ let out = '<ul class="tree">';
609
+ for (const name of [...node.dirs.keys()].sort()) {
610
+ out += `<li><span class="dir">${escapeHtml(name)}/</span>${emit(node.dirs.get(name))}</li>`;
611
+ }
612
+ for (const rel of node.files) {
613
+ const href = `${rel.slice(0, -'.md'.length)}.html`;
614
+ const name = rel.split('/').pop();
615
+ out += `<li><a href="${escapeHtml(href)}">${escapeHtml(name)}</a></li>`;
616
+ }
617
+ return `${out}</ul>`;
618
+ }
619
+
620
+ return emit(root);
621
+ }
622
+
623
+ async function renderIndex({ outRoot, relFiles, title, stamp }) {
624
+ const body = `<h1>${escapeHtml(title)}</h1>${buildTreeHtml(relFiles)}`;
625
+ const footer = `共 ${relFiles.length} 份文件 · ${stamp} · 來源檔較新時請重新執行 dflow render`;
626
+ await writeText(path.join(outRoot, 'index.html'), pageHtml({
627
+ title: escapeHtml(title),
628
+ crumb: 'specs /',
629
+ metaCard: '',
630
+ body,
631
+ footer
632
+ }));
633
+ return 'index.html';
634
+ }
635
+
636
+ // ----------------------------------------------------- output-dir ownership
637
+
638
+ // Validate a parsed-or-raw manifest. Returns { ok: true, files } or
639
+ // { ok: false, reason }. Anything that fails here means "refuse to run and
640
+ // delete nothing" — presence alone never grants ownership.
641
+ function parseManifest(rawText) {
642
+ let data;
643
+ try {
644
+ data = JSON.parse(rawText);
645
+ } catch (error) {
646
+ return { ok: false, reason: 'not valid JSON' };
647
+ }
648
+ if (typeof data !== 'object' || data === null || Array.isArray(data)) {
649
+ return { ok: false, reason: 'not a JSON object' };
650
+ }
651
+ if (!('dflow-render' in data)) {
652
+ return { ok: false, reason: 'missing "dflow-render" schema version field' };
653
+ }
654
+ if (data['dflow-render'] !== MANIFEST_VERSION) {
655
+ return { ok: false, reason: `unsupported "dflow-render" schema version: ${JSON.stringify(data['dflow-render'])}` };
656
+ }
657
+ if (!Array.isArray(data.files)) {
658
+ return { ok: false, reason: '"files" is not an array' };
659
+ }
660
+ for (const entry of data.files) {
661
+ if (typeof entry !== 'string' || entry === '') {
662
+ return { ok: false, reason: '"files" contains a non-string or empty entry' };
663
+ }
664
+ if (entry.includes('\\') || /^[A-Za-z]:/.test(entry) || entry.startsWith('/')) {
665
+ return { ok: false, reason: `"files" entry is not a relative posix path: ${entry}` };
666
+ }
667
+ const segments = entry.split('/');
668
+ if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
669
+ return { ok: false, reason: `"files" entry escapes the output directory: ${entry}` };
670
+ }
671
+ }
672
+ return { ok: true, files: data.files };
673
+ }
674
+
675
+ // Stale = previous ledger minus this run's outputs. Comparison is
676
+ // case-insensitive: on case-insensitive filesystems (Windows, default macOS) a
677
+ // case-only rename would otherwise delete the freshly written file through its
678
+ // old-case name. On case-sensitive filesystems this can leave a stale
679
+ // odd-case file behind — accepted residual, never-misdelete beats never-leak.
680
+ // The manifest itself (and its tmp name) is never a deletion candidate.
681
+ function staleEntries(oldFiles, newFiles) {
682
+ const current = new Set(newFiles.map((file) => file.toLowerCase()));
683
+ return oldFiles.filter(
684
+ (file) =>
685
+ file !== MANIFEST_NAME &&
686
+ file !== MANIFEST_TMP_NAME &&
687
+ !current.has(file.toLowerCase())
688
+ );
689
+ }
690
+
691
+ // Atomic manifest write (tmp + rename): a crash mid-write must never leave a
692
+ // torn manifest, or the next run would refuse the directory it owns.
693
+ async function writeManifest(outRoot, files) {
694
+ const body = `${JSON.stringify({ 'dflow-render': MANIFEST_VERSION, files }, null, 2)}\n`;
695
+ const tmpPath = path.join(outRoot, MANIFEST_TMP_NAME);
696
+ await fs.writeFile(ioPath(tmpPath), body, 'utf8');
697
+ await fs.rename(ioPath(tmpPath), ioPath(path.join(outRoot, MANIFEST_NAME)));
698
+ }
699
+
700
+ // Physical path for the overlap guard: resolve symlinks/junctions in the
701
+ // deepest EXISTING ancestor (--out may not exist yet), then re-append the
702
+ // not-yet-existing tail. Without this the guard is lexical only, and a
703
+ // junction --out physically inside --src would pass it (impl review R1 F1).
704
+ // Deliberately not ioPath()-wrapped: realpath must return the plain form the
705
+ // guard compares, and these are user-supplied roots, not deep tree paths.
706
+ async function realpathDeep(p) {
707
+ let base = p;
708
+ const tail = [];
709
+ for (;;) {
710
+ try {
711
+ const real = await fs.realpath(base);
712
+ return tail.length ? path.join(real, ...tail.reverse()) : real;
713
+ } catch (error) {
714
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') {
715
+ throw error;
716
+ }
717
+ const parent = path.dirname(base);
718
+ if (parent === base) {
719
+ return p; // nothing on this root exists — fall back to the lexical path
720
+ }
721
+ tail.push(path.basename(base));
722
+ base = parent;
723
+ }
724
+ }
725
+ }
726
+
727
+ // True when `parent` contains (or equals) `child`. path.relative handles
728
+ // case-insensitive drives on win32.
729
+ function pathContains(parent, child) {
730
+ const rel = path.relative(parent, child);
731
+ if (rel === '') {
732
+ return true;
733
+ }
734
+ if (path.isAbsolute(rel)) {
735
+ return false;
736
+ }
737
+ return rel.split(path.sep)[0] !== '..';
738
+ }
739
+
740
+ // Source→output projection collisions (cold-eye gate G3 F2): distinct
741
+ // sources can map to one output path (a.md vs A.md under the case-insensitive
742
+ // comparison the ledger already uses; a root index.md vs the generated file
743
+ // tree), and a source directory literally named *.html can need the same
744
+ // path a sibling source file produces. A root source directory named after
745
+ // the manifest (or its tmp name) needs that reserved path as a directory,
746
+ // which the ownership stamp and the end-of-run manifest write need as a file
747
+ // (the source-side door of cold-eye gate G5 F1). Undetected, the run dies on
748
+ // a raw EISDIR/ENOTDIR mid-write — after the ownership stamp — so this is
749
+ // checked before any output mutation. Returns null, or a human-readable
750
+ // description of the first collision found.
751
+ function findProjectionCollision(relFiles) {
752
+ const toOut = (rel) => `${rel.slice(0, -'.md'.length)}.html`;
753
+ for (const rel of relFiles) {
754
+ // The ledger stores out-relative posix paths and parseManifest rejects
755
+ // anything else. A source name a POSIX filesystem allows but the schema
756
+ // cannot represent (a backslash inside a segment, a drive-letter-like
757
+ // prefix) would be written into this run's own manifest and lock the
758
+ // directory out at the next run's parse (cold-eye gate G7) — refuse
759
+ // before mutating anything instead.
760
+ if (rel.includes('\\') || /^[A-Za-z]:/.test(rel)) {
761
+ return `source path ${rel} cannot be represented in the render manifest`;
762
+ }
763
+ }
764
+ const byOut = new Map([['index.html', 'the generated file tree']]);
765
+ for (const rel of relFiles) {
766
+ const out = toOut(rel);
767
+ const prior = byOut.get(out.toLowerCase());
768
+ if (prior) {
769
+ return `${rel} and ${prior} would both produce ${out}`;
770
+ }
771
+ byOut.set(out.toLowerCase(), rel);
772
+ }
773
+ const neededDirs = new Map();
774
+ for (const rel of relFiles) {
775
+ const parts = toOut(rel).split('/');
776
+ for (let i = 1; i < parts.length; i++) {
777
+ const prefix = parts.slice(0, i).join('/');
778
+ if (!neededDirs.has(prefix.toLowerCase())) {
779
+ neededDirs.set(prefix.toLowerCase(), { prefix, source: rel });
780
+ }
781
+ }
782
+ }
783
+ const indexDir = neededDirs.get('index.html');
784
+ if (indexDir) {
785
+ return `${indexDir.source} needs ${indexDir.prefix}/ as a directory, but render generates index.html there`;
786
+ }
787
+ for (const reserved of [MANIFEST_NAME, MANIFEST_TMP_NAME]) {
788
+ const hit = neededDirs.get(reserved);
789
+ if (hit) {
790
+ return `${hit.source} needs ${hit.prefix}/ as a directory, but render reserves that name for its ownership manifest`;
791
+ }
792
+ }
793
+ for (const rel of relFiles) {
794
+ const out = toOut(rel);
795
+ const hit = neededDirs.get(out.toLowerCase());
796
+ if (hit) {
797
+ return `${rel} would produce ${out} as a file, but ${hit.source} needs ${hit.prefix}/ as a directory`;
798
+ }
799
+ }
800
+ return null;
801
+ }
802
+
803
+ // The owned output tree must contain only what render itself could have
804
+ // created — real directories and regular files with a single name — before
805
+ // render touches it. Writes, stale unlinks, and prunes below outRoot all
806
+ // follow symlinks/junctions in the path (and fs.writeFile follows a link in
807
+ // the final component), so a link planted inside the owned tree would
808
+ // redirect them outside --out even though the root was realpath'd (cold-eye
809
+ // gate G1 F1). A hardlinked regular file is the same escape without a link
810
+ // dirent: it reports as an ordinary file, but the full-rebuild rewrite
811
+ // truncates the shared inode, changing the file's other name outside --out
812
+ // (cold-eye gate G2 F1). A real directory squatting on the reserved
813
+ // manifest-tmp name at the tree root is the one all-regular-entries shape
814
+ // render still cannot survive: it passes the type whitelist, but the
815
+ // end-of-run manifest write opens that exact path as a file and would die
816
+ // EISDIR only after outputs were written and stale files deleted (cold-eye
817
+ // gate G5 F1) — so it is refused here, before any mutation. render never
818
+ // creates any of these, nor special files, so all of them are foreign:
819
+ // report and refuse rather than write or delete through them. Dirents
820
+ // classify junctions as symlinks, and links are never recursed into, so a
821
+ // link cycle cannot hang the walk. Returns { rel, what } for the first
822
+ // unsafe entry (out-relative posix path + refusal phrase), or null for a
823
+ // tree that is safe to rebuild.
824
+ async function findUnsafeEntry(root) {
825
+ async function walk(dir, relParts) {
826
+ const entries = await fs.readdir(ioPath(dir), { withFileTypes: true });
827
+ for (const entry of entries) {
828
+ const rel = [...relParts, entry.name].join('/');
829
+ if (entry.isSymbolicLink()) {
830
+ return { rel, what: 'a symlink or junction' };
831
+ }
832
+ // Reserved-name comparison is case-insensitive like every ledger
833
+ // comparison: on case-insensitive filesystems a case variant collides
834
+ // with the same end-of-run write.
835
+ if (relParts.length === 0 && entry.isDirectory() && entry.name.toLowerCase() === MANIFEST_TMP_NAME) {
836
+ return { rel, what: 'a directory at the reserved manifest-tmp name' };
837
+ }
838
+ if (entry.isDirectory()) {
839
+ const found = await walk(path.join(dir, entry.name), [...relParts, entry.name]);
840
+ if (found) {
841
+ return found;
842
+ }
843
+ } else if (entry.isFile()) {
844
+ const stats = await fs.lstat(ioPath(path.join(dir, entry.name)));
845
+ if (stats.nlink > 1) {
846
+ return { rel, what: 'a regular file with multiple hard links' };
847
+ }
848
+ } else {
849
+ return { rel, what: 'not a regular file or directory' };
850
+ }
851
+ }
852
+ return null;
853
+ }
854
+ return walk(root, []);
855
+ }
856
+
857
+ // Mutation proof (cold-eye gate G3 F1): a valid manifest plus a link-free
858
+ // tree still does not prove any particular file is render's own work — the
859
+ // manifest is copyable and can list files render never wrote. Every existing
860
+ // regular file at a path this run will write (planned mirror outputs) or may
861
+ // delete (ledger entries) must carry GENERATED_MARK in its head, or the run
862
+ // refuses before mutating anything. The in-content marker survives an
863
+ // interrupted run whose deferred ledger rewrite never happened, so crash
864
+ // convergence stays possible while foreign or hand-replaced files fail
865
+ // closed. Directory/file mismatches at planned paths are refused here too —
866
+ // they would otherwise die as a raw EISDIR/ENOTDIR mid-write, after
867
+ // ownership state changed (the output-side sibling of G3 F2). Runs after
868
+ // findUnsafeEntry, so every path probed here is a real file or directory.
869
+ // Returns { rel, problem } for the first unproven target, or null.
870
+ async function findUnprovenTarget({ outRoot, plannedRels, listedRels }) {
871
+ async function lstatRel(rel) {
872
+ try {
873
+ return await fs.lstat(ioPath(path.join(outRoot, ...rel.split('/'))));
874
+ } catch (error) {
875
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') {
876
+ throw error;
877
+ }
878
+ return null;
879
+ }
880
+ }
881
+ async function bearsMark(rel) {
882
+ const head = await readHead(path.join(outRoot, ...rel.split('/')), 4096);
883
+ return head.includes(GENERATED_MARK);
884
+ }
885
+
886
+ const neededDirs = new Set();
887
+ for (const rel of plannedRels) {
888
+ const parts = rel.split('/');
889
+ for (let i = 1; i < parts.length; i++) {
890
+ neededDirs.add(parts.slice(0, i).join('/'));
891
+ }
892
+ }
893
+ for (const rel of neededDirs) {
894
+ const stats = await lstatRel(rel);
895
+ if (stats && !stats.isDirectory()) {
896
+ return { rel, problem: 'is a file, but this run needs it as a directory' };
897
+ }
898
+ }
899
+
900
+ const proven = new Set();
901
+ for (const rel of plannedRels) {
902
+ const stats = await lstatRel(rel);
903
+ if (stats === null) {
904
+ continue;
905
+ }
906
+ if (stats.isDirectory()) {
907
+ return { rel, problem: 'is a directory, but this run writes a file there' };
908
+ }
909
+ if (!(await bearsMark(rel))) {
910
+ return { rel, problem: 'was not generated by dflow render' };
911
+ }
912
+ proven.add(rel.toLowerCase());
913
+ }
914
+
915
+ for (const rel of listedRels) {
916
+ if (proven.has(rel.toLowerCase())) {
917
+ continue;
918
+ }
919
+ const stats = await lstatRel(rel);
920
+ if (stats === null) {
921
+ continue;
922
+ }
923
+ if (stats.isDirectory()) {
924
+ return { rel, problem: 'is listed in the manifest but is a directory' };
925
+ }
926
+ if (!(await bearsMark(rel))) {
927
+ return { rel, problem: 'is listed in the manifest but was not generated by dflow render' };
928
+ }
929
+ }
930
+ return null;
931
+ }
932
+
933
+ async function collectMdFiles(srcRoot) {
934
+ const files = [];
935
+ async function walk(dir, relParts) {
936
+ const entries = await fs.readdir(ioPath(dir), { withFileTypes: true });
937
+ for (const entry of entries) {
938
+ if (entry.isDirectory()) {
939
+ await walk(path.join(dir, entry.name), [...relParts, entry.name]);
940
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
941
+ files.push([...relParts, entry.name].join('/'));
942
+ }
943
+ }
944
+ }
945
+ await walk(srcRoot, []);
946
+ return files.sort();
947
+ }
948
+
949
+ // After stale deletion, prune directories that became empty (deepest first)
950
+ // — but only parents of files THIS run actually unlinked. Those directories
951
+ // held a marker-proven render output until a moment ago, so an empty
952
+ // leftover is render's own mirror structure. A stale ledger entry whose file
953
+ // was already gone proves nothing about the directory chain above it: the
954
+ // ledger lists files, not directories, and an empty directory there may be
955
+ // foreign (e.g. user-recreated at an old mirror path) — pruning on the
956
+ // ENOENT path would delete a directory render cannot prove it made
957
+ // (cold-eye gate G6 F1). fs.rmdir refuses non-empty directories, so this
958
+ // can never remove a directory that still holds anything.
959
+ async function pruneEmptyDirs(outRoot, unlinkedRels) {
960
+ const dirs = new Set();
961
+ for (const rel of unlinkedRels) {
962
+ let dir = path.posix.dirname(rel);
963
+ while (dir && dir !== '.') {
964
+ dirs.add(dir);
965
+ dir = path.posix.dirname(dir);
966
+ }
967
+ }
968
+ const deepestFirst = [...dirs].sort((a, b) => b.split('/').length - a.split('/').length);
969
+ for (const rel of deepestFirst) {
970
+ try {
971
+ await fs.rmdir(ioPath(path.join(outRoot, ...rel.split('/'))));
972
+ } catch (error) {
973
+ // Not empty or already gone — either way, leave it.
974
+ }
975
+ }
976
+ }
977
+
978
+ // ------------------------------------------------------------------ CLI face
979
+
980
+ function parseRenderArgs(args) {
981
+ const options = { src: DEFAULT_SRC, out: DEFAULT_OUT, title: DEFAULT_TITLE };
982
+ const valueFlags = { '--src': 'src', '--out': 'out', '--title': 'title' };
983
+ for (let i = 0; i < args.length; i++) {
984
+ const arg = args[i];
985
+ const eq = arg.indexOf('=');
986
+ const name = eq === -1 ? arg : arg.slice(0, eq);
987
+ if (!Object.prototype.hasOwnProperty.call(valueFlags, name)) {
988
+ throw new Error(`Unsupported render option: ${arg}`);
989
+ }
990
+ let value;
991
+ if (eq !== -1) {
992
+ value = arg.slice(eq + 1);
993
+ } else {
994
+ i += 1;
995
+ value = i < args.length ? args[i] : '';
996
+ }
997
+ if (!value) {
998
+ throw new Error(`Missing value for render option: ${name}`);
999
+ }
1000
+ options[valueFlags[name]] = value;
1001
+ }
1002
+ return options;
1003
+ }
1004
+
1005
+ async function runRender({ cwd, args = [], stdout, stderr }) {
1006
+ let options;
1007
+ try {
1008
+ options = parseRenderArgs(args);
1009
+ } catch (error) {
1010
+ stderr.write(`${error.message}\n`);
1011
+ return 1;
1012
+ }
1013
+
1014
+ let srcRoot = path.resolve(cwd, options.src);
1015
+
1016
+ let srcStat = null;
1017
+ try {
1018
+ srcStat = await fs.stat(ioPath(srcRoot));
1019
+ } catch (error) {
1020
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') {
1021
+ throw error;
1022
+ }
1023
+ }
1024
+ if (!srcStat || !srcStat.isDirectory()) {
1025
+ stderr.write(`src not found: ${srcRoot}\n`);
1026
+ return 1;
1027
+ }
1028
+
1029
+ // Physical paths from here on: both the overlap guard and every later write
1030
+ // / cleanup must see through symlinks and junctions, or a linked --out
1031
+ // pointing inside --src would bypass the guard (impl review R1 F1).
1032
+ srcRoot = await realpathDeep(srcRoot);
1033
+ const outRoot = await realpathDeep(path.resolve(cwd, options.out));
1034
+
1035
+ // Overlap guard, both directions: --out inside --src would pollute the specs
1036
+ // tree (and get rescanned as source next run); --src inside --out would put
1037
+ // the source tree inside the directory render owns and cleans.
1038
+ if (pathContains(srcRoot, outRoot)) {
1039
+ stderr.write(`--out must not be inside --src (or equal to it): ${outRoot}\n`);
1040
+ return 1;
1041
+ }
1042
+ if (pathContains(outRoot, srcRoot)) {
1043
+ stderr.write(`--src must not be inside --out: ${srcRoot}\n`);
1044
+ return 1;
1045
+ }
1046
+
1047
+ // Collect and sanity-check the source projection before touching --out at
1048
+ // all: a colliding projection must refuse cleanly here, not die on a raw
1049
+ // fs error mid-write after the ownership stamp (cold-eye gate G3 F2).
1050
+ const relMdFiles = await collectMdFiles(srcRoot);
1051
+ const collision = findProjectionCollision(relMdFiles);
1052
+ if (collision) {
1053
+ stderr.write(
1054
+ `refusing to run: ${collision}; nothing was written.\n` +
1055
+ 'Rename the colliding source files, or render a narrower --src.\n'
1056
+ );
1057
+ return 1;
1058
+ }
1059
+
1060
+ // Output-directory ownership (crash-safe order):
1061
+ // (0) first run (missing/empty --out): stamp ownership with a files:[]
1062
+ // manifest BEFORE writing anything else;
1063
+ // (1) render and write every output of this run;
1064
+ // (2) delete stale files (previous manifest minus this run — only files
1065
+ // the ledger listed, never anything else);
1066
+ // (3) rewrite the full manifest last.
1067
+ // A crash at any point leaves a manifest in place (initial stamp or the
1068
+ // previous complete one), so the next run recognizes its own directory and
1069
+ // converges by re-rendering everything.
1070
+ let previousFiles = [];
1071
+ let outEntries = null;
1072
+ try {
1073
+ outEntries = await fs.readdir(ioPath(outRoot));
1074
+ } catch (error) {
1075
+ if (error.code === 'ENOTDIR') {
1076
+ stderr.write(`out is not a directory (a file is in the way): ${outRoot}\n`);
1077
+ return 1;
1078
+ }
1079
+ if (error.code !== 'ENOENT') {
1080
+ throw error;
1081
+ }
1082
+ }
1083
+
1084
+ if (outEntries === null) {
1085
+ try {
1086
+ await fs.mkdir(ioPath(outRoot), { recursive: true });
1087
+ } catch (error) {
1088
+ // Windows reports ENOENT (not POSIX's ENOTDIR) from the readdir above
1089
+ // when an ancestor of --out is a file, so that case first surfaces here
1090
+ // as mkdir ENOTDIR; EEXIST covers outRoot itself having become a file
1091
+ // since the readdir (impl review R2 F1).
1092
+ if (error.code !== 'ENOTDIR' && error.code !== 'EEXIST') {
1093
+ throw error;
1094
+ }
1095
+ stderr.write(`out is not a directory (a file is in the way): ${outRoot}\n`);
1096
+ return 1;
1097
+ }
1098
+ await writeManifest(outRoot, []);
1099
+ } else {
1100
+ // A leftover manifest tmp (crash residue of writeManifest's
1101
+ // write-tmp-then-rename) never counts toward "someone else's directory",
1102
+ // but the reserved NAME alone is no ownership proof (cold-eye gate G4
1103
+ // F1): in a directory with no manifest, the tmp must prove itself — a
1104
+ // single-name regular file whose content parses as a valid manifest — or
1105
+ // the run refuses without touching it. It is never deleted up front in
1106
+ // any branch; the end-of-run writeManifest overwrites the reserved name
1107
+ // in place, and the unsafe-entry scan below vets a leftover tmp in an
1108
+ // owned directory first — link / hardlink / special-file shapes and a
1109
+ // directory squatting on the reserved name (cold-eye gate G5 F1) are all
1110
+ // refused there before anything is written or deleted.
1111
+ const meaningful = outEntries.filter((name) => name !== MANIFEST_TMP_NAME);
1112
+ if (meaningful.length === 0) {
1113
+ if (outEntries.includes(MANIFEST_TMP_NAME)) {
1114
+ const tmpPath = path.join(outRoot, MANIFEST_TMP_NAME);
1115
+ let tmpStats = null;
1116
+ try {
1117
+ tmpStats = await fs.lstat(ioPath(tmpPath));
1118
+ } catch (error) {
1119
+ if (error.code !== 'ENOENT') {
1120
+ throw error;
1121
+ }
1122
+ }
1123
+ // Size cap before reading: a genuine stamp residue is a small JSON
1124
+ // file; never slurp an arbitrarily large foreign file to disprove it.
1125
+ const provenOurs =
1126
+ tmpStats !== null &&
1127
+ tmpStats.isFile() &&
1128
+ tmpStats.nlink === 1 &&
1129
+ tmpStats.size <= 64 * 1024 * 1024 &&
1130
+ parseManifest(await readText(tmpPath)).ok;
1131
+ if (tmpStats !== null && !provenOurs) {
1132
+ stderr.write(
1133
+ `refusing to run: ${MANIFEST_TMP_NAME} in ${outRoot} cannot be proven to be dflow render's own crash residue; your files were not touched.\n` +
1134
+ 'Remove it yourself, or pick a different --out.\n'
1135
+ );
1136
+ return 1;
1137
+ }
1138
+ }
1139
+ await writeManifest(outRoot, []);
1140
+ } else if (!meaningful.includes(MANIFEST_NAME)) {
1141
+ stderr.write(
1142
+ `refusing to write into non-empty directory without ${MANIFEST_NAME}: ${outRoot}\n` +
1143
+ 'dflow render owns its output directory; pick a new or empty --out.\n'
1144
+ );
1145
+ return 1;
1146
+ } else {
1147
+ // lstat before reading: a directory (or link) squatting on the exact
1148
+ // manifest name would otherwise surface as a raw EISDIR from the read
1149
+ // — and a planted link would be read through before the tree scan
1150
+ // below could refuse it.
1151
+ const manifestStats = await fs.lstat(ioPath(path.join(outRoot, MANIFEST_NAME)));
1152
+ if (!manifestStats.isFile()) {
1153
+ stderr.write(
1154
+ `refusing to run: ${MANIFEST_NAME} in ${outRoot} is not a regular file; no files were deleted.\n` +
1155
+ 'Remove the output directory yourself, or pick a different --out.\n'
1156
+ );
1157
+ return 1;
1158
+ }
1159
+ const parsed = parseManifest(await readText(path.join(outRoot, MANIFEST_NAME)));
1160
+ if (!parsed.ok) {
1161
+ stderr.write(
1162
+ `refusing to run: ${MANIFEST_NAME} in ${outRoot} is invalid (${parsed.reason}); no files were deleted.\n` +
1163
+ 'Remove the output directory yourself, or pick a different --out.\n'
1164
+ );
1165
+ return 1;
1166
+ }
1167
+ previousFiles = parsed.files;
1168
+ }
1169
+ }
1170
+
1171
+ // Ownership alone is not enough: every write and delete below outRoot
1172
+ // follows links in the path, and rewriting a hardlinked file writes its
1173
+ // shared inode — the accepted tree must hold nothing render could not have
1174
+ // created itself.
1175
+ const unsafe = await findUnsafeEntry(outRoot);
1176
+ if (unsafe) {
1177
+ stderr.write(
1178
+ `refusing to run: ${unsafe.rel} inside the output directory is ${unsafe.what}; your files were not touched.\n` +
1179
+ 'dflow render only creates regular files and directories, and will not write or delete through anything else — remove it, or pick a different --out.\n'
1180
+ );
1181
+ return 1;
1182
+ }
1183
+
1184
+ // Ownership (manifest) + entry types (scan) still do not prove a given
1185
+ // file is render's own work; the generated marker inside the file does.
1186
+ const plannedRels = [...relMdFiles.map((rel) => `${rel.slice(0, -'.md'.length)}.html`), 'index.html'];
1187
+ const unproven = await findUnprovenTarget({ outRoot, plannedRels, listedRels: previousFiles });
1188
+ if (unproven) {
1189
+ stderr.write(
1190
+ `refusing to run: ${unproven.rel} in the output directory ${unproven.problem}; your files were not touched.\n` +
1191
+ 'Remove it (and any manifest entry naming it), or pick a different --out.\n'
1192
+ );
1193
+ return 1;
1194
+ }
1195
+
1196
+ const tree = new Set(relMdFiles);
1197
+ const basenames = new Map();
1198
+ for (const rel of relMdFiles) {
1199
+ const name = rel.split('/').pop();
1200
+ if (!basenames.has(name)) {
1201
+ basenames.set(name, []);
1202
+ }
1203
+ basenames.get(name).push(rel);
1204
+ }
1205
+
1206
+ // marked is ESM-only: dynamic import is the one loading path that works on
1207
+ // every Node >=22.0.0 from CommonJS. Do not convert this to a require() call.
1208
+ const { Marked } = await import('marked');
1209
+
1210
+ const stamp = nowStamp();
1211
+ const written = [];
1212
+ for (const relPosix of relMdFiles) {
1213
+ written.push(await renderFile({ MarkedCtor: Marked, srcRoot, outRoot, relPosix, stamp, tree, basenames }));
1214
+ }
1215
+ written.push(await renderIndex({ outRoot, relFiles: relMdFiles, title: options.title, stamp }));
1216
+
1217
+ const stale = staleEntries(previousFiles, written);
1218
+ const unlinked = [];
1219
+ for (const rel of stale) {
1220
+ try {
1221
+ await fs.unlink(ioPath(path.join(outRoot, ...rel.split('/'))));
1222
+ unlinked.push(rel);
1223
+ } catch (error) {
1224
+ // ENOENT: already gone. ENOTDIR: the listed path's parent chain no
1225
+ // longer holds directories, so no file of ours can exist there.
1226
+ // Either way this run deleted nothing, so the entry earns no
1227
+ // empty-parent pruning below.
1228
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') {
1229
+ throw error;
1230
+ }
1231
+ }
1232
+ }
1233
+ await pruneEmptyDirs(outRoot, unlinked);
1234
+
1235
+ await writeManifest(outRoot, written);
1236
+
1237
+ stdout.write(`rendered ${relMdFiles.length} md files -> ${outRoot}\n`);
1238
+ stdout.write(`open: ${path.join(outRoot, 'index.html')}\n`);
1239
+ return 0;
1240
+ }
1241
+
1242
+ function nowStamp() {
1243
+ const d = new Date();
1244
+ const pad = (n) => String(n).padStart(2, '0');
1245
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
1246
+ }
1247
+
1248
+ module.exports = {
1249
+ runRender,
1250
+ // Exported for tests: the data-safety core (manifest schema refusals, the
1251
+ // never-delete-unlisted stale diff, the unsafe-entry output scan, the
1252
+ // marker-based mutation proof, projection-collision detection) and the
1253
+ // overlap guard are tested directly in addition to the end-to-end CLI tests.
1254
+ parseManifest,
1255
+ staleEntries,
1256
+ pathContains,
1257
+ findUnsafeEntry,
1258
+ findUnprovenTarget,
1259
+ findProjectionCollision,
1260
+ GENERATED_MARK,
1261
+ splitFrontmatter,
1262
+ headingSlug
1263
+ };