dflow-sdd-ddd 0.13.0 → 0.15.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 +824 -1
- package/CONTRIBUTING.md +16 -10
- package/README.en.md +156 -200
- package/README.md +89 -144
- package/TEMPLATE-COVERAGE.md +15 -8
- package/TEMPLATE-LANGUAGE-GLOSSARY.md +15 -1
- package/bin/dflow.js +36 -4
- package/docs/commands.en.md +110 -0
- package/docs/commands.md +101 -0
- package/docs/doctor-uncertainty.en.md +212 -0
- package/docs/doctor-uncertainty.md +212 -0
- package/docs/evaluating-dflow.en.md +29 -11
- package/docs/evaluating-dflow.md +8 -6
- package/docs/npm-publish-checklist.md +3 -1
- package/docs/release-versioning-policy.md +8 -2
- package/docs/upgrading.en.md +196 -0
- package/docs/upgrading.md +197 -0
- package/docs/using-with-claude-code.en.md +25 -10
- package/docs/using-with-claude-code.md +20 -7
- package/docs/using-with-codex.en.md +18 -6
- package/docs/using-with-codex.md +16 -5
- package/docs/using-with-github-copilot.en.md +25 -10
- package/docs/using-with-github-copilot.md +21 -8
- package/lib/doc-shapes.json +997 -0
- package/lib/doctor-checks.js +2654 -0
- package/lib/init.js +3583 -107
- package/lib/render-diagrams.js +1474 -0
- package/lib/render.js +865 -49
- package/package.json +2 -2
- package/templates/brownfield/references/drift-verification.md +4 -0
- package/templates/brownfield/references/finish-feature-flow.md +635 -88
- package/templates/brownfield/references/finish-feature-follow-up.md +60 -0
- package/templates/brownfield/references/finish-feature-minimal-host.md +406 -0
- package/templates/brownfield/references/finish-feature-post-hoc-hotfix.md +95 -0
- package/templates/brownfield/references/git-integration.md +160 -15
- package/templates/brownfield/references/init-project-flow.md +26 -4
- package/templates/brownfield/references/modify-existing-flow.md +412 -87
- package/templates/brownfield/references/modify-existing-follow-up.md +121 -0
- package/templates/brownfield/references/modify-existing-post-hoc-hotfix.md +82 -0
- package/templates/brownfield/references/new-feature-flow.md +61 -6
- package/templates/brownfield/references/new-phase-flow.md +57 -7
- package/templates/brownfield/references/pr-review-checklist.md +303 -10
- package/templates/brownfield/scaffolding/AI-AGENT-GUIDE.md +158 -34
- package/templates/brownfield/scaffolding/CLAUDE-md-snippet.md +1 -1
- package/templates/brownfield/scaffolding/Git-principles-gitflow.md +75 -6
- package/templates/brownfield/scaffolding/Git-principles-trunk.md +82 -8
- package/templates/brownfield/scaffolding/_conventions.md +50 -28
- package/templates/brownfield/scaffolding/_overview.md +1 -0
- package/templates/brownfield/templates/_index.md +151 -7
- package/templates/brownfield/templates/analysis.md +79 -0
- package/templates/brownfield/templates/behavior.md +1 -0
- package/templates/brownfield/templates/context-definition.md +1 -0
- package/templates/brownfield/templates/context-map.md +2 -1
- package/templates/brownfield/templates/glossary.md +1 -0
- package/templates/brownfield/templates/lightweight-spec.md +154 -11
- package/templates/brownfield/templates/models.md +1 -0
- package/templates/brownfield/templates/phase-spec.md +9 -1
- package/templates/brownfield/templates/rules.md +1 -0
- package/templates/brownfield/templates/tech-debt.md +1 -0
- package/templates/common/references/ddd-modeling-guide.md +33 -16
- package/templates/{greenfield → common}/references/dflow-feedback-flow.md +2 -1
- package/templates/common/references/flow-rationale-registry.md +130 -0
- package/templates/common/skill/SKILL.md +13 -11
- package/templates/greenfield/references/drift-verification.md +4 -0
- package/templates/greenfield/references/finish-feature-flow.md +625 -89
- package/templates/greenfield/references/finish-feature-follow-up.md +60 -0
- package/templates/greenfield/references/finish-feature-minimal-host.md +363 -0
- package/templates/greenfield/references/finish-feature-post-hoc-hotfix.md +95 -0
- package/templates/greenfield/references/git-integration.md +148 -15
- package/templates/greenfield/references/init-project-flow.md +28 -8
- package/templates/greenfield/references/modify-existing-flow.md +378 -85
- package/templates/greenfield/references/modify-existing-follow-up.md +103 -0
- package/templates/greenfield/references/modify-existing-post-hoc-hotfix.md +82 -0
- package/templates/greenfield/references/new-feature-flow.md +67 -4
- package/templates/greenfield/references/new-phase-flow.md +56 -7
- package/templates/greenfield/references/pr-review-checklist.md +287 -8
- package/templates/greenfield/scaffolding/AI-AGENT-GUIDE.md +153 -32
- package/templates/greenfield/scaffolding/CLAUDE-md-snippet.md +5 -2
- package/templates/greenfield/scaffolding/Git-principles-gitflow.md +74 -6
- package/templates/greenfield/scaffolding/Git-principles-trunk.md +88 -12
- package/templates/greenfield/scaffolding/_conventions.md +50 -28
- package/templates/greenfield/scaffolding/_overview.md +6 -2
- package/templates/greenfield/templates/_index.md +137 -7
- package/templates/greenfield/templates/aggregate-design.md +1 -0
- package/templates/greenfield/templates/analysis.md +79 -0
- package/templates/greenfield/templates/behavior.md +1 -0
- package/templates/greenfield/templates/context-definition.md +1 -0
- package/templates/greenfield/templates/context-map.md +2 -1
- package/templates/greenfield/templates/events.md +4 -1
- package/templates/greenfield/templates/glossary.md +1 -0
- package/templates/greenfield/templates/lightweight-spec.md +154 -11
- package/templates/greenfield/templates/models.md +1 -0
- package/templates/greenfield/templates/phase-spec.md +9 -1
- package/templates/greenfield/templates/rules.md +1 -0
- package/templates/greenfield/templates/tech-debt.md +1 -0
- package/templates/brownfield/references/dflow-feedback-flow.md +0 -251
package/lib/render.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// dflow render — specs Markdown -> human-readable static HTML (PROPOSAL-073).
|
|
2
2
|
//
|
|
3
3
|
// Markdown stays the AI-facing source of truth; this command projects it into
|
|
4
|
-
// a mirrored .html tree plus a root index.html
|
|
5
|
-
// Every run is a full rebuild (the index
|
|
4
|
+
// a mirrored .html tree plus a root index.html for human reading.
|
|
5
|
+
// Every run is a full rebuild (the index and in-tree autolinks depend on
|
|
6
6
|
// the whole file set, so partial rebuilds would leave stale links).
|
|
7
7
|
//
|
|
8
8
|
// Rendering parity baseline: the retired dev prototype
|
|
@@ -12,6 +12,37 @@
|
|
|
12
12
|
// highlighting, .md link rewriting with GFM-style heading ids (CJK-safe), and
|
|
13
13
|
// in-tree autolinking of inline-code .md mentions.
|
|
14
14
|
//
|
|
15
|
+
// PROPOSAL-077 A1 extends that baseline with long-field readability: cards
|
|
16
|
+
// holding a long field span the full grid row, long fields get prose spacing,
|
|
17
|
+
// and wall-length fields are clamped behind a pure-CSS expand toggle (print
|
|
18
|
+
// always fully expands). Layout-only — cell content is never split or
|
|
19
|
+
// reworded, and the page stays JavaScript-free.
|
|
20
|
+
//
|
|
21
|
+
// PROPOSAL-079: features/completed/ (append-only archive) is not inlined in
|
|
22
|
+
// the root index — it renders as one physical index page per year (grouped
|
|
23
|
+
// by the SPEC-YYYYMMDD directory-name prefix; non-conforming entries in an
|
|
24
|
+
// 'other' bucket), with the root tree carrying year links only. Every
|
|
25
|
+
// generated index page is a reserved output in the projection-collision
|
|
26
|
+
// guard.
|
|
27
|
+
//
|
|
28
|
+
// PROPOSAL-100: in a file named analysis.md, each filled `### LC-nn` /
|
|
29
|
+
// `### FL-nn` subsection also gets its lifecycle or flow drawn as inline SVG
|
|
30
|
+
// above its unchanged cards, or a one-line note saying why not
|
|
31
|
+
// (lib/render-diagrams.js). Every other page, and every analysis.md whose
|
|
32
|
+
// LC/FL tables are still the template's (placeholder rows only), renders
|
|
33
|
+
// exactly as before.
|
|
34
|
+
//
|
|
35
|
+
// PROPOSAL-101: on a recognised specs root (shared/_conventions.md is
|
|
36
|
+
// present) the root index is a grouped directory instead of the file tree —
|
|
37
|
+
// Features, Domain, architecture and migration, shared, everything else —
|
|
38
|
+
// each group a native <details> closed on first open (a lone group opens),
|
|
39
|
+
// with a purpose line, a collapsed reading guide and a few path-scoped role
|
|
40
|
+
// labels; year pages gain one orientation line and list each feature
|
|
41
|
+
// directory as a row (the same rows active/ gets). Any other --src keeps the
|
|
42
|
+
// file tree. On both sides, index and year-page hrefs percent-encode `%`,
|
|
43
|
+
// `#` and `?`. Mirror pages are untouched, and the page stays
|
|
44
|
+
// JavaScript-free.
|
|
45
|
+
//
|
|
15
46
|
// marked ships as ESM-only and `require(esm)` is not on by default until Node
|
|
16
47
|
// 22.12 (engines allows >=22.0.0), so this CommonJS module MUST load it via
|
|
17
48
|
// dynamic import('marked') — never via a require() call.
|
|
@@ -19,6 +50,8 @@
|
|
|
19
50
|
const fs = require('node:fs/promises');
|
|
20
51
|
const path = require('node:path');
|
|
21
52
|
|
|
53
|
+
const diagrams = require('./render-diagrams.js');
|
|
54
|
+
|
|
22
55
|
const DEFAULT_SRC = 'dflow/specs';
|
|
23
56
|
const DEFAULT_OUT = 'dflow-specs-html';
|
|
24
57
|
const DEFAULT_TITLE = 'dflow specs';
|
|
@@ -53,7 +86,110 @@ const STATUS_PILL = {
|
|
|
53
86
|
draft: 'neutral'
|
|
54
87
|
};
|
|
55
88
|
|
|
56
|
-
|
|
89
|
+
// Gherkin keyword highlighting reaches three contexts: ```gherkin/feature
|
|
90
|
+
// fences, untagged fences that LOOK like scenario blocks, and plain prose
|
|
91
|
+
// paragraphs whose lines are all keyword-led (the dominant OBTS behavior.md
|
|
92
|
+
// style — steps as ordinary text with two-space line breaks). The prose and
|
|
93
|
+
// untagged-fence paths require >= 2 keyword-led lines: a lone English prose
|
|
94
|
+
// paragraph starting with "When the …" must never light up.
|
|
95
|
+
const GHERKIN_KW_SRC = '(Scenario(?: Outline)?:|Background:|Examples:|Given|When|Then|And|But)';
|
|
96
|
+
const GHERKIN_KEYWORDS = new RegExp(`^(\\s*)${GHERKIN_KW_SRC}(?=[ \\t]|$)`, 'gm');
|
|
97
|
+
const GHERKIN_PROSE_LINE = new RegExp(`^(\\s*)${GHERKIN_KW_SRC}(?=[ \\t])`);
|
|
98
|
+
// Step keywords carry per-keyword classes (Given=setup, When=action,
|
|
99
|
+
// Then=assertion, And/But=continuation); block headers share kw-s.
|
|
100
|
+
const GHERKIN_KW_CLASS = { Given: 'kw-g', When: 'kw-w', Then: 'kw-t', And: 'kw-a', But: 'kw-a' };
|
|
101
|
+
|
|
102
|
+
function gherkinKwSpan(kw) {
|
|
103
|
+
const cls = kw.endsWith(':') ? 'kw-s' : GHERKIN_KW_CLASS[kw];
|
|
104
|
+
return `<span class="kw ${cls}">${kw}</span>`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function highlightGherkin(escapedText) {
|
|
108
|
+
return escapedText.replace(GHERKIN_KEYWORDS, (m, lead, kw) => `${lead}${gherkinKwSpan(kw)}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// >= 2 keyword-led lines -> treat an untagged fence as a scenario block.
|
|
112
|
+
function looksLikeGherkin(text) {
|
|
113
|
+
const hits = String(text).match(GHERKIN_KEYWORDS);
|
|
114
|
+
return hits !== null && hits.length >= 2;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Raw inline HTML can carry a newline or a literal <br> inside an attribute
|
|
118
|
+
// value, and the string-level line split below would then inject a keyword
|
|
119
|
+
// span into the middle of a tag (review visual-r1 F1). Any html token in
|
|
120
|
+
// the paragraph's inline tree disqualifies it from prose highlighting —
|
|
121
|
+
// codespans, links, and em/strong stay eligible (their rendered tags can
|
|
122
|
+
// never begin a split line without also failing the keyword-led test).
|
|
123
|
+
function hasRawHtmlToken(tokens) {
|
|
124
|
+
for (const token of tokens || []) {
|
|
125
|
+
if (token.type === 'html' || (token.tokens && hasRawHtmlToken(token.tokens))) {
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Split rendered inline HTML into [line, delim, line, …] at hard-break
|
|
133
|
+
// <br> tags and soft-break newlines that sit OUTSIDE any tag. Markdown
|
|
134
|
+
// link/image titles may span lines, so a newline can legally live inside
|
|
135
|
+
// a rendered title="…" attribute — splitting there would let the keyword
|
|
136
|
+
// wrapper inject a span mid-tag (review visual-r2 F1). Marked-rendered
|
|
137
|
+
// attributes are entity-escaped, so tag extents are exactly the unescaped
|
|
138
|
+
// '<'…'>' pairs; raw html tokens (where that guarantee fails) are already
|
|
139
|
+
// excluded by hasRawHtmlToken before this runs.
|
|
140
|
+
function splitProseLines(innerHtml) {
|
|
141
|
+
const html = String(innerHtml);
|
|
142
|
+
const parts = [];
|
|
143
|
+
let cur = '';
|
|
144
|
+
let inTag = false;
|
|
145
|
+
for (let i = 0; i < html.length; i++) {
|
|
146
|
+
const ch = html[i];
|
|
147
|
+
if (inTag) {
|
|
148
|
+
cur += ch;
|
|
149
|
+
if (ch === '>') {
|
|
150
|
+
inTag = false;
|
|
151
|
+
}
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (ch === '<') {
|
|
155
|
+
const br = /^<br\s*\/?>/i.exec(html.slice(i));
|
|
156
|
+
if (br) {
|
|
157
|
+
parts.push(cur, br[0]);
|
|
158
|
+
cur = '';
|
|
159
|
+
i += br[0].length - 1;
|
|
160
|
+
} else {
|
|
161
|
+
inTag = true;
|
|
162
|
+
cur += ch;
|
|
163
|
+
}
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (ch === '\n') {
|
|
167
|
+
parts.push(cur, '\n');
|
|
168
|
+
cur = '';
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
cur += ch;
|
|
172
|
+
}
|
|
173
|
+
parts.push(cur);
|
|
174
|
+
return parts;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Prose steps: a paragraph qualifies when it holds >= 2 non-empty lines
|
|
178
|
+
// (tag-aware split, see above) and EVERY one starts with a step/header
|
|
179
|
+
// keyword; then each line's keyword is wrapped — always at tag depth zero,
|
|
180
|
+
// because every segment starts outside a tag by construction. The
|
|
181
|
+
// every-line rule keeps hard-wrapped ordinary prose (where one wrapped
|
|
182
|
+
// line may start with "When …") from ever qualifying.
|
|
183
|
+
function highlightProseSteps(innerHtml) {
|
|
184
|
+
const parts = splitProseLines(innerHtml);
|
|
185
|
+
const lines = parts.filter((p, i) => i % 2 === 0 && p.trim() !== '');
|
|
186
|
+
if (lines.length < 2 || !lines.every((line) => GHERKIN_PROSE_LINE.test(line))) {
|
|
187
|
+
return innerHtml;
|
|
188
|
+
}
|
|
189
|
+
return parts
|
|
190
|
+
.map((p, i) => (i % 2 === 0 ? p.replace(GHERKIN_PROSE_LINE, (m, lead, kw) => `${lead}${gherkinKwSpan(kw)}`) : p))
|
|
191
|
+
.join('');
|
|
192
|
+
}
|
|
57
193
|
|
|
58
194
|
// All multi-column tables render as cards (user decision 2026-07-09 after two
|
|
59
195
|
// prototype demo rounds; no per-table heuristic). Degenerate single-column
|
|
@@ -64,6 +200,21 @@ const CAT_HEADERS = new Set([
|
|
|
64
200
|
'status', 'subdomain type', 'layer', 'severity', 'owner / team', 'producer'
|
|
65
201
|
]);
|
|
66
202
|
|
|
203
|
+
// PROPOSAL-077 A1 long-field thresholds, in plain-text code points of the
|
|
204
|
+
// cell (tags stripped; CJK counts 1 per character). LONG promotes the card
|
|
205
|
+
// to a full grid row plus prose line spacing — at ~24 CJK chars per line in
|
|
206
|
+
// a 330px card, 200 chars is 8+ rendered lines, beyond any convention-
|
|
207
|
+
// compliant summary. CLAMP additionally caps the field at CLAMP_LINES with
|
|
208
|
+
// a CSS-only expand toggle; 400 chars still exceeds CLAMP_LINES at full
|
|
209
|
+
// content width (~59 CJK chars per line), so the toggle only appears where
|
|
210
|
+
// clipping is near-certain. Latin text runs narrower per char, so a wide
|
|
211
|
+
// latin field can show a toggle whose expansion reveals little — accepted
|
|
212
|
+
// noise; thresholds are not script-aware by design. The row title and chip
|
|
213
|
+
// columns are exempt: titles are names by design, chips are ≤40 chars.
|
|
214
|
+
const LONG_FIELD_CHARS = 200;
|
|
215
|
+
const CLAMP_FIELD_CHARS = 400;
|
|
216
|
+
const CLAMP_LINES = 6;
|
|
217
|
+
|
|
67
218
|
const CSS = `
|
|
68
219
|
:root {
|
|
69
220
|
--bg:#f6f8f9; --surface:#ffffff; --frame:#eef2f4; --deep:#e7edef;
|
|
@@ -72,6 +223,7 @@ const CSS = `
|
|
|
72
223
|
--ok-fg:#1e6b34; --ok-bg:#e2f2e6; --ok-line:#bfe0c7;
|
|
73
224
|
--warn-fg:#8a5600; --warn-bg:#f7ecd7; --warn-line:#e8d5ac;
|
|
74
225
|
--del-fg:#9c3838; --del-bg:#f9e9e9; --del-line:#e8c4c4;
|
|
226
|
+
--head-strong:#0a4f47; --kw-then:#6d4fc2;
|
|
75
227
|
}
|
|
76
228
|
@media (prefers-color-scheme: dark) {
|
|
77
229
|
:root {
|
|
@@ -81,6 +233,7 @@ const CSS = `
|
|
|
81
233
|
--ok-fg:#7bd397; --ok-bg:#142d1b; --ok-line:#235c33;
|
|
82
234
|
--warn-fg:#e2b269; --warn-bg:#2f2510; --warn-line:#59461d;
|
|
83
235
|
--del-fg:#e08f8f; --del-bg:#331a1a; --del-line:#5c2727;
|
|
236
|
+
--head-strong:#7fd8ca; --kw-then:#b6a0f0;
|
|
84
237
|
}
|
|
85
238
|
}
|
|
86
239
|
* { box-sizing: border-box; }
|
|
@@ -102,13 +255,13 @@ pre {
|
|
|
102
255
|
padding: 0.85rem 1.1rem; overflow-x: auto; font-size: 12.8px; line-height: 1.85;
|
|
103
256
|
}
|
|
104
257
|
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; }
|
|
258
|
+
h1 { font-size: 25px; line-height: 1.35; margin: 0.2rem 0 1rem; color: var(--head-strong); }
|
|
106
259
|
h2 {
|
|
107
260
|
font-size: 19px; margin: 2.2rem 0 0.6rem; padding-top: 1.2rem;
|
|
108
|
-
border-top: 1px solid var(--line);
|
|
261
|
+
border-top: 1px solid var(--line); color: var(--head-strong);
|
|
109
262
|
}
|
|
110
|
-
h3 { font-size: 16.5px; margin: 1.6rem 0 0.4rem; }
|
|
111
|
-
h4 { font-size: 15px; margin: 1.2rem 0 0.3rem; }
|
|
263
|
+
h3 { font-size: 16.5px; margin: 1.6rem 0 0.4rem; color: var(--accent); }
|
|
264
|
+
h4 { font-size: 15px; margin: 1.2rem 0 0.3rem; color: var(--accent); }
|
|
112
265
|
blockquote {
|
|
113
266
|
margin: 0.8rem 0; padding: 0.55rem 1rem;
|
|
114
267
|
background: var(--accent-soft); border-left: 3px solid var(--accent);
|
|
@@ -149,7 +302,12 @@ tr:last-child td { border-bottom: none; }
|
|
|
149
302
|
content: ""; position: absolute; left: 4px; top: 1px; width: 3px; height: 7px;
|
|
150
303
|
border: solid var(--bg); border-width: 0 2px 2px 0; transform: rotate(45deg);
|
|
151
304
|
}
|
|
152
|
-
.kw {
|
|
305
|
+
.kw { font-weight: 700; }
|
|
306
|
+
.kw-s { color: var(--accent); }
|
|
307
|
+
.kw-g { color: var(--ok-fg); }
|
|
308
|
+
.kw-w { color: var(--warn-fg); }
|
|
309
|
+
.kw-t { color: var(--kw-then); }
|
|
310
|
+
.kw-a { color: var(--soft); }
|
|
153
311
|
.cards {
|
|
154
312
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
|
|
155
313
|
gap: 0.9rem; align-items: start; margin: 0.8rem 0 1.4rem;
|
|
@@ -176,6 +334,30 @@ tr:last-child td { border-bottom: none; }
|
|
|
176
334
|
margin-bottom: 0.05rem;
|
|
177
335
|
}
|
|
178
336
|
.fld-v { font-size: 14px; line-height: 1.7; overflow-wrap: anywhere; }
|
|
337
|
+
.card.wide { grid-column: 1 / -1; }
|
|
338
|
+
.fld-v.prose { line-height: 1.85; position: relative; }
|
|
339
|
+
.fxt { position: absolute; opacity: 0; width: 1px; height: 1px; margin: 0; pointer-events: none; }
|
|
340
|
+
.fxc { max-height: calc(${CLAMP_LINES} * 1.85em); overflow: hidden; position: relative; }
|
|
341
|
+
.fxc::after {
|
|
342
|
+
content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 2.4em;
|
|
343
|
+
background: linear-gradient(to bottom, transparent, var(--surface));
|
|
344
|
+
}
|
|
345
|
+
.fxt:checked ~ .fxc { max-height: none; }
|
|
346
|
+
.fxt:checked ~ .fxc::after { display: none; }
|
|
347
|
+
.fxl {
|
|
348
|
+
display: inline-block; margin: 0.35rem 0 0.1rem; padding: 2px 10px;
|
|
349
|
+
font-size: 11.5px; font-weight: 600; color: var(--accent);
|
|
350
|
+
background: var(--accent-soft); border: 1px solid var(--accent-line);
|
|
351
|
+
border-radius: 999px; cursor: pointer; user-select: none;
|
|
352
|
+
}
|
|
353
|
+
.fxl .fxs { display: none; }
|
|
354
|
+
.fxt:checked ~ .fxl .fxm { display: none; }
|
|
355
|
+
.fxt:checked ~ .fxl .fxs { display: inline; }
|
|
356
|
+
.fxt:focus-visible ~ .fxl { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
357
|
+
@media print {
|
|
358
|
+
.fxc { max-height: none; }
|
|
359
|
+
.fxc::after, .fxl, .fxt { display: none; }
|
|
360
|
+
}
|
|
179
361
|
.crumb { font-size: 12px; color: var(--faint); font-family: Consolas, monospace; margin: 0 0 1rem; }
|
|
180
362
|
.crumb a { color: var(--faint); }
|
|
181
363
|
.meta-card {
|
|
@@ -190,11 +372,39 @@ tr:last-child td { border-bottom: none; }
|
|
|
190
372
|
margin-top: 3rem; padding-top: 0.9rem; border-top: 1px solid var(--line);
|
|
191
373
|
color: var(--faint); font-size: 12px; font-family: Consolas, monospace;
|
|
192
374
|
}
|
|
193
|
-
ul.tree, ul.tree ul { list-style: none;
|
|
375
|
+
ul.tree, ul.tree ul { list-style: none; line-height: 2; font-size: 13.5px; }
|
|
194
376
|
ul.tree { padding-left: 0; }
|
|
195
|
-
ul.tree
|
|
196
|
-
ul.tree
|
|
377
|
+
ul.tree ul { padding-left: 1.35rem; margin: 0; }
|
|
378
|
+
ul.tree .dir { color: var(--faint); font-weight: 600; }
|
|
379
|
+
ul.tree .years { font-size: 13px; color: var(--faint); }
|
|
380
|
+
ul.tree .years a { font-weight: 600; }
|
|
381
|
+
.yearnav { font-size: 13.5px; color: var(--faint); margin: -0.3rem 0 1.1rem; font-family: Consolas, monospace; }
|
|
382
|
+
.yearnav a { text-decoration: none; font-weight: 600; }
|
|
383
|
+
.yearnav strong { color: var(--ink); }
|
|
384
|
+
ul.tree a { text-decoration: none; font-family: Consolas, monospace; }
|
|
197
385
|
ul.tree a:hover { text-decoration: underline; }
|
|
386
|
+
ul.tree li { position: relative; }
|
|
387
|
+
ul.tree ul li::before {
|
|
388
|
+
content: ""; position: absolute; left: -0.85rem; top: 0; height: 100%;
|
|
389
|
+
border-left: 1px solid var(--line);
|
|
390
|
+
}
|
|
391
|
+
ul.tree ul li:last-child::before { height: 1em; }
|
|
392
|
+
ul.tree ul li::after {
|
|
393
|
+
content: ""; position: absolute; left: -0.85rem; top: 1em; width: 0.6rem;
|
|
394
|
+
border-top: 1px solid var(--line);
|
|
395
|
+
}
|
|
396
|
+
ul.tree .dir::before, ul.tree li > a::before {
|
|
397
|
+
content: ""; display: inline-block; width: 15px; height: 15px;
|
|
398
|
+
margin-right: 0.45em; vertical-align: -0.2em; background: currentColor;
|
|
399
|
+
}
|
|
400
|
+
ul.tree .dir::before {
|
|
401
|
+
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M1.5 4A1.5 1.5 0 0 1 3 2.5h3.1l1.6 2H13A1.5 1.5 0 0 1 14.5 6v6.5A1.5 1.5 0 0 1 13 14H3a1.5 1.5 0 0 1-1.5-1.5z'/%3E%3C/svg%3E") center/contain no-repeat;
|
|
402
|
+
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M1.5 4A1.5 1.5 0 0 1 3 2.5h3.1l1.6 2H13A1.5 1.5 0 0 1 14.5 6v6.5A1.5 1.5 0 0 1 13 14H3a1.5 1.5 0 0 1-1.5-1.5z'/%3E%3C/svg%3E") center/contain no-repeat;
|
|
403
|
+
}
|
|
404
|
+
ul.tree li > a::before {
|
|
405
|
+
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4 1.5h5.4L13.5 5.6V13A1.5 1.5 0 0 1 12 14.5H4A1.5 1.5 0 0 1 2.5 13V3A1.5 1.5 0 0 1 4 1.5zm5.4 0v4.1h4.1z'/%3E%3C/svg%3E") center/contain no-repeat;
|
|
406
|
+
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4 1.5h5.4L13.5 5.6V13A1.5 1.5 0 0 1 12 14.5H4A1.5 1.5 0 0 1 2.5 13V3A1.5 1.5 0 0 1 4 1.5zm5.4 0v4.1h4.1z'/%3E%3C/svg%3E") center/contain no-repeat;
|
|
407
|
+
}
|
|
198
408
|
`;
|
|
199
409
|
|
|
200
410
|
// ------------------------------------------------------------------ file IO
|
|
@@ -342,12 +552,21 @@ function plainTableHtml(headerCells, rows) {
|
|
|
342
552
|
// pill styling); remaining columns -> stacked label+value fields, with in-cell
|
|
343
553
|
// <br> line breaks preserved verbatim. Empty cells are omitted. Degenerate
|
|
344
554
|
// single-column tables keep the plain-table rendering.
|
|
345
|
-
|
|
555
|
+
//
|
|
556
|
+
// Long fields (PROPOSAL-077 A1): a field at LONG_FIELD_CHARS+ makes its card
|
|
557
|
+
// span the full grid row and gets prose spacing; at CLAMP_FIELD_CHARS+ the
|
|
558
|
+
// field is additionally clamped behind a checkbox-driven CSS toggle (the
|
|
559
|
+
// checkbox stays focusable for keyboard use; print CSS removes the clamp).
|
|
560
|
+
// The field's HTML is emitted unmodified inside the clamp wrapper — never
|
|
561
|
+
// split, reflowed, or reworded. nextToggleId mints document-unique ids for
|
|
562
|
+
// the checkbox/label pairs.
|
|
563
|
+
function cardsHtml(headerCells, rows, nextToggleId) {
|
|
346
564
|
const cards = rows.map((cells) => {
|
|
347
565
|
const plains = cells.map(stripTags);
|
|
348
566
|
const title = plains[0] ? cells[0] : '(未命名)';
|
|
349
567
|
const chips = [];
|
|
350
568
|
const fields = [];
|
|
569
|
+
let hasLongField = false;
|
|
351
570
|
for (let i = 1; i < headerCells.length; i++) {
|
|
352
571
|
const cell = cells[i] ?? '';
|
|
353
572
|
const text = plains[i] ?? '';
|
|
@@ -363,22 +582,41 @@ function cardsHtml(headerCells, rows) {
|
|
|
363
582
|
chips.push(`<span class="chip cat">${headerCells[i]}: ${cell}</span>`);
|
|
364
583
|
}
|
|
365
584
|
} else {
|
|
585
|
+
const chars = [...text].length;
|
|
586
|
+
let valueHtml = cell;
|
|
587
|
+
let valueClass = 'fld-v';
|
|
588
|
+
if (chars >= LONG_FIELD_CHARS) {
|
|
589
|
+
hasLongField = true;
|
|
590
|
+
valueClass = 'fld-v prose';
|
|
591
|
+
if (chars >= CLAMP_FIELD_CHARS) {
|
|
592
|
+
const id = nextToggleId();
|
|
593
|
+
valueHtml =
|
|
594
|
+
`<input type="checkbox" class="fxt" id="${id}">` +
|
|
595
|
+
`<div class="fxc">${cell}</div>` +
|
|
596
|
+
`<label class="fxl" for="${id}">` +
|
|
597
|
+
'<span class="fxm">展開全文 ▾</span><span class="fxs">收合 ▴</span></label>';
|
|
598
|
+
}
|
|
599
|
+
}
|
|
366
600
|
fields.push(
|
|
367
601
|
`<div class="fld"><span class="fld-k">${headerCells[i]}</span>` +
|
|
368
|
-
`<div class="
|
|
602
|
+
`<div class="${valueClass}">${valueHtml}</div></div>`
|
|
369
603
|
);
|
|
370
604
|
}
|
|
371
605
|
}
|
|
372
606
|
const chipsHtml = chips.length ? `<div class="card-chips">${chips.join('')}</div>` : '';
|
|
373
|
-
return `<article class="card"><div class="card-title">${title}</div>` +
|
|
607
|
+
return `<article class="card${hasLongField ? ' wide' : ''}"><div class="card-title">${title}</div>` +
|
|
374
608
|
`${chipsHtml}<div class="card-fields">${fields.join('')}</div></article>`;
|
|
375
609
|
});
|
|
376
610
|
return `<div class="cards">${cards.join('')}</div>\n`;
|
|
377
611
|
}
|
|
378
612
|
|
|
379
613
|
// Per-file renderer overrides. `seen` carries the per-document heading slug
|
|
380
|
-
// dedup state, so a fresh renderer is built for every file.
|
|
614
|
+
// dedup state, so a fresh renderer is built for every file. toggleSeq mints
|
|
615
|
+
// the per-document ids for long-field clamp toggles; the `fldx-` prefix
|
|
616
|
+
// keeps them out of the heading-slug namespace (slugs never contain a
|
|
617
|
+
// hyphenated `fldx-N` unless a heading literally says so).
|
|
381
618
|
function buildRenderer(seen) {
|
|
619
|
+
let toggleSeq = 0;
|
|
382
620
|
return {
|
|
383
621
|
heading(token) {
|
|
384
622
|
const inner = this.parser.parseInline(token.tokens);
|
|
@@ -401,17 +639,27 @@ function buildRenderer(seen) {
|
|
|
401
639
|
return `<a href="${escapeHtml(href)}"${title}>${inner}</a>`;
|
|
402
640
|
},
|
|
403
641
|
|
|
404
|
-
// Fenced code; gherkin/feature blocks get keyword highlighting
|
|
642
|
+
// Fenced code; gherkin/feature blocks get keyword highlighting, and an
|
|
643
|
+
// untagged fence that reads as a scenario block (>= 2 keyword-led lines)
|
|
644
|
+
// is highlighted the same way — other-language fences never are.
|
|
405
645
|
code(token) {
|
|
406
646
|
const lang = String(token.lang || '').trim().split(/\s+/)[0];
|
|
407
647
|
let escaped = escapeHtml(token.text);
|
|
408
|
-
if (lang === 'gherkin' || lang === 'feature') {
|
|
409
|
-
escaped = escaped
|
|
648
|
+
if (lang === 'gherkin' || lang === 'feature' || (!lang && looksLikeGherkin(token.text))) {
|
|
649
|
+
escaped = highlightGherkin(escaped);
|
|
410
650
|
}
|
|
411
651
|
const cls = lang ? ` class="language-${escapeHtml(lang)}"` : '';
|
|
412
652
|
return `<pre><code${cls}>${escaped}\n</code></pre>\n`;
|
|
413
653
|
},
|
|
414
654
|
|
|
655
|
+
// Prose scenario steps (keyword-led <br>-separated lines) get the same
|
|
656
|
+
// keyword colors; ordinary paragraphs pass through marked's default
|
|
657
|
+
// shape, and paragraphs holding raw inline HTML are never rewritten.
|
|
658
|
+
paragraph(token) {
|
|
659
|
+
const inner = this.parser.parseInline(token.tokens);
|
|
660
|
+
return `<p>${hasRawHtmlToken(token.tokens) ? inner : highlightProseSteps(inner)}</p>\n`;
|
|
661
|
+
},
|
|
662
|
+
|
|
415
663
|
// Task-list checkboxes -> styled spans (same look as the prototype; the
|
|
416
664
|
// trailing space restores the gap marked v18 no longer inserts itself).
|
|
417
665
|
checkbox(token) {
|
|
@@ -427,7 +675,7 @@ function buildRenderer(seen) {
|
|
|
427
675
|
if (headerCells.length < 2 || rows.length === 0) {
|
|
428
676
|
return plainTableHtml(headerCells, rows);
|
|
429
677
|
}
|
|
430
|
-
return cardsHtml(headerCells, rows);
|
|
678
|
+
return cardsHtml(headerCells, rows, () => `fldx-${toggleSeq++}`);
|
|
431
679
|
}
|
|
432
680
|
};
|
|
433
681
|
}
|
|
@@ -540,7 +788,7 @@ function metaCardHtml(meta, fallbackTitle) {
|
|
|
540
788
|
|
|
541
789
|
// ----------------------------------------------------------------- rendering
|
|
542
790
|
|
|
543
|
-
function pageHtml({ title, crumb, metaCard, body, footer }) {
|
|
791
|
+
function pageHtml({ title, crumb, metaCard, body, footer, extraCss = '' }) {
|
|
544
792
|
return `<!DOCTYPE html>
|
|
545
793
|
${GENERATED_MARK}
|
|
546
794
|
<html lang="zh-Hant">
|
|
@@ -548,7 +796,7 @@ ${GENERATED_MARK}
|
|
|
548
796
|
<meta charset="utf-8">
|
|
549
797
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
550
798
|
<title>${title}</title>
|
|
551
|
-
<style>${CSS}</style>
|
|
799
|
+
<style>${CSS}${extraCss}</style>
|
|
552
800
|
</head>
|
|
553
801
|
<body>
|
|
554
802
|
<main>
|
|
@@ -562,18 +810,33 @@ ${body}
|
|
|
562
810
|
`;
|
|
563
811
|
}
|
|
564
812
|
|
|
565
|
-
async function renderFile({ MarkedCtor, srcRoot, outRoot, relPosix, stamp, tree, basenames }) {
|
|
813
|
+
async function renderFile({ MarkedCtor, srcRoot, outRoot, relPosix, stamp, tree, basenames, diagramStats }) {
|
|
566
814
|
const relHtml = `${relPosix.slice(0, -'.md'.length)}.html`;
|
|
567
815
|
const mdPath = path.join(srcRoot, ...relPosix.split('/'));
|
|
568
816
|
const outPath = path.join(outRoot, ...relHtml.split('/'));
|
|
817
|
+
const parts = relPosix.split('/');
|
|
569
818
|
|
|
570
819
|
const { meta, body } = splitFrontmatter(await readText(mdPath));
|
|
571
820
|
const seen = new Map();
|
|
572
|
-
const
|
|
821
|
+
const options = { gfm: true, renderer: buildRenderer(seen) };
|
|
822
|
+
// PROPOSAL-100: only analysis.md is searched for LC-/FL- subsections; the
|
|
823
|
+
// hook inserts each picture or note as its own block before the
|
|
824
|
+
// subsection's first recognised table, so the cards are built exactly as
|
|
825
|
+
// on every other page.
|
|
826
|
+
const diagramPage = parts[parts.length - 1] === 'analysis.md' ? diagrams.newPageState() : null;
|
|
827
|
+
if (diagramPage) {
|
|
828
|
+
options.hooks = { processAllTokens: (tokens) => diagrams.insertDiagrams(tokens, diagramPage) };
|
|
829
|
+
}
|
|
830
|
+
const marked = new MarkedCtor(options);
|
|
573
831
|
let bodyHtml = marked.parse(preprocess(body));
|
|
574
832
|
bodyHtml = autolinkCodeMentions(bodyHtml, relPosix, tree, basenames);
|
|
575
833
|
|
|
576
|
-
const
|
|
834
|
+
const hasDiagramBlock = diagramPage !== null && diagramPage.drawn + diagramPage.notDrawn > 0;
|
|
835
|
+
if (hasDiagramBlock) {
|
|
836
|
+
diagramStats.drawn += diagramPage.drawn;
|
|
837
|
+
diagramStats.notDrawn += diagramPage.notDrawn;
|
|
838
|
+
}
|
|
839
|
+
|
|
577
840
|
const stem = parts[parts.length - 1].slice(0, -'.md'.length);
|
|
578
841
|
const indexHref = '../'.repeat(parts.length - 1) + 'index.html';
|
|
579
842
|
const crumb = `<a href="${indexHref}">specs</a> / ` + parts.map(escapeHtml).join(' / ');
|
|
@@ -584,55 +847,567 @@ async function renderFile({ MarkedCtor, srcRoot, outRoot, relPosix, stamp, tree,
|
|
|
584
847
|
crumb,
|
|
585
848
|
metaCard: metaCardHtml(meta, stem),
|
|
586
849
|
body: bodyHtml,
|
|
587
|
-
footer
|
|
850
|
+
footer,
|
|
851
|
+
extraCss: hasDiagramBlock ? diagrams.DIAGRAM_CSS : ''
|
|
588
852
|
}));
|
|
589
853
|
return relHtml;
|
|
590
854
|
}
|
|
591
855
|
|
|
856
|
+
// PROPOSAL-079: features/completed/ is a monotonically growing archive, so
|
|
857
|
+
// the root index must not inline it. Entries under the prefix group by the
|
|
858
|
+
// year in their SPEC directory name; anything non-conforming lands in the
|
|
859
|
+
// 'other' bucket (still reachable, never dropped). Grouping is mechanical —
|
|
860
|
+
// directory names only, no frontmatter parsing. Returns null when the tree
|
|
861
|
+
// holds nothing under the prefix (then no pagination happens at all).
|
|
862
|
+
const COMPLETED_PREFIX = 'features/completed/';
|
|
863
|
+
const SPEC_YEAR_RE = /^SPEC-(\d{4})\d{4,}-/;
|
|
864
|
+
|
|
865
|
+
function completedIndexRel(key) {
|
|
866
|
+
return `${COMPLETED_PREFIX}index-${key}.html`;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function completedKeyLabel(key) {
|
|
870
|
+
return key === 'other' ? '未分年' : key;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Sort keys: years newest-first, the 'other' bucket always last.
|
|
874
|
+
function completedKeyOrder(keys) {
|
|
875
|
+
return [...keys].sort((a, b) => {
|
|
876
|
+
if (a === 'other') return 1;
|
|
877
|
+
if (b === 'other') return -1;
|
|
878
|
+
return b.localeCompare(a);
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function groupCompletedByYear(relFiles) {
|
|
883
|
+
const groups = new Map(); // key -> { files: [rel…], units: Set<top-level name> }
|
|
884
|
+
for (const rel of relFiles) {
|
|
885
|
+
if (!rel.startsWith(COMPLETED_PREFIX)) {
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
const rest = rel.slice(COMPLETED_PREFIX.length);
|
|
889
|
+
const top = rest.split('/')[0];
|
|
890
|
+
const match = rest.includes('/') ? SPEC_YEAR_RE.exec(top) : null;
|
|
891
|
+
const key = match ? match[1] : 'other';
|
|
892
|
+
if (!groups.has(key)) {
|
|
893
|
+
groups.set(key, { files: [], units: new Set() });
|
|
894
|
+
}
|
|
895
|
+
groups.get(key).files.push(rel);
|
|
896
|
+
groups.get(key).units.add(top);
|
|
897
|
+
}
|
|
898
|
+
return groups.size ? groups : null;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// PROPOSAL-101: every href on the root index and the year pages goes
|
|
902
|
+
// through here. Inside a relative path only `%` (escape), `#` (fragment)
|
|
903
|
+
// and `?` (query) change what a browser resolves — `a#b.html` points at
|
|
904
|
+
// `a` — so those three are percent-encoded; everything else (CJK, spaces,
|
|
905
|
+
// `&`) stays literal, and a path holding none of the three comes out
|
|
906
|
+
// byte-identical. Callers still escapeHtml() the result for the attribute.
|
|
907
|
+
const HREF_ESCAPES = { '%': '%25', '#': '%23', '?': '%3F' };
|
|
908
|
+
|
|
909
|
+
function indexHref(relPath) {
|
|
910
|
+
return relPath.replace(/[%#?]/g, (ch) => HREF_ESCAPES[ch]);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// Root-index year links for the completed/ stub line. Hrefs are root-
|
|
914
|
+
// relative; the surrounding <span> keeps them out of the `li > a` file-icon
|
|
915
|
+
// selector.
|
|
916
|
+
function completedYearLinksHtml(groups) {
|
|
917
|
+
const links = completedKeyOrder(groups.keys()).map((key) => {
|
|
918
|
+
const g = groups.get(key);
|
|
919
|
+
return `<a href="${escapeHtml(indexHref(completedIndexRel(key)))}">${escapeHtml(completedKeyLabel(key))} (${g.units.size})</a>`;
|
|
920
|
+
});
|
|
921
|
+
return `<span class="years">${links.join(' · ')}</span>`;
|
|
922
|
+
}
|
|
923
|
+
|
|
592
924
|
// Nested <ul> file tree with links to the mirrored .html files.
|
|
593
|
-
|
|
594
|
-
|
|
925
|
+
// opts.stubs: [{ dirParts, html }] — pseudo-directory lines injected among
|
|
926
|
+
// a node's subdirectories (used for the completed/ year-links line; the
|
|
927
|
+
// node is created even when no other file implies it). opts.topDirsDesc:
|
|
928
|
+
// sort depth-0 directories newest-first (completed year pages).
|
|
929
|
+
function buildTreeHtml(relFiles, opts = {}) {
|
|
930
|
+
const stubs = opts.stubs || [];
|
|
931
|
+
const root = { dirs: new Map(), files: [], stubs: new Map() };
|
|
932
|
+
const ensure = (node, part) => {
|
|
933
|
+
if (!node.dirs.has(part)) {
|
|
934
|
+
node.dirs.set(part, { dirs: new Map(), files: [], stubs: new Map() });
|
|
935
|
+
}
|
|
936
|
+
return node.dirs.get(part);
|
|
937
|
+
};
|
|
595
938
|
for (const rel of [...relFiles].sort()) {
|
|
596
939
|
const parts = rel.split('/');
|
|
597
940
|
let node = root;
|
|
598
941
|
for (const part of parts.slice(0, -1)) {
|
|
599
|
-
|
|
600
|
-
node.dirs.set(part, { dirs: new Map(), files: [] });
|
|
601
|
-
}
|
|
602
|
-
node = node.dirs.get(part);
|
|
942
|
+
node = ensure(node, part);
|
|
603
943
|
}
|
|
604
944
|
node.files.push(rel);
|
|
605
945
|
}
|
|
946
|
+
for (const stub of stubs) {
|
|
947
|
+
let node = root;
|
|
948
|
+
for (const part of stub.dirParts.slice(0, -1)) {
|
|
949
|
+
node = ensure(node, part);
|
|
950
|
+
}
|
|
951
|
+
node.stubs.set(stub.dirParts[stub.dirParts.length - 1], stub.html);
|
|
952
|
+
}
|
|
606
953
|
|
|
607
|
-
function emit(node) {
|
|
954
|
+
function emit(node, depth) {
|
|
608
955
|
let out = '<ul class="tree">';
|
|
609
|
-
|
|
610
|
-
|
|
956
|
+
const names = [...new Set([...node.dirs.keys(), ...node.stubs.keys()])].sort();
|
|
957
|
+
if (depth === 0 && opts.topDirsDesc) {
|
|
958
|
+
names.reverse();
|
|
959
|
+
}
|
|
960
|
+
for (const name of names) {
|
|
961
|
+
if (node.stubs.has(name)) {
|
|
962
|
+
out += `<li><span class="dir">${escapeHtml(name)}/</span> ${node.stubs.get(name)}</li>`;
|
|
963
|
+
} else {
|
|
964
|
+
out += `<li><span class="dir">${escapeHtml(name)}/</span>${emit(node.dirs.get(name), depth + 1)}</li>`;
|
|
965
|
+
}
|
|
611
966
|
}
|
|
612
967
|
for (const rel of node.files) {
|
|
613
|
-
const href = `${rel.slice(0, -'.md'.length)}.html
|
|
968
|
+
const href = indexHref(`${rel.slice(0, -'.md'.length)}.html`);
|
|
614
969
|
const name = rel.split('/').pop();
|
|
615
970
|
out += `<li><a href="${escapeHtml(href)}">${escapeHtml(name)}</a></li>`;
|
|
616
971
|
}
|
|
617
972
|
return `${out}</ul>`;
|
|
618
973
|
}
|
|
619
974
|
|
|
620
|
-
return emit(root);
|
|
975
|
+
return emit(root, 0);
|
|
621
976
|
}
|
|
622
977
|
|
|
623
|
-
|
|
624
|
-
|
|
978
|
+
// ---------------------------------------------------------- grouped index
|
|
979
|
+
// PROPOSAL-101: when --src is recognisably a Dflow specs root — it holds
|
|
980
|
+
// shared/_conventions.md, which dflow init always creates; presence only,
|
|
981
|
+
// the content is never read — the root index becomes a grouped directory:
|
|
982
|
+
// a header, jump links, and one native <details> per group, all closed on
|
|
983
|
+
// first open (an only group opens). Grouping, order, role labels and guide
|
|
984
|
+
// text come from paths alone, never from document content, and all live in
|
|
985
|
+
// the registry below. Any other --src keeps the pre-P101 file tree and year
|
|
986
|
+
// pages, changed only by indexHref(): a top-level directory that merely
|
|
987
|
+
// happens to be called `features` or `shared` is no evidence that Dflow's
|
|
988
|
+
// conventions hold there.
|
|
989
|
+
const SPECS_ROOT_MARKER = 'shared/_conventions.md';
|
|
990
|
+
const OVERVIEW_REL = 'shared/_overview.md';
|
|
991
|
+
|
|
992
|
+
// The groups, in page order. `dirs: null` collects root files and every
|
|
993
|
+
// top-level directory no other group claims. A guide line with a `when`
|
|
994
|
+
// test shows only when that test finds what the line talks about.
|
|
995
|
+
const hasFile = (target) => (rels) => rels.includes(target);
|
|
996
|
+
const hasMatch = (re) => (rels) => rels.some((rel) => re.test(rel));
|
|
997
|
+
|
|
998
|
+
const INDEX_GROUPS = [
|
|
999
|
+
{
|
|
1000
|
+
id: 'features',
|
|
1001
|
+
name: 'Features',
|
|
1002
|
+
dirs: ['features'],
|
|
1003
|
+
purpose: '每一次變更的規格與紀錄:進行中、backlog 與已完成',
|
|
1004
|
+
guide: [
|
|
1005
|
+
{ html: '一個目錄是一個 feature:<code>_index.md</code> 是它的總覽(目標、範圍、各階段規格與目前的業務規則快照),<code>phase-spec-*.md</code> 是一個階段的完整規格,<code>lightweight-*.md</code> 與 <code>BUG-*.md</code> 是小改動的規格。', when: hasMatch(/^features\/(active|completed)\/[^/]+\//) },
|
|
1006
|
+
{ html: '<code>active/</code> 慣例上放還在進行的 feature。', when: hasMatch(/^features\/active\//) },
|
|
1007
|
+
{ html: '<code>backlog/</code> 放先記下來的想法或筆記;放在這裡不代表已經排定或核准,要打開看內容才知道還適不適用。', when: hasMatch(/^features\/backlog\//) },
|
|
1008
|
+
{ html: '<code>completed/</code> 是已完成 feature 的歷史紀錄,依年份分頁;系統現在的行為以 Domain 那一組的文件為準。', when: hasMatch(/^features\/completed\//) }
|
|
1009
|
+
]
|
|
1010
|
+
},
|
|
1011
|
+
{
|
|
1012
|
+
id: 'domain',
|
|
1013
|
+
name: 'Domain',
|
|
1014
|
+
dirs: ['domain'],
|
|
1015
|
+
purpose: '系統知識:各 context 的模型、規則、行為與分析',
|
|
1016
|
+
guide: [
|
|
1017
|
+
{ html: '「跨 context」那一列是 <code>domain/</code> 直接底下的文件:<code>context-map.md</code> 看各 context 的分工與關係,<code>glossary.md</code> 看用語的定義,<code>analysis.md</code> 記跨 context 的流程、功能與角色的對照,以及不只屬於一個 context 的機制。', when: hasMatch(/^domain\/[^/]+$/) },
|
|
1018
|
+
{ html: '其後每一列是 <code>domain/</code> 底下的一個目錄,慣例上一個目錄是一個 bounded context:<code>context-definition.md</code>(或 <code>context.md</code>)講它負責什麼、不負責什麼,<code>models.md</code> 列它的模型,<code>rules.md</code> 是業務規則的索引(BR-ID),<code>behavior.md</code> 用情境寫出目前的行為,<code>events.md</code> 列領域事件,<code>analysis.md</code> 記這個 context 自己的狀態怎麼流轉、數字怎麼算、機制怎麼運作。', when: hasMatch(/^domain\/[^/]+\//) }
|
|
1019
|
+
]
|
|
1020
|
+
},
|
|
1021
|
+
{
|
|
1022
|
+
id: 'arch',
|
|
1023
|
+
name: '架構與遷移',
|
|
1024
|
+
dirs: ['architecture', 'migration'],
|
|
1025
|
+
purpose: '技術債、架構決策與遷移的紀錄',
|
|
1026
|
+
guide: [
|
|
1027
|
+
{ html: '<code>tech-debt.md</code> 記開發途中發現、留待之後處理的技術債。', when: hasMatch(/^(architecture|migration)\/tech-debt\.md$/) },
|
|
1028
|
+
{ html: '<code>migration/</code> 底下記的是往目標架構遷移的事。', when: hasMatch(/^migration\//) },
|
|
1029
|
+
{ html: '<code>architecture/decisions/</code> 放架構決策紀錄(ADR),一份文件記一個決定。', when: hasMatch(/^architecture\/decisions\//) }
|
|
1030
|
+
]
|
|
1031
|
+
},
|
|
1032
|
+
{
|
|
1033
|
+
id: 'shared',
|
|
1034
|
+
name: '共用文件',
|
|
1035
|
+
dirs: ['shared'],
|
|
1036
|
+
purpose: '專案層級的總覽、撰寫慣例與 AI 協作指引',
|
|
1037
|
+
guide: [
|
|
1038
|
+
{ html: '<code>_overview.md</code> 是這個系統的總覽,第一次讀從它開始。', when: hasFile('shared/_overview.md') },
|
|
1039
|
+
{ html: '<code>_conventions.md</code> 記這個專案寫規格的慣例。', when: hasFile('shared/_conventions.md') },
|
|
1040
|
+
{ html: '<code>Git-principles-*.md</code> 記這個專案的 Git 分支與提交慣例。', when: hasMatch(/^shared\/Git-principles-[^/]*\.md$/) },
|
|
1041
|
+
{ html: '<code>AI-AGENT-GUIDE.md</code> 是給 AI 助手的工作指引。', when: hasFile('shared/AI-AGENT-GUIDE.md') },
|
|
1042
|
+
{ html: '<code>dflow-workflows/</code> 是 Dflow 放進來的流程文件與空白範本,由 Dflow 更新,不是這個專案自己寫的文件。', when: hasMatch(/^shared\/dflow-workflows\//) }
|
|
1043
|
+
]
|
|
1044
|
+
},
|
|
1045
|
+
{
|
|
1046
|
+
id: 'other',
|
|
1047
|
+
name: '其他',
|
|
1048
|
+
dirs: null,
|
|
1049
|
+
purpose: '根目錄的文件與其他頂層目錄,照目錄層級列出',
|
|
1050
|
+
guide: null
|
|
1051
|
+
}
|
|
1052
|
+
];
|
|
1053
|
+
|
|
1054
|
+
// Recognised filenames list first, in this order; every other file follows
|
|
1055
|
+
// by name. Absent files leave no gap and no hint.
|
|
1056
|
+
const DOMAIN_ROOT_ORDER = ['context-map.md', 'glossary.md', 'analysis.md'];
|
|
1057
|
+
const CONTEXT_ORDER = [
|
|
1058
|
+
'context-definition.md', 'context.md', 'analysis.md', 'models.md', 'rules.md', 'behavior.md', 'events.md'
|
|
1059
|
+
];
|
|
1060
|
+
|
|
1061
|
+
// Role labels by exact path shape (`*` = any one segment). A path matching
|
|
1062
|
+
// no shape shows its filename alone — the index never guesses, so a
|
|
1063
|
+
// template analysis.md under shared/ is never labelled as this project's.
|
|
1064
|
+
const ROLE_LABELS = [
|
|
1065
|
+
{ shape: ['domain', 'analysis.md'], label: '跨 context 分析' },
|
|
1066
|
+
{ shape: ['domain', '*', 'analysis.md'], label: 'context 分析' },
|
|
1067
|
+
{ shape: ['features', 'active', '*', '_index.md'], label: 'feature 總覽' }
|
|
1068
|
+
];
|
|
1069
|
+
// Same idea for a directory's disclosure summary.
|
|
1070
|
+
const DIR_LABELS = [
|
|
1071
|
+
{ shape: ['shared', 'dflow-workflows'], label: 'Dflow 放進來的流程文件與範本' }
|
|
1072
|
+
];
|
|
1073
|
+
|
|
1074
|
+
const INDEX_CSS = `
|
|
1075
|
+
.ix-head { color: var(--soft); font-size: 14px; margin: -0.4rem 0 0.7rem; }
|
|
1076
|
+
.ix-jump { color: var(--faint); font-size: 14px; margin: 0 0 1.2rem; }
|
|
1077
|
+
.ix-jump a { font-weight: 600; text-decoration: none; }
|
|
1078
|
+
.ix-group {
|
|
1079
|
+
background: var(--surface); border: 1px solid var(--line);
|
|
1080
|
+
border-radius: 10px; margin: 0 0 0.7rem;
|
|
1081
|
+
}
|
|
1082
|
+
.ix-group > summary { cursor: pointer; padding: 0.6rem 1rem; line-height: 1.55; }
|
|
1083
|
+
.ix-group[open] > summary { border-bottom: 1px solid var(--line); }
|
|
1084
|
+
.ix-name { font-size: 17px; font-weight: 700; color: var(--head-strong); }
|
|
1085
|
+
.ix-dirs { font-family: Consolas, monospace; font-size: 12.5px; color: var(--faint); margin-left: 0.25em; }
|
|
1086
|
+
.ix-count { font-size: 12.5px; color: var(--soft); margin-left: 0.25em; white-space: nowrap; }
|
|
1087
|
+
.ix-purpose { display: block; font-size: 13.5px; color: var(--soft); }
|
|
1088
|
+
.ix-body { padding: 0.55rem 1rem 0.85rem; }
|
|
1089
|
+
.ix-body, .ix-year { font-size: 13.5px; }
|
|
1090
|
+
.ix-body a, .ix-head a, .ix-year a { font-family: Consolas, monospace; text-decoration: none; overflow-wrap: anywhere; }
|
|
1091
|
+
.ix-body a:hover, .ix-head a:hover, .ix-year a:hover { text-decoration: underline; }
|
|
1092
|
+
.ix-guide {
|
|
1093
|
+
background: var(--accent-soft); border: 1px solid var(--accent-line);
|
|
1094
|
+
border-radius: 8px; padding: 0.3rem 0.8rem; margin: 0 0 0.7rem;
|
|
1095
|
+
}
|
|
1096
|
+
.ix-guide > summary { cursor: pointer; color: var(--accent); font-weight: 600; }
|
|
1097
|
+
.ix-guide ul { margin: 0.35rem 0 0.3rem; padding-left: 1.2rem; }
|
|
1098
|
+
.ix-body h3 {
|
|
1099
|
+
font-family: Consolas, monospace; font-size: 13.5px; color: var(--soft);
|
|
1100
|
+
margin: 0.9rem 0 0.2rem;
|
|
1101
|
+
}
|
|
1102
|
+
.ix-list { list-style: none; padding-left: 0; margin: 0.15rem 0 0.3rem; line-height: 1.9; }
|
|
1103
|
+
.ix-rows { border-top: 1px solid var(--line); margin: 0.3rem 0; }
|
|
1104
|
+
.ix-row {
|
|
1105
|
+
display: grid; grid-template-columns: minmax(7rem, 15rem) 1fr; gap: 0.1rem 1rem;
|
|
1106
|
+
padding: 0.4rem 0; border-bottom: 1px solid var(--line);
|
|
1107
|
+
}
|
|
1108
|
+
.ix-rowname { font-weight: 600; overflow-wrap: anywhere; }
|
|
1109
|
+
.ix-rowname.path { font-family: Consolas, monospace; }
|
|
1110
|
+
.ix-docs { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.1rem 1.1rem; }
|
|
1111
|
+
.ix-docs > details { flex-basis: 100%; }
|
|
1112
|
+
.ix-docs.stack { display: block; }
|
|
1113
|
+
.ix-docs.stack > .ix-list { margin: 0; line-height: 1.75; }
|
|
1114
|
+
.ix-role {
|
|
1115
|
+
font-size: 11px; color: var(--soft); border: 1px solid var(--line);
|
|
1116
|
+
border-radius: 999px; padding: 0 0.45em; margin-left: 0.1em; white-space: nowrap;
|
|
1117
|
+
}
|
|
1118
|
+
.ix-sub { margin: 0.1rem 0; }
|
|
1119
|
+
.ix-sub > summary { cursor: pointer; }
|
|
1120
|
+
.ix-sub > :not(summary) { margin-left: 1.2rem; }
|
|
1121
|
+
.ix-dname { font-family: Consolas, monospace; font-weight: 600; color: var(--soft); }
|
|
1122
|
+
.ix-years a { font-weight: 600; }
|
|
1123
|
+
.ix-note { font-size: 13.5px; color: var(--soft); margin: -0.4rem 0 1rem; }
|
|
1124
|
+
.ix-year .ix-row { grid-template-columns: minmax(7rem, 20rem) 1fr; }
|
|
1125
|
+
@media (max-width: 40rem) {
|
|
1126
|
+
.ix-row, .ix-year .ix-row { grid-template-columns: 1fr; }
|
|
1127
|
+
}
|
|
1128
|
+
`;
|
|
1129
|
+
|
|
1130
|
+
function matchesShape(parts, shape) {
|
|
1131
|
+
return shape.length === parts.length && shape.every((seg, i) => seg === '*' || seg === parts[i]);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function pathLabel(table, parts) {
|
|
1135
|
+
const hit = table.find(({ shape }) => matchesShape(parts, shape));
|
|
1136
|
+
return hit ? hit.label : null;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// A directory node: `path` is its slash-terminated tree path ('' = root).
|
|
1140
|
+
function buildDirTree(relFiles) {
|
|
1141
|
+
const root = { path: '', dirs: new Map(), files: [] };
|
|
1142
|
+
for (const rel of relFiles) {
|
|
1143
|
+
const parts = rel.split('/');
|
|
1144
|
+
let node = root;
|
|
1145
|
+
for (const part of parts.slice(0, -1)) {
|
|
1146
|
+
if (!node.dirs.has(part)) {
|
|
1147
|
+
node.dirs.set(part, { path: `${node.path}${part}/`, dirs: new Map(), files: [] });
|
|
1148
|
+
}
|
|
1149
|
+
node = node.dirs.get(part);
|
|
1150
|
+
}
|
|
1151
|
+
node.files.push(rel);
|
|
1152
|
+
}
|
|
1153
|
+
return root;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
function countDocs(node) {
|
|
1157
|
+
let n = node.files.length;
|
|
1158
|
+
for (const child of node.dirs.values()) {
|
|
1159
|
+
n += countDocs(child);
|
|
1160
|
+
}
|
|
1161
|
+
return n;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function sortedDirNames(node, skip = []) {
|
|
1165
|
+
return [...node.dirs.keys()].filter((name) => !skip.includes(name)).sort();
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function orderedFiles(files, fixedOrder = []) {
|
|
1169
|
+
const base = (rel) => rel.split('/').pop();
|
|
1170
|
+
const fixed = fixedOrder.flatMap((name) => files.filter((rel) => base(rel) === name));
|
|
1171
|
+
const rest = files.filter((rel) => !fixedOrder.includes(base(rel))).sort();
|
|
1172
|
+
return [...fixed, ...rest];
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// A feature directory's files: _index.md first, then every file whose name
|
|
1176
|
+
// carries a date — Dflow names phase specs and lightweight specs
|
|
1177
|
+
// `{kind}-YYYY-MM-DD-{slug}.md` on the day they are created, so date order
|
|
1178
|
+
// is roughly phase order — by that date, then the rest (BUG-{NUMBER}-*,
|
|
1179
|
+
// anything the project named itself) by name. Names only: file contents
|
|
1180
|
+
// and filesystem times never decide the order.
|
|
1181
|
+
const FILE_DATE_RE = /(?:^|[-_])(\d{4}-\d{2}-\d{2})(?=[-_.])/;
|
|
1182
|
+
|
|
1183
|
+
function featureFileOrder(files) {
|
|
1184
|
+
const base = (rel) => rel.split('/').pop();
|
|
1185
|
+
const dateOf = (rel) => {
|
|
1186
|
+
const match = FILE_DATE_RE.exec(base(rel));
|
|
1187
|
+
return match ? match[1] : null;
|
|
1188
|
+
};
|
|
1189
|
+
const byName = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
1190
|
+
const rest = files.filter((rel) => base(rel) !== '_index.md');
|
|
1191
|
+
return [
|
|
1192
|
+
...files.filter((rel) => base(rel) === '_index.md'),
|
|
1193
|
+
...rest.filter((rel) => dateOf(rel)).sort((a, b) => byName(dateOf(a), dateOf(b)) || byName(a, b)),
|
|
1194
|
+
...rest.filter((rel) => !dateOf(rel)).sort()
|
|
1195
|
+
];
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// `base` is the directory the page itself sits in: hrefs are relative to
|
|
1199
|
+
// it, while labels always read the full path.
|
|
1200
|
+
function docLinkHtml(rel, base = '') {
|
|
1201
|
+
const href = indexHref(`${rel.slice(base.length, -'.md'.length)}.html`);
|
|
1202
|
+
const role = pathLabel(ROLE_LABELS, rel.split('/'));
|
|
1203
|
+
const roleHtml = role ? ` <span class="ix-role">${escapeHtml(role)}</span>` : '';
|
|
1204
|
+
return `<a href="${escapeHtml(href)}">${escapeHtml(rel.split('/').pop())}</a>${roleHtml}`;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function docListHtml(files, base = '') {
|
|
1208
|
+
return files.length ? `<ul class="ix-list">${files.map((rel) => `<li>${docLinkHtml(rel, base)}</li>`).join('')}</ul>` : '';
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function docInlineHtml(files) {
|
|
1212
|
+
return files.map((rel) => `<span>${docLinkHtml(rel)}</span>`).join('');
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// The generic listing every part without a layout of its own uses: files
|
|
1216
|
+
// first by name, then each subdirectory as a named disclosure with its
|
|
1217
|
+
// document count, recursively — every document keeps its own link.
|
|
1218
|
+
function dirListingHtml(node, base = '') {
|
|
1219
|
+
return docListHtml(orderedFiles(node.files), base) +
|
|
1220
|
+
sortedDirNames(node).map((name) => subdirHtml(name, node.dirs.get(name), base)).join('');
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
function subdirHtml(name, node, base = '') {
|
|
1224
|
+
const label = pathLabel(DIR_LABELS, node.path.slice(0, -1).split('/'));
|
|
1225
|
+
return `<details class="ix-sub"><summary><span class="ix-dname">${escapeHtml(name)}/</span> ` +
|
|
1226
|
+
`<span class="ix-count">${countDocs(node)} 份</span>${label ? ` <span class="ix-role">${escapeHtml(label)}</span>` : ''}` +
|
|
1227
|
+
`</summary>${dirListingHtml(node, base)}</details>`;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// A row's documents sit side by side (`stacked` false: short conventional
|
|
1231
|
+
// names, as in Domain) or one per line (feature rows: long names whose
|
|
1232
|
+
// order matters).
|
|
1233
|
+
function rowHtml(name, isPath, docsHtml, stacked = false) {
|
|
1234
|
+
return `<div class="ix-row"><div class="ix-rowname${isPath ? ' path' : ''}">${name}</div>` +
|
|
1235
|
+
`<div class="ix-docs${stacked ? ' stack' : ''}">${docsHtml}</div></div>`;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// (D) One feature directory as a row: its files one per line in
|
|
1239
|
+
// featureFileOrder, deeper directories as disclosures after them.
|
|
1240
|
+
function featureRowHtml(name, node, base = '') {
|
|
1241
|
+
return rowHtml(`${escapeHtml(name)}/`, true, docListHtml(featureFileOrder(node.files), base) +
|
|
1242
|
+
sortedDirNames(node).map((sub) => subdirHtml(sub, node.dirs.get(sub), base)).join(''), true);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
// (B) One row for domain/'s own files, then one per subdirectory: its
|
|
1246
|
+
// direct files inline, deeper directories as disclosures.
|
|
1247
|
+
function domainBodyHtml(node) {
|
|
1248
|
+
const rows = [];
|
|
1249
|
+
if (node.files.length) {
|
|
1250
|
+
rows.push(rowHtml('跨 context', false, docInlineHtml(orderedFiles(node.files, DOMAIN_ROOT_ORDER))));
|
|
1251
|
+
}
|
|
1252
|
+
for (const name of sortedDirNames(node)) {
|
|
1253
|
+
const dir = node.dirs.get(name);
|
|
1254
|
+
const nested = sortedDirNames(dir).map((sub) => subdirHtml(sub, dir.dirs.get(sub))).join('');
|
|
1255
|
+
rows.push(rowHtml(`${escapeHtml(name)}/`, true, docInlineHtml(orderedFiles(dir.files, CONTEXT_ORDER)) + nested));
|
|
1256
|
+
}
|
|
1257
|
+
return rows.length ? `<div class="ix-rows">${rows.join('')}</div>` : '';
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// (D) active/: one feature row per directory. backlog/ lists generically;
|
|
1261
|
+
// completed/ keeps the PROPOSAL-079 year links (its year pages use the same
|
|
1262
|
+
// feature rows, completedRowsHtml).
|
|
1263
|
+
function featuresBodyHtml(node, completedGroups) {
|
|
1264
|
+
let out = docListHtml(orderedFiles(node.files));
|
|
1265
|
+
const active = node.dirs.get('active');
|
|
1266
|
+
if (active) {
|
|
1267
|
+
const rows = sortedDirNames(active).map((name) => featureRowHtml(name, active.dirs.get(name)));
|
|
1268
|
+
out += '<h3>active/</h3>' + docListHtml(orderedFiles(active.files)) +
|
|
1269
|
+
(rows.length ? `<div class="ix-rows">${rows.join('')}</div>` : '');
|
|
1270
|
+
}
|
|
1271
|
+
const backlog = node.dirs.get('backlog');
|
|
1272
|
+
if (backlog) {
|
|
1273
|
+
out += `<h3>backlog/</h3>${dirListingHtml(backlog)}`;
|
|
1274
|
+
}
|
|
1275
|
+
if (completedGroups) {
|
|
1276
|
+
out += `<h3>completed/</h3><p class="ix-years">${completedYearLinksHtml(completedGroups)}</p>`;
|
|
1277
|
+
}
|
|
1278
|
+
return out + sortedDirNames(node, ['active', 'backlog', 'completed'])
|
|
1279
|
+
.map((name) => subdirHtml(name, node.dirs.get(name))).join('');
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// Everything without a layout of its own: root files first, then one small
|
|
1283
|
+
// heading per top-level directory.
|
|
1284
|
+
function topLevelBodyHtml(rootFiles, dirEntries) {
|
|
1285
|
+
return docListHtml(orderedFiles(rootFiles)) +
|
|
1286
|
+
dirEntries.map(([name, node]) => `<h3>${escapeHtml(name)}/</h3>${dirListingHtml(node)}`).join('');
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// The grouped index's <h1>..groups body. Pure, and every listing sorts at
|
|
1290
|
+
// output, so the same relFiles in any order give the same HTML.
|
|
1291
|
+
function groupedIndexHtml({ relFiles, title, completedGroups }) {
|
|
1292
|
+
const tree = buildDirTree(relFiles);
|
|
1293
|
+
const claimed = INDEX_GROUPS.flatMap((group) => group.dirs || []);
|
|
1294
|
+
const groups = [];
|
|
1295
|
+
for (const group of INDEX_GROUPS) {
|
|
1296
|
+
const rootFiles = group.dirs === null ? tree.files : [];
|
|
1297
|
+
const dirEntries = (group.dirs || sortedDirNames(tree, claimed))
|
|
1298
|
+
.filter((name) => tree.dirs.has(name))
|
|
1299
|
+
.map((name) => [name, tree.dirs.get(name)]);
|
|
1300
|
+
const count = rootFiles.length + dirEntries.reduce((n, [, node]) => n + countDocs(node), 0);
|
|
1301
|
+
if (count === 0) {
|
|
1302
|
+
continue;
|
|
1303
|
+
}
|
|
1304
|
+
let body;
|
|
1305
|
+
if (group.id === 'features') {
|
|
1306
|
+
body = featuresBodyHtml(dirEntries[0][1], completedGroups);
|
|
1307
|
+
} else if (group.id === 'domain') {
|
|
1308
|
+
body = domainBodyHtml(dirEntries[0][1]);
|
|
1309
|
+
} else {
|
|
1310
|
+
body = topLevelBodyHtml(rootFiles, dirEntries);
|
|
1311
|
+
}
|
|
1312
|
+
const guideLines = (group.guide || []).filter((line) => !line.when || line.when(relFiles));
|
|
1313
|
+
const guide = guideLines.length
|
|
1314
|
+
? `<details class="ix-guide"><summary>怎麼讀這些文件</summary><ul>${guideLines.map((line) => `<li>${line.html}</li>`).join('')}</ul></details>`
|
|
1315
|
+
: '';
|
|
1316
|
+
const dirs = [...(rootFiles.length ? ['根目錄'] : []), ...dirEntries.map(([name]) => `${escapeHtml(name)}/`)];
|
|
1317
|
+
groups.push({
|
|
1318
|
+
group,
|
|
1319
|
+
html: (open) => `<details class="ix-group" id="g-${group.id}"${open ? ' open' : ''}><summary>` +
|
|
1320
|
+
`<span class="ix-name">${escapeHtml(group.name)}</span> <span class="ix-dirs">${dirs.join(' · ')}</span> ` +
|
|
1321
|
+
`<span class="ix-count">${count} 份</span> <span class="ix-purpose">${escapeHtml(group.purpose)}</span>` +
|
|
1322
|
+
`</summary><div class="ix-body">${guide}${body}</div></details>`
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
const start = relFiles.includes(OVERVIEW_REL)
|
|
1326
|
+
? ` · 從這裡開始:<a href="${escapeHtml(indexHref(`${OVERVIEW_REL.slice(0, -'.md'.length)}.html`))}">${escapeHtml(OVERVIEW_REL)}</a>`
|
|
1327
|
+
: '';
|
|
1328
|
+
const jump = groups.length > 1
|
|
1329
|
+
? `<p class="ix-jump">跳到:${groups.map(({ group }) => `<a href="#g-${group.id}">${escapeHtml(group.name)}</a>`).join(' · ')}</p>`
|
|
1330
|
+
: '';
|
|
1331
|
+
return `<h1>${escapeHtml(title)}</h1><p class="ix-head">共 ${relFiles.length} 份文件${start}</p>${jump}` +
|
|
1332
|
+
groups.map(({ html }) => html(groups.length === 1)).join('');
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// (D) A year page on a recognised specs root: one feature row per
|
|
1336
|
+
// directory, newest-first as the PROPOSAL-079 tree lists them, then the
|
|
1337
|
+
// loose files. Hrefs stay relative to the year page.
|
|
1338
|
+
function completedRowsHtml(files) {
|
|
1339
|
+
const node = buildDirTree(files).dirs.get('features').dirs.get('completed');
|
|
1340
|
+
const rows = sortedDirNames(node).reverse()
|
|
1341
|
+
.map((name) => featureRowHtml(name, node.dirs.get(name), COMPLETED_PREFIX));
|
|
1342
|
+
return '<div class="ix-year">' + (rows.length ? `<div class="ix-rows">${rows.join('')}</div>` : '') +
|
|
1343
|
+
docListHtml(orderedFiles(node.files), COMPLETED_PREFIX) + '</div>';
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// Year-page orientation line (E): only on a recognised specs root.
|
|
1347
|
+
const COMPLETED_NOTE =
|
|
1348
|
+
'這一頁是已完成 feature 的歷史紀錄,依目錄名 SPEC 編號裡的年份分頁,認不出年份的放在「未分年」;' +
|
|
1349
|
+
'系統現在的行為以 <code>domain/</code> 底下的文件為準。';
|
|
1350
|
+
|
|
1351
|
+
async function renderIndex({ outRoot, relFiles, title, stamp, completedGroups, specsRoot }) {
|
|
1352
|
+
let body;
|
|
1353
|
+
if (specsRoot) {
|
|
1354
|
+
body = groupedIndexHtml({ relFiles, title, completedGroups });
|
|
1355
|
+
} else {
|
|
1356
|
+
let tree;
|
|
1357
|
+
if (completedGroups) {
|
|
1358
|
+
const mainFiles = relFiles.filter((rel) => !rel.startsWith(COMPLETED_PREFIX));
|
|
1359
|
+
tree = buildTreeHtml(mainFiles, {
|
|
1360
|
+
stubs: [{ dirParts: ['features', 'completed'], html: completedYearLinksHtml(completedGroups) }]
|
|
1361
|
+
});
|
|
1362
|
+
} else {
|
|
1363
|
+
tree = buildTreeHtml(relFiles);
|
|
1364
|
+
}
|
|
1365
|
+
body = `<h1>${escapeHtml(title)}</h1>${tree}`;
|
|
1366
|
+
}
|
|
625
1367
|
const footer = `共 ${relFiles.length} 份文件 · ${stamp} · 來源檔較新時請重新執行 dflow render`;
|
|
626
1368
|
await writeText(path.join(outRoot, 'index.html'), pageHtml({
|
|
627
1369
|
title: escapeHtml(title),
|
|
628
1370
|
crumb: 'specs /',
|
|
629
1371
|
metaCard: '',
|
|
630
1372
|
body,
|
|
631
|
-
footer
|
|
1373
|
+
footer,
|
|
1374
|
+
extraCss: specsRoot ? INDEX_CSS : ''
|
|
632
1375
|
}));
|
|
633
1376
|
return 'index.html';
|
|
634
1377
|
}
|
|
635
1378
|
|
|
1379
|
+
// PROPOSAL-079: one physical page per completed year (user requirement:
|
|
1380
|
+
// real files, not visual collapsing — ten years of archive must not pile
|
|
1381
|
+
// into one page). SPEC directories list newest-first inside the page.
|
|
1382
|
+
async function renderCompletedIndex({ outRoot, key, groups, title, stamp, specsRoot }) {
|
|
1383
|
+
const relHtml = completedIndexRel(key);
|
|
1384
|
+
const group = groups.get(key);
|
|
1385
|
+
const stripped = group.files.map((rel) => rel.slice(COMPLETED_PREFIX.length));
|
|
1386
|
+
const label = completedKeyLabel(key);
|
|
1387
|
+
const nav = completedKeyOrder(groups.keys())
|
|
1388
|
+
.map((k) => (k === key
|
|
1389
|
+
? `<strong>${escapeHtml(completedKeyLabel(k))}</strong>`
|
|
1390
|
+
: `<a href="${escapeHtml(indexHref(`index-${k}.html`))}">${escapeHtml(completedKeyLabel(k))}</a>`))
|
|
1391
|
+
.join(' · ');
|
|
1392
|
+
const body =
|
|
1393
|
+
`<h1>completed / ${escapeHtml(label)}</h1>` +
|
|
1394
|
+
`<p class="yearnav">${nav}</p>` +
|
|
1395
|
+
(specsRoot
|
|
1396
|
+
? `<p class="ix-note">${COMPLETED_NOTE}</p>${completedRowsHtml(group.files)}`
|
|
1397
|
+
: buildTreeHtml(stripped, { topDirsDesc: true }));
|
|
1398
|
+
const crumb = '<a href="../../index.html">specs</a> / features / completed / ' + escapeHtml(label);
|
|
1399
|
+
const footer = `completed ${escapeHtml(label)} · 共 ${group.units.size} 項 · ${stamp} · 來源檔較新時請重新執行 dflow render`;
|
|
1400
|
+
await writeText(path.join(outRoot, ...relHtml.split('/')), pageHtml({
|
|
1401
|
+
title: escapeHtml(`${title} · completed ${label}`),
|
|
1402
|
+
crumb,
|
|
1403
|
+
metaCard: '',
|
|
1404
|
+
body,
|
|
1405
|
+
footer,
|
|
1406
|
+
extraCss: specsRoot ? INDEX_CSS : ''
|
|
1407
|
+
}));
|
|
1408
|
+
return relHtml;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
636
1411
|
// ----------------------------------------------------- output-dir ownership
|
|
637
1412
|
|
|
638
1413
|
// Validate a parsed-or-raw manifest. Returns { ok: true, files } or
|
|
@@ -748,7 +1523,13 @@ function pathContains(parent, child) {
|
|
|
748
1523
|
// a raw EISDIR/ENOTDIR mid-write — after the ownership stamp — so this is
|
|
749
1524
|
// checked before any output mutation. Returns null, or a human-readable
|
|
750
1525
|
// description of the first collision found.
|
|
751
|
-
|
|
1526
|
+
//
|
|
1527
|
+
// reservedPages lists every generated (non-mirror) page THIS run will write
|
|
1528
|
+
// — the root file tree plus any completed year indexes (PROPOSAL-079). Each
|
|
1529
|
+
// is checked against mirror outputs AND against source directories needing
|
|
1530
|
+
// the same path. Defaults to the root index alone so unit callers predate
|
|
1531
|
+
// pagination unchanged.
|
|
1532
|
+
function findProjectionCollision(relFiles, reservedPages = [{ out: 'index.html', what: 'the generated file tree' }]) {
|
|
752
1533
|
const toOut = (rel) => `${rel.slice(0, -'.md'.length)}.html`;
|
|
753
1534
|
for (const rel of relFiles) {
|
|
754
1535
|
// The ledger stores out-relative posix paths and parseManifest rejects
|
|
@@ -761,7 +1542,7 @@ function findProjectionCollision(relFiles) {
|
|
|
761
1542
|
return `source path ${rel} cannot be represented in the render manifest`;
|
|
762
1543
|
}
|
|
763
1544
|
}
|
|
764
|
-
const byOut = new Map(
|
|
1545
|
+
const byOut = new Map(reservedPages.map(({ out, what }) => [out.toLowerCase(), what]));
|
|
765
1546
|
for (const rel of relFiles) {
|
|
766
1547
|
const out = toOut(rel);
|
|
767
1548
|
const prior = byOut.get(out.toLowerCase());
|
|
@@ -780,9 +1561,11 @@ function findProjectionCollision(relFiles) {
|
|
|
780
1561
|
}
|
|
781
1562
|
}
|
|
782
1563
|
}
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
|
|
1564
|
+
for (const { out } of reservedPages) {
|
|
1565
|
+
const hit = neededDirs.get(out.toLowerCase());
|
|
1566
|
+
if (hit) {
|
|
1567
|
+
return `${hit.source} needs ${hit.prefix}/ as a directory, but render generates ${out} there`;
|
|
1568
|
+
}
|
|
786
1569
|
}
|
|
787
1570
|
for (const reserved of [MANIFEST_NAME, MANIFEST_TMP_NAME]) {
|
|
788
1571
|
const hit = neededDirs.get(reserved);
|
|
@@ -1048,7 +1831,17 @@ async function runRender({ cwd, args = [], stdout, stderr }) {
|
|
|
1048
1831
|
// all: a colliding projection must refuse cleanly here, not die on a raw
|
|
1049
1832
|
// fs error mid-write after the ownership stamp (cold-eye gate G3 F2).
|
|
1050
1833
|
const relMdFiles = await collectMdFiles(srcRoot);
|
|
1051
|
-
const
|
|
1834
|
+
const completedGroups = groupCompletedByYear(relMdFiles);
|
|
1835
|
+
const reservedPages = [{ out: 'index.html', what: 'the generated file tree' }];
|
|
1836
|
+
if (completedGroups) {
|
|
1837
|
+
for (const key of completedKeyOrder(completedGroups.keys())) {
|
|
1838
|
+
reservedPages.push({
|
|
1839
|
+
out: completedIndexRel(key),
|
|
1840
|
+
what: `the generated completed index for ${completedKeyLabel(key)}`
|
|
1841
|
+
});
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
const collision = findProjectionCollision(relMdFiles, reservedPages);
|
|
1052
1845
|
if (collision) {
|
|
1053
1846
|
stderr.write(
|
|
1054
1847
|
`refusing to run: ${collision}; nothing was written.\n` +
|
|
@@ -1183,7 +1976,10 @@ async function runRender({ cwd, args = [], stdout, stderr }) {
|
|
|
1183
1976
|
|
|
1184
1977
|
// Ownership (manifest) + entry types (scan) still do not prove a given
|
|
1185
1978
|
// file is render's own work; the generated marker inside the file does.
|
|
1186
|
-
const plannedRels = [
|
|
1979
|
+
const plannedRels = [
|
|
1980
|
+
...relMdFiles.map((rel) => `${rel.slice(0, -'.md'.length)}.html`),
|
|
1981
|
+
...reservedPages.map(({ out }) => out)
|
|
1982
|
+
];
|
|
1187
1983
|
const unproven = await findUnprovenTarget({ outRoot, plannedRels, listedRels: previousFiles });
|
|
1188
1984
|
if (unproven) {
|
|
1189
1985
|
stderr.write(
|
|
@@ -1209,10 +2005,19 @@ async function runRender({ cwd, args = [], stdout, stderr }) {
|
|
|
1209
2005
|
|
|
1210
2006
|
const stamp = nowStamp();
|
|
1211
2007
|
const written = [];
|
|
2008
|
+
const diagramStats = { drawn: 0, notDrawn: 0 };
|
|
1212
2009
|
for (const relPosix of relMdFiles) {
|
|
1213
|
-
written.push(await renderFile({ MarkedCtor: Marked, srcRoot, outRoot, relPosix, stamp, tree, basenames }));
|
|
2010
|
+
written.push(await renderFile({ MarkedCtor: Marked, srcRoot, outRoot, relPosix, stamp, tree, basenames, diagramStats }));
|
|
1214
2011
|
}
|
|
1215
|
-
|
|
2012
|
+
// PROPOSAL-101: the grouped index, its guide text and the year-page note
|
|
2013
|
+
// only on a recognised specs root.
|
|
2014
|
+
const specsRoot = tree.has(SPECS_ROOT_MARKER);
|
|
2015
|
+
if (completedGroups) {
|
|
2016
|
+
for (const key of completedKeyOrder(completedGroups.keys())) {
|
|
2017
|
+
written.push(await renderCompletedIndex({ outRoot, key, groups: completedGroups, title: options.title, stamp, specsRoot }));
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
written.push(await renderIndex({ outRoot, relFiles: relMdFiles, title: options.title, stamp, completedGroups, specsRoot }));
|
|
1216
2021
|
|
|
1217
2022
|
const stale = staleEntries(previousFiles, written);
|
|
1218
2023
|
const unlinked = [];
|
|
@@ -1235,6 +2040,10 @@ async function runRender({ cwd, args = [], stdout, stderr }) {
|
|
|
1235
2040
|
await writeManifest(outRoot, written);
|
|
1236
2041
|
|
|
1237
2042
|
stdout.write(`rendered ${relMdFiles.length} md files -> ${outRoot}\n`);
|
|
2043
|
+
// PROPOSAL-100: only when some LC-/FL- subsection got a picture or a note.
|
|
2044
|
+
if (diagramStats.drawn + diagramStats.notDrawn > 0) {
|
|
2045
|
+
stdout.write(`diagrams: ${diagramStats.drawn} drawn, ${diagramStats.notDrawn} not drawn\n`);
|
|
2046
|
+
}
|
|
1238
2047
|
stdout.write(`open: ${path.join(outRoot, 'index.html')}\n`);
|
|
1239
2048
|
return 0;
|
|
1240
2049
|
}
|
|
@@ -1259,5 +2068,12 @@ module.exports = {
|
|
|
1259
2068
|
findProjectionCollision,
|
|
1260
2069
|
GENERATED_MARK,
|
|
1261
2070
|
splitFrontmatter,
|
|
1262
|
-
headingSlug
|
|
2071
|
+
headingSlug,
|
|
2072
|
+
// PROPOSAL-077 A1: exported so tests lock the documented thresholds.
|
|
2073
|
+
LONG_FIELD_CHARS,
|
|
2074
|
+
CLAMP_FIELD_CHARS,
|
|
2075
|
+
// PROPOSAL-079: exported for grouping-edge unit tests.
|
|
2076
|
+
groupCompletedByYear,
|
|
2077
|
+
// PROPOSAL-101: exported so tests can feed shuffled and non-Windows paths.
|
|
2078
|
+
groupedIndexHtml
|
|
1263
2079
|
};
|