skill-family-doc-render 0.3.0 → 0.4.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 CHANGED
@@ -1,5 +1,26 @@
1
1
  # 变更日志
2
2
 
3
+ ## 0.4.0 - 2026-09-07
4
+
5
+ ### 新增
6
+
7
+ - 增加显式成对的 `site.format: markdown-v1` 与 `site.template: editorial`。受限 Markdown
8
+ 由精确依赖 `marked@18.0.11` 分词,再经过本包的结构、链接和锚点校验;包内模板生成
9
+ 完整 HTML、分组目录、静态搜索、交互与首尾不循环的教程翻页。
10
+ - 增加包内 `editorial` 模板的 CSS、JavaScript 和 SVG 图标。新模式不读取项目自有主题
11
+ CSS 或 JavaScript,同一冻结输入会产生确定的页面及资源字节。
12
+ - 增加站内 SVG 主动内容检查。脚本、事件属性、`foreignObject`、DTD/实体声明,以及
13
+ 外部或 data 资源会在输出替换前被拒绝;SVG 不内联。
14
+ - 增加只读的 `--check-project --repo <name>`,依次检查输入与公开安全、Markdown 机械
15
+ 合同、覆盖状态、渲染与基线、内部链接和资源,并在失败时输出可复制的恢复提示。
16
+
17
+ ### 安全与兼容
18
+
19
+ - `site.format` 与 `site.template` 同时缺省时继续使用完整 HTML 注入。该路径只供既有
20
+ 消费者兼容,不获得新主题;新建站点使用 `markdown-v1` 与 `editorial`。
21
+ - `--check-project` 必须与单个 `--repo` 一起使用,不与其他模式组合。全程只读;状态
22
+ 问题返回 1,输入或工具问题返回 2。
23
+
3
24
  ## 0.3.0 - 2026-09-07
4
25
 
5
26
  ### 新增
package/README.md CHANGED
@@ -2,10 +2,13 @@
2
2
 
3
3
  Config-driven GitHub Pages knowledge-site renderer for skill-family projects (multi-repo capable).
4
4
 
5
- Reads `public-release.json` from the current working directory, renders every repo carrying a
6
- `site` field from its site source directory to its target directory (typically `docs/`), injects
7
- nav / pager / footer, replaces version placeholders, copies assets, writes `.nojekyll` and a
8
- tree baseline (`site-baseline.json`), and runs a content-level leak scan over every output file
5
+ Reads `public-release.json` from the current working directory and renders every repo carrying a
6
+ `site` field from its site source directory to its target directory (typically `docs/`). New
7
+ sites explicitly select `format: "markdown-v1"` and `template: "editorial"`; the renderer parses
8
+ restricted Markdown and supplies the complete page structure, navigation, theme, interactions,
9
+ and search data. Sites without `format` keep the legacy complete-HTML injection path for
10
+ compatibility. Both paths replace version placeholders, write `.nojekyll` and a
11
+ tree baseline (`site-baseline.json`), and run a content-level leak scan over every output file
9
12
  (pages **and** assets) before any target change. Render, scan, baseline computation and the
10
13
  `--assert-git` preflight all finish before the target is changed, so failures in those steps
11
14
  leave the existing target untouched. The final directory commit uses Foundation's fixed-set
@@ -13,70 +16,69 @@ publication APIs. A failure before commit removes staging and leaves the existin
13
16
  untouched. After commit, or when publication is indeterminate, staging or displaced data may
14
17
  remain for diagnosis; the renderer does not guess or roll back.
15
18
 
16
- > **Development status:** this worktree is the unpublished `0.3.0` candidate. The latest npm
17
- > release is `0.2.1`; it does not contain `site.coverage`, `site.versionSources`, `--status`, or
18
- > `--refresh-coverage`.
19
+ > **Version boundary:** this source tree is version `0.4.0`. Verify that exact version in the
20
+ > official npm registry before treating it as installable. Version 0.4.0 adds `markdown-v1`, the
21
+ > `editorial` template, and `--check-project` on top of the 0.3.0 coverage workflow.
19
22
 
20
23
  ## Install
21
24
 
22
- Install the current published release with an exact version:
25
+ After confirming availability, install this release with an exact version:
23
26
 
