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.
- package/CHANGELOG.md +91 -0
- package/README.en.md +83 -17
- package/README.md +39 -9
- package/TEMPLATE-COVERAGE.md +1 -0
- package/bin/dflow.js +58 -2
- package/docs/evaluating-dflow.en.md +21 -2
- package/docs/evaluating-dflow.md +17 -3
- package/docs/using-with-claude-code.en.md +23 -16
- package/docs/using-with-claude-code.md +20 -14
- package/docs/using-with-codex.en.md +15 -8
- package/docs/using-with-codex.md +10 -7
- package/docs/using-with-github-copilot.en.md +8 -3
- package/docs/using-with-github-copilot.md +6 -3
- package/lib/init.js +93 -8
- package/lib/render.js +1263 -0
- package/package.json +5 -2
- package/templates/brownfield/references/init-project-flow.md +43 -1
- package/templates/brownfield/scaffolding/AI-AGENT-GUIDE.md +5 -0
- package/templates/brownfield/templates/_index.md +2 -0
- package/templates/brownfield/templates/context-definition.md +2 -0
- package/templates/brownfield/templates/context-map.md +1 -0
- package/templates/brownfield/templates/glossary.md +1 -0
- package/templates/brownfield/templates/models.md +1 -0
- package/templates/brownfield/templates/phase-spec.md +2 -0
- package/templates/brownfield/templates/rules.md +1 -0
- package/templates/brownfield/templates/tech-debt.md +1 -0
- package/templates/greenfield/references/init-project-flow.md +43 -1
- package/templates/greenfield/scaffolding/AI-AGENT-GUIDE.md +5 -0
- package/templates/greenfield/templates/_index.md +2 -0
- package/templates/greenfield/templates/aggregate-design.md +2 -0
- package/templates/greenfield/templates/context-definition.md +2 -0
- package/templates/greenfield/templates/context-map.md +1 -0
- package/templates/greenfield/templates/events.md +1 -0
- package/templates/greenfield/templates/glossary.md +1 -0
- package/templates/greenfield/templates/models.md +1 -0
- package/templates/greenfield/templates/phase-spec.md +2 -0
- package/templates/greenfield/templates/rules.md +1 -0
- 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, '&')
|
|
237
|
+
.replace(/</g, '<')
|
|
238
|
+
.replace(/>/g, '>')
|
|
239
|
+
.replace(/"/g, '"')
|
|
240
|
+
.replace(/'/g, ''');
|
|
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(/</g, '<')
|
|
251
|
+
.replace(/>/g, '>')
|
|
252
|
+
.replace(/"/g, '"')
|
|
253
|
+
.replace(/'/g, "'")
|
|
254
|
+
.replace(/&/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
|
+
};
|