24
27
  ```sh
25
- npm install --save-exact skill-family-doc-render@0.2.1
28
+ npm install --save-exact skill-family-doc-render@0.4.0
26
29
  ```
27
30
 
28
31
  Requires Node.js `>=22.22.2 <23` (aligned with the skill-family foundation packages).
29
32
 
30
- The unpublished 0.3.0 candidate consumes the three Foundation packages at the exact `0.18.0` pin:
31
- `skill-family-contracts@0.18.0`, `skill-family-harness-node@0.18.0` and
32
- `skill-family-engineering-kit@0.18.0` (the last one is used by the workspace profile check).
33
+ Version 0.4.0 has exact runtime dependencies on `marked@18.0.11`,
34
+ `skill-family-contracts@0.18.0`, and `skill-family-harness-node@0.18.0`. `marked` supplies the
35
+ Markdown lexer; the renderer applies its own restricted-format validation before passing semantic
36
+ content to the template. The workspace profile check separately uses
37
+ `skill-family-engineering-kit@0.18.0`.
33
38
  The 0.2.0 baseline format and exit-code changes are breaking changes; existing 0.1.x users
34
39
  must re-render their baseline before using `--check`. See the changelog for the full migration
35
40
  notes.
36
41
 
37
42
  ## CLI
38
43
 
39
- The published 0.2.1 release supports rendering, drift checks, repository selection, and the Git
40
- index assertion:
44
+ Use the exact 0.4.0 CLI for rendering, checks, coverage maintenance, and the Git index assertion:
41
45
 
42
46
  ```sh
43
47
  # render all repos with a site field (writes to disk)
44
- npx skill-family-doc-render@0.2.1
48
+ npx skill-family-doc-render@0.4.0
45
49
 
46
50
  # drift check only: compare in-memory render against committed site-baseline.json, write nothing
47
- npx skill-family-doc-render@0.2.1 --check
51
+ npx skill-family-doc-render@0.4.0 --check
48
52
 
49
53
  # render / check a single repo by name
50
- npx skill-family-doc-render@0.2.1 --repo <name>
54
+ npx skill-family-doc-render@0.4.0 --repo <name>
51
55
 
52
56
  # additionally assert every rendered file is tracked in the git index
53
- npx skill-family-doc-render@0.2.1 --assert-git
54
- ```
55
-
56
- Run the unpublished 0.3.0 coverage commands from a local package checkout. The current directory
57
- must still be the workspace that owns `public-release.json`; adjust the script path when the
58
- package is nested in a monorepo:
59
-
60
- ```sh
61
- # inspect whether reviewed product inputs have changed; writes nothing
62
- node bin/skill-family-doc-render.mjs --status --repo <name>
57
+ npx skill-family-doc-render@0.4.0 --assert-git
63
58
 
64
- # refresh only the reviewed-input snapshot after semantic and style review
65
- node bin/skill-family-doc-render.mjs --refresh-coverage --repo <name>
59
+ # inspect or refresh the reviewed product-input snapshot
60
+ npx skill-family-doc-render@0.4.0 --status --repo <name>
61
+ npx skill-family-doc-render@0.4.0 --refresh-coverage --repo <name>
62
+ npx skill-family-doc-render@0.4.0 --check-project --repo <name>
66
63
  ```
67
64
 
68
- After 0.3.0 is published, consumers can install or invoke that exact release:
65
+ The current directory must be the workspace that owns `public-release.json`. From this package's
66
+ source checkout, `node bin/skill-family-doc-render.mjs` is the equivalent local CLI entry.
69
67
 
70
- ```sh
71
- npm install --save-exact skill-family-doc-render@0.3.0
72
- npx skill-family-doc-render@0.3.0 --status --repo <name>
73
- ```
68
+ `--check-project` requires exactly one `--repo <name>`. It cannot be combined with `--check`,
69
+ `--status`, `--refresh-coverage`, `--assert-git`, or `--help`. It checks input and public-content
70
+ safety, the mechanical Markdown contract, coverage freshness, the in-memory render and baseline,
71
+ then internal links and resources. It writes nothing. A failure identifies the repo,
72
+ `public-release.json`, the reason, and a copyable `skill-family-docs-render-site` recovery prompt.
74
73
 
75
- Exit codes: `0` success; `1` drift class — `--check` drift, leak-scan hit, `--assert-git`
74
+ Exit codes: `0` success; `1` drift/status class — `--check` drift, leak-scan hit, `--assert-git`
76
75
  missing files, missing/corrupt render baseline, or missing/stale coverage snapshot; `2`
77
76
  configuration/tool class — missing/invalid `public-release.json` or `pages.json`, JSON parse
78
77
  failures, unreplaced `@TOKEN@` placeholders, invalid `--repo` usage, invalid coverage inputs, or
79
- Git failure (the message includes the JSON field path). When several repos are
78
+ Git failure (the message includes the JSON field path). `--check-project` uses the same classes:
79
+ public-safety, coverage, render, link, or resource findings return `1`; invalid input,
80
+ configuration, or tool errors return `2`.
81
+ When several repos are
80
82
  rendered in one run, each repo is isolated: a failure in one does not stop the others, and the
81
83
  run ends with a summary of successes/failures plus a non-zero exit (2 if any failure was
82
84
  configuration-class, otherwise 1).
@@ -85,8 +87,8 @@ configuration-class, otherwise 1).
85
87
 
86
88
  The config file lives at the workspace root (resolved from `process.cwd()`). It is validated
87
89
  against `schemas/public-release.schema.json` (JSON Schema 2020-12, strict policy) before
88
- anything runs. `coverage` and `versionSources` in the example below belong to the unpublished
89
- 0.3.0 candidate.
90
+ anything runs. The paired `format` and `template` fields in the example below require version
91
+ 0.4.0 or later.
90
92
 
91
93
  ```json
92
94
  {
@@ -102,6 +104,8 @@ anything runs. `coverage` and `versionSources` in the example below belong to th
102
104
  "dir": "site-src",
103
105
  "target": "docs",
104
106
  "pages": "pages.json",
107
+ "format": "markdown-v1",
108
+ "template": "editorial",
105
109
  "coverage": {
106
110
  "lock": "site-coverage-lock.json",
107
111
  "inputs": ["package.json", "src/**", "schemas/**"]
@@ -128,10 +132,12 @@ anything runs. `coverage` and `versionSources` in the example below belong to th
128
132
 
129
133
  - Only repos with a `site` field are rendered; `--repo <name>` selects one by `name`
130
134
  (`--repo` without a valid value is an error, never a silent "render all").
131
- - `site.pages` points to a pages manifest: `{ "pages": [{ "id", "title", "order", "inPager" }] }`,
132
- validated against `schemas/pages.schema.json`. Pages are sorted by `order`;
133
- `inPager: false` excludes a page from the prev/next pager. Page `id` is restricted to
134
- `[A-Za-z0-9][A-Za-z0-9_-]*`.
135
+ - `site.format` and `site.template` are paired. New sites use only `markdown-v1` and `editorial`.
136
+ Omitting both selects the legacy HTML compatibility path.
137
+ - `site.pages` points to a manifest validated against `schemas/pages.schema.json`. A
138
+ `markdown-v1` manifest declares site metadata, navigation groups, and each page's `id`, title,
139
+ reader role, layout kind, group, unique order, and `inPager` state. The page `id` is restricted
140
+ to `[A-Za-z0-9][A-Za-z0-9_-]*`.
135
141
  - `forbiddenPublicPaths` / `privateLiterals` append project-specific literals to the leak scan.
136
142
  - `site.coverage` declares reviewed product inputs relative to `repo.source`. Each input accepts
137
143
  `*` and `**` glob syntax and must match at least one file after the coverage lock itself is
@@ -141,14 +147,37 @@ anything runs. `coverage` and `versionSources` in the example below belong to th
141
147
  the scalar value without copying the version into configuration. Every referenced JSON file
142
148
  automatically joins the coverage inputs.
143
149
 
144
- ## Placeholders
150
+ ## Input formats
151
+
152
+ ### Restricted Markdown and the editorial template
145
153
 
146
- Source pages use three HTML-comment injection points, replaced at render time:
154
+ Each `markdown-v1` page is `site.dir/<id>.md`, with an exact one-to-one match to the manifest.
155
+ The format supports paragraphs, level 2–4 headings, ordered and unordered lists, tables, inline
156
+ links and emphasis, fenced code with a language, `NOTE` / `TIP` / `WARNING` callouts, and local
157
+ SVG images. The manifest supplies the only H1. A heading may end with `{#stable-id}`; otherwise a
158
+ deterministic slug is generated. Raw HTML, MDX, task or nested lists, indented or unlabelled code,
159
+ ordinary blockquotes, and arbitrary attributes are rejected before output replacement.
160
+
161
+ The editorial template owns HTML, CSS, JavaScript, icons, navigation, search, and the non-circular
162
+ tutorial pager. Projects using `markdown-v1` do not supply theme CSS or JavaScript. Project images
163
+ are limited to contained SVG files. Before rendering, SVG safety checks reject scripts, event
164
+ attributes, `foreignObject`, DTD/entity declarations, and external or data resources; SVG remains
165
+ an external image and is never inlined.
166
+
167
+ ### Legacy HTML compatibility
168
+
169
+ When `format` and `template` are omitted, source pages remain complete HTML and use three comment
170
+ injection points:
147
171
 
148
172
  - `<!--NAV-->` — table-of-contents nav (active page gets `class="active"`)
149
- - `<!--PAGER-->` — circular prev/next pager
173
+ - `<!--PAGER-->` — legacy circular prev/next pager
150
174
  - `<!--FOOTER-->` — site footer (owner + license)
151
175
 
176
+ Legacy HTML remains available for existing consumers, but it does not receive the editorial
177
+ theme. New sites should not start on this compatibility path.
178
+
179
+ ## Version placeholders
180
+
152
181
  Version tokens are derived automatically from the repo's `package.json` version and `name`
153
182
  (uppercased, non-alphanumeric runs collapsed to `_`):
154
183
 
@@ -189,7 +218,7 @@ changes.
189
218
 
190
219
  ## Leak scan
191
220
 
192
- Every output file — rendered pages **and** copied assets (`.html`/`.css`/`.js`/`.svg`) — is
221
+ Every output file — rendered pages **and** copied or template assets (`.html`/`.css`/`.js`/`.svg`) — is
193
222
  scanned **before** any write (`scripts/lib/leak-scan.mjs`). The scanner decodes HTML entities
194
223
  (two passes, defeating double encoding), JS escape sequences and URL percent-encoding first,
195
224
  then matches case-insensitively against built-in private literals (absolute home paths) and
@@ -221,8 +250,8 @@ The digest is a deterministic set-level sha256 over the contained resources.
221
250
 
222
251
  ## Foundation dependencies
223
252
 
224
- This package is a thin domain layer over the skill-family foundation and adds no other runtime
225
- dependencies:
253
+ This package is a thin domain layer over the skill-family foundation. In addition to the two
254
+ Foundation packages below, it uses exact `marked@18.0.11` for Markdown tokenization:
226
255
 
227
256
  - [`skill-family-harness-node`](https://github.com/ifoohoo/skill-family-harness-node) — all file
228
257
  reads go through `resolveContained` / `readFileContained` (path traversal, symlink and realpath
@@ -244,9 +273,9 @@ dependencies:
244
273
  Apache-2.0
245
274
 
246
275
  <!-- release-skill:capability:safe-first-command -->
247
- > **Start here:** for the unpublished 0.3.0 candidate, run
248
- > `node bin/skill-family-doc-render.mjs --check` from its local checkout. Published 0.2.1 users
249
- > can run `./node_modules/.bin/skill-family-doc-render --check`. The drift check is read-only:
276
+ > **Start here:** run `npx skill-family-doc-render@0.4.0 --check-project --repo <name>` after
277
+ > confirming that exact release in the official registry. From a local checkout, use
278
+ > `node bin/skill-family-doc-render.mjs --check-project --repo <name>`. The check is read-only:
250
279
  > it renders in memory, compares against the committed `site-baseline.json`, writes
251
280
  > nothing and touches no network or credentials. The CLI performs no Git writes; only an
252
281
  > explicit `--assert-git` performs a read-only query of the Git index.
@@ -255,7 +284,7 @@ Apache-2.0
255
284
  > **External write boundary:** rendering writes the declared target and a controlled sibling
256
285
  > staging directory, both within the target's canonical parent, with contained path checks. An
257
286
  > explicit `--refresh-coverage` writes only the declared coverage lock through an atomic file
258
- > replacement. `--status` and `--check` write nothing.
287
+ > replacement. `--status`, `--check`, and `--check-project` write nothing.
259
288
  > Render, scan, baseline and `--assert-git` preflight failures happen before target changes.
260
289
  > Staging is removed after success or a clearly pre-commit failure; post-commit or
261
290
  > indeterminate failures may retain it to preserve diagnosable state.
@@ -265,12 +294,9 @@ Apache-2.0
265
294
 
266
295
  ## Minimal example
267
296
 
268
- Use the local checkout while 0.3.0 remains unpublished:
297
+ Display help and run a read-only project check:
269
298
 
270
299
  ```sh
271
- node bin/skill-family-doc-render.mjs --help
272
- node bin/skill-family-doc-render.mjs --check
300
+ npx skill-family-doc-render@0.4.0 --help
301
+ npx skill-family-doc-render@0.4.0 --check-project --repo <name>
273
302
  ```
274
-
275
- Replace `node bin/skill-family-doc-render.mjs` with `npx skill-family-doc-render@0.3.0` only after
276
- the registry publishes 0.3.0.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skill-family-doc-render",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Config-driven GitHub Pages knowledge-site renderer for skill-family projects (multi-repo capable).",
5
5
  "author": "广州市风荷科技有限公司",
6
6
  "license": "Apache-2.0",
@@ -15,6 +15,7 @@
15
15
  "bin",
16
16
  "scripts",
17
17
  "schemas",
18
+ "templates",
18
19
  "README.md",
19
20
  "CHANGELOG.md",
20
21
  "LICENSE"
@@ -25,6 +26,7 @@
25
26
  "render:site:check": "node bin/skill-family-doc-render.mjs --check"
26
27
  },
27
28
  "dependencies": {
29
+ "marked": "18.0.11",
28
30
  "skill-family-contracts": "0.18.0",
29
31
  "skill-family-harness-node": "0.18.0"
30
32
  }
@@ -2,24 +2,82 @@
2
2
  "$id": "https://github.com/ifoohoo/skill-family-doc-render/schemas/pages.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "title": "pages.json",
5
- "description": "站点页面清单:页面顺序、导航与翻页的真源。site 块为站点元数据(渲染器不消费)。id 限文件名片段字符,顺带消除拼路径时的 shell 元字符面。",
6
- "type": "object",
7
- "additionalProperties": false,
8
- "required": ["pages"],
9
- "properties": {
10
- "site": { "type": "object" },
11
- "pages": {
12
- "type": "array",
13
- "minItems": 1,
14
- "items": {
15
- "type": "object",
16
- "additionalProperties": false,
17
- "required": ["id", "title", "order"],
18
- "properties": {
19
- "id": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]*$" },
20
- "title": { "type": "string", "minLength": 1 },
21
- "order": { "type": "integer" },
22
- "inPager": { "type": "boolean" }
5
+ "description": "旧 HTML 与 markdown-v1 共用的页面清单合同。渲染器按 public-release.json 中的 format 选择对应语义。",
6
+ "oneOf": [
7
+ { "$ref": "#/$defs/legacy" },
8
+ { "$ref": "#/$defs/markdownV1" }
9
+ ],
10
+ "$defs": {
11
+ "safeId": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]*$" },
12
+ "legacy": {
13
+ "type": "object",
14
+ "additionalProperties": false,
15
+ "required": ["pages"],
16
+ "properties": {
17
+ "site": { "type": "object" },
18
+ "pages": {
19
+ "type": "array",
20
+ "minItems": 1,
21
+ "items": {
22
+ "type": "object",
23
+ "additionalProperties": false,
24
+ "required": ["id", "title", "order"],
25
+ "properties": {
26
+ "id": { "$ref": "#/$defs/safeId" },
27
+ "title": { "type": "string", "minLength": 1 },
28
+ "order": { "type": "integer" },
29
+ "inPager": { "type": "boolean" }
30
+ }
31
+ }
32
+ }
33
+ }
34
+ },
35
+ "markdownV1": {
36
+ "type": "object",
37
+ "additionalProperties": false,
38
+ "required": ["site", "groups", "pages"],
39
+ "properties": {
40
+ "site": {
41
+ "type": "object",
42
+ "additionalProperties": false,
43
+ "required": ["title", "description", "productKind"],
44
+ "properties": {
45
+ "title": { "type": "string", "minLength": 1 },
46
+ "description": { "type": "string", "minLength": 1 },
47
+ "productKind": { "enum": ["plugin", "library"] }
48
+ }
49
+ },
50
+ "groups": {
51
+ "type": "array",
52
+ "minItems": 1,
53
+ "items": {
54
+ "type": "object",
55
+ "additionalProperties": false,
56
+ "required": ["id", "title"],
57
+ "properties": {
58
+ "id": { "$ref": "#/$defs/safeId" },
59
+ "title": { "type": "string", "minLength": 1 }
60
+ }
61
+ }
62
+ },
63
+ "pages": {
64
+ "type": "array",
65
+ "minItems": 1,
66
+ "items": {
67
+ "type": "object",
68
+ "additionalProperties": false,
69
+ "required": ["id", "title", "role", "kind", "group", "order", "inPager"],
70
+ "properties": {
71
+ "id": { "$ref": "#/$defs/safeId" },
72
+ "title": { "type": "string", "minLength": 1 },
73
+ "navTitle": { "type": "string", "minLength": 1 },
74
+ "role": { "enum": ["overview", "setup", "first-success", "maintenance", "release", "troubleshooting", "upgrade", "reference"] },
75
+ "kind": { "enum": ["home", "task", "reference", "troubleshooting"] },
76
+ "group": { "$ref": "#/$defs/safeId" },
77
+ "order": { "type": "integer" },
78
+ "inPager": { "type": "boolean" }
79
+ }
80
+ }
23
81
  }
24
82
  }
25
83
  }
@@ -29,6 +29,8 @@
29
29
  "dir": { "type": "string", "minLength": 1 },
30
30
  "target": { "type": "string", "minLength": 1 },
31
31
  "pages": { "type": "string", "minLength": 1 },
32
+ "format": { "const": "markdown-v1" },
33
+ "template": { "const": "editorial" },
32
34
  "coverage": {
33
35
  "type": "object",
34
36
  "additionalProperties": false,
@@ -65,6 +67,10 @@
65
67
  "anyOf": [{ "type": "string" }, { "type": "number" }, { "type": "boolean" }]
66
68
  }
67
69
  }
70
+ },
71
+ "dependentRequired": {
72
+ "format": ["template"],
73
+ "template": ["format"]
68
74
  }
69
75
  }
70
76
  }
@@ -0,0 +1,213 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // 将受限 markdown-v1 编译为 editorial 模板的语义内容块。
3
+ import { Lexer } from 'marked';
4
+
5
+ const TOKEN = /@[A-Z0-9_]+@/g;
6
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
7
+
8
+ export class MarkdownV1Error extends TypeError {
9
+ constructor(message) {
10
+ super(`markdown-v1: ${message}`);
11
+ }
12
+ }
13
+
14
+ const fail = (message) => { throw new MarkdownV1Error(message); };
15
+
16
+ /** Replace configured facts as literal data, never as Markdown syntax. */
17
+ export function replaceMarkdownTokens(value, tokens, label = 'text') {
18
+ if (typeof value !== 'string') fail(`${label} must be a string`);
19
+ const replaced = value.replace(TOKEN, (match) => (
20
+ Object.hasOwn(tokens, match) ? String(tokens[match]) : match
21
+ ));
22
+ const residual = replaced.match(TOKEN);
23
+ if (residual) fail(`${label} contains unresolved token(s): ${[...new Set(residual)].join(', ')}`);
24
+ return replaced;
25
+ }
26
+
27
+ function inline(tokens, facts, label) {
28
+ const result = [];
29
+ for (const token of tokens || []) {
30
+ switch (token.type) {
31
+ case 'text':
32
+ if (token.tokens?.length) result.push(...inline(token.tokens, facts, label));
33
+ else result.push(replaceMarkdownTokens(token.text, facts, label));
34
+ break;
35
+ case 'escape':
36
+ result.push(replaceMarkdownTokens(token.text, facts, label));
37
+ break;
38
+ case 'strong':
39
+ case 'em':
40
+ result.push({ type: token.type, content: inline(token.tokens, facts, label) });
41
+ break;
42
+ case 'codespan':
43
+ result.push({ type: 'code', text: replaceMarkdownTokens(token.text, facts, label) });
44
+ break;
45
+ case 'link':
46
+ if (token.title) fail(`${label} link titles are not supported`);
47
+ result.push({
48
+ type: 'link',
49
+ href: replaceMarkdownTokens(token.href, facts, `${label} link`),
50
+ content: inline(token.tokens, facts, label),
51
+ });
52
+ break;
53
+ case 'image':
54
+ fail(`${label} images must be the only content in their paragraph`);
55
+ break;
56
+ case 'html':
57
+ fail(`${label} raw HTML is not supported`);
58
+ break;
59
+ default:
60
+ fail(`${label} uses unsupported inline syntax: ${token.type}`);
61
+ }
62
+ }
63
+ return result;
64
+ }
65
+
66
+ function listItem(item, facts, label) {
67
+ if (item.task || item.checked !== undefined) fail(`${label} task lists are not supported`);
68
+ const content = item.tokens.filter((token) => token.type !== 'space');
69
+ if (content.length !== 1 || !['text', 'paragraph'].includes(content[0].type)) {
70
+ fail(`${label} nested or multi-paragraph list items are not supported`);
71
+ }
72
+ const token = content[0];
73
+ return inline(token.tokens || Lexer.lexInline(token.text, { gfm: true }), facts, label);
74
+ }
75
+
76
+ function explicitHeading(token) {
77
+ const match = /^(.*\S)\s+\{#([A-Za-z0-9][A-Za-z0-9_-]*)\}\s*$/.exec(token.text);
78
+ if (!match) {
79
+ if (/\s+\{[^}]*\}\s*$/.test(token.text)) fail('heading contains an unsupported or invalid attribute extension');
80
+ return null;
81
+ }
82
+ return { title: match[1], id: match[2] };
83
+ }
84
+
85
+ function slug(title) {
86
+ const value = title
87
+ .normalize('NFKD')
88
+ .toLowerCase()
89
+ .replace(/[^a-z0-9_-]+/g, '-')
90
+ .replace(/^-+|-+$/g, '');
91
+ return value || 'section';
92
+ }
93
+
94
+ function callout(token, facts, label) {
95
+ const meaningful = token.tokens.filter((item) => item.type !== 'space');
96
+ if (meaningful.length !== 1 || meaningful[0].type !== 'paragraph') {
97
+ fail(`${label} callout must contain one paragraph`);
98
+ }
99
+ const match = /^\[!(NOTE|TIP|WARNING)\]\s*\n([\s\S]*\S)\s*$/.exec(meaningful[0].text);
100
+ if (!match) fail(`${label} blockquotes must use NOTE, TIP, or WARNING callout syntax`);
101
+ return {
102
+ type: 'callout',
103
+ tone: match[1],
104
+ content: inline(Lexer.lexInline(match[2], { gfm: true }), facts, label),
105
+ };
106
+ }
107
+
108
+ /**
109
+ * @param {string} source
110
+ * @param {{pageId:string,tokens?:Record<string,string|number|boolean>}} options
111
+ * @returns {{blocks:object[],images:string[]}}
112
+ */
113
+ export function compileMarkdownV1(source, { pageId, tokens = {} }) {
114
+ if (typeof source !== 'string' || source.includes('\u0000')) fail(`${pageId}.md must be UTF-8 text without NUL`);
115
+ if (!SAFE_ID.test(pageId)) fail(`invalid page ID: ${pageId}`);
116
+
117
+ let lexed;
118
+ try {
119
+ lexed = Lexer.lex(source, { gfm: true, breaks: false, pedantic: false });
120
+ } catch (cause) {
121
+ throw new MarkdownV1Error(`${pageId}.md cannot be tokenized: ${cause.message}`);
122
+ }
123
+
124
+ const explicitIds = new Set([pageId]);
125
+ for (const token of lexed) {
126
+ if (token.type !== 'heading') continue;
127
+ if (token.depth === 1) fail(`${pageId}.md must not contain an H1`);
128
+ if (![2, 3, 4].includes(token.depth)) fail(`${pageId}.md heading levels must be H2-H4`);
129
+ const explicit = explicitHeading(token);
130
+ if (!explicit) continue;
131
+ if (explicit.id.startsWith('editorial-')) fail(`${pageId}.md uses a reserved heading ID: ${explicit.id}`);
132
+ if (explicitIds.has(explicit.id)) fail(`${pageId}.md contains duplicate heading ID: ${explicit.id}`);
133
+ explicitIds.add(explicit.id);
134
+ }
135
+
136
+ const usedIds = new Set(explicitIds);
137
+ const blocks = [];
138
+ const images = new Set();
139
+ for (const token of lexed) {
140
+ const label = `${pageId}.md`;
141
+ switch (token.type) {
142
+ case 'space':
143
+ break;
144
+ case 'paragraph': {
145
+ if (token.tokens.length === 1 && token.tokens[0].type === 'image') {
146
+ const image = token.tokens[0];
147
+ if (image.title) fail(`${label} image titles are not supported`);
148
+ const src = replaceMarkdownTokens(image.href, tokens, `${label} image`);
149
+ const alt = replaceMarkdownTokens(image.text, tokens, `${label} image alt`);
150
+ blocks.push({ type: 'image', src, alt });
151
+ images.add(src);
152
+ } else {
153
+ blocks.push({ type: 'paragraph', content: inline(token.tokens, tokens, label) });
154
+ }
155
+ break;
156
+ }
157
+ case 'heading': {
158
+ const explicit = explicitHeading(token);
159
+ const title = explicit?.title ?? token.text;
160
+ let headingId = explicit?.id;
161
+ if (!headingId) {
162
+ const candidate = slug(title);
163
+ const base = candidate.startsWith('editorial-') ? `section-${candidate}` : candidate;
164
+ headingId = base;
165
+ let suffix = 2;
166
+ while (usedIds.has(headingId)) {
167
+ headingId = `${base}-${suffix}`;
168
+ suffix += 1;
169
+ }
170
+ usedIds.add(headingId);
171
+ }
172
+ const headingTokens = Lexer.lexInline(title, { gfm: true });
173
+ blocks.push({ type: 'heading', level: token.depth, id: headingId, content: inline(headingTokens, tokens, label) });
174
+ break;
175
+ }
176
+ case 'list':
177
+ if (token.ordered && token.start !== 1) fail(`${label} ordered lists must start at 1`);
178
+ blocks.push({
179
+ type: 'list',
180
+ ordered: token.ordered,
181
+ items: token.items.map((item, index) => listItem(item, tokens, `${label} list item ${index + 1}`)),
182
+ });
183
+ break;
184
+ case 'table':
185
+ blocks.push({
186
+ type: 'table',
187
+ headers: token.header.map((cell) => inline(cell.tokens, tokens, label)),
188
+ rows: token.rows.map((row) => row.map((cell) => inline(cell.tokens, tokens, label))),
189
+ });
190
+ break;
191
+ case 'code':
192
+ if (!token.lang || token.codeBlockStyle === 'indented') fail(`${label} code fences require a language marker`);
193
+ blocks.push({
194
+ type: 'code',
195
+ language: token.lang,
196
+ text: replaceMarkdownTokens(token.text, tokens, `${label} code`),
197
+ });
198
+ break;
199
+ case 'blockquote':
200
+ blocks.push(callout(token, tokens, label));
201
+ break;
202
+ case 'html':
203
+ fail(`${label} raw HTML is not supported`);
204
+ break;
205
+ case 'def':
206
+ fail(`${label} reference definitions are not supported`);
207
+ break;
208
+ default:
209
+ fail(`${label} uses unsupported block syntax: ${token.type}`);
210
+ }
211
+ }
212
+ return { blocks, images: [...images].sort() };
213
+ }