skill-family-doc-render 0.3.0 → 0.4.1

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,33 @@
1
1
  # 变更日志
2
2
 
3
+ ## 0.4.1 - 2026-09-07
4
+
5
+ ### 变更
6
+
7
+ - 与 `skill-family-docs` 和公开站点的 0.4.1 联合发布对齐版本。渲染器的公开 API、
8
+ 命令行参数与输入合同均没有变化;相同内容输入仍生成相同的站点字节。
9
+
10
+ ## 0.4.0 - 2026-09-07
11
+
12
+ ### 新增
13
+
14
+ - 增加显式成对的 `site.format: markdown-v1` 与 `site.template: editorial`。受限 Markdown
15
+ 由精确依赖 `marked@18.0.11` 分词,再经过本包的结构、链接和锚点校验;包内模板生成
16
+ 完整 HTML、分组目录、静态搜索、交互与首尾不循环的教程翻页。
17
+ - 增加包内 `editorial` 模板的 CSS、JavaScript 和 SVG 图标。新模式不读取项目自有主题
18
+ CSS 或 JavaScript,同一冻结输入会产生确定的页面及资源字节。
19
+ - 增加站内 SVG 主动内容检查。脚本、事件属性、`foreignObject`、DTD/实体声明,以及
20
+ 外部或 data 资源会在输出替换前被拒绝;SVG 不内联。
21
+ - 增加只读的 `--check-project --repo <name>`,依次检查输入与公开安全、Markdown 机械
22
+ 合同、覆盖状态、渲染与基线、内部链接和资源,并在失败时输出可复制的恢复提示。
23
+
24
+ ### 安全与兼容
25
+
26
+ - `site.format` 与 `site.template` 同时缺省时继续使用完整 HTML 注入。该路径只供既有
27
+ 消费者兼容,不获得新主题;新建站点使用 `markdown-v1` 与 `editorial`。
28
+ - `--check-project` 必须与单个 `--repo` 一起使用,不与其他模式组合。全程只读;状态
29
+ 问题返回 1,输入或工具问题返回 2。
30
+
3
31
  ## 0.3.0 - 2026-09-07
4
32
 
5
33
  ### 新增
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,70 @@ 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.1`. Verify that exact version in the
20
+ > official npm registry before treating it as installable. Version 0.4.1 preserves the
21
+ > `markdown-v1`, `editorial`, and `--check-project` behavior introduced in 0.4.0 and aligns the
22
+ > renderer, documentation plugin, and public site at 0.4.1 for the joint release.
19
23
 
20
24
  ## Install
21
25
 
22
- Install the current published release with an exact version:
26
+ After confirming availability, install this release with an exact version:
23
27
 
24
28
  ```sh
25
- npm install --save-exact skill-family-doc-render@0.2.1
29
+ npm install --save-exact skill-family-doc-render@0.4.1
26
30
  ```
27
31
 
28
32
  Requires Node.js `>=22.22.2 <23` (aligned with the skill-family foundation packages).
29
33
 
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).
34
+ Version 0.4.1 has exact runtime dependencies on `marked@18.0.11`,
35
+ `skill-family-contracts@0.18.0`, and `skill-family-harness-node@0.18.0`. `marked` supplies the
36
+ Markdown lexer; the renderer applies its own restricted-format validation before passing semantic
37
+ content to the template. The workspace profile check separately uses
38
+ `skill-family-engineering-kit@0.18.0`.
33
39
  The 0.2.0 baseline format and exit-code changes are breaking changes; existing 0.1.x users
34
40
  must re-render their baseline before using `--check`. See the changelog for the full migration
35
41
  notes.
36
42
 
37
43
  ## CLI
38
44
 
39
- The published 0.2.1 release supports rendering, drift checks, repository selection, and the Git
40
- index assertion:
45
+ Use the exact 0.4.1 CLI for rendering, checks, coverage maintenance, and the Git index assertion:
41
46
 
42
47
  ```sh
43
48
  # render all repos with a site field (writes to disk)
44
- npx skill-family-doc-render@0.2.1
49
+ npx skill-family-doc-render@0.4.1
45
50
 
46
51
  # drift check only: compare in-memory render against committed site-baseline.json, write nothing
47
- npx skill-family-doc-render@0.2.1 --check
52
+ npx skill-family-doc-render@0.4.1 --check
48
53
 
49
54
  # render / check a single repo by name
50
- npx skill-family-doc-render@0.2.1 --repo <name>
55
+ npx skill-family-doc-render@0.4.1 --repo <name>
51
56
 
52
57
  # 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>
58
+ npx skill-family-doc-render@0.4.1 --assert-git
63
59
 
64
- # refresh only the reviewed-input snapshot after semantic and style review
65
- node bin/skill-family-doc-render.mjs --refresh-coverage --repo <name>
60
+ # inspect or refresh the reviewed product-input snapshot
61
+ npx skill-family-doc-render@0.4.1 --status --repo <name>
62
+ npx skill-family-doc-render@0.4.1 --refresh-coverage --repo <name>
63
+ npx skill-family-doc-render@0.4.1 --check-project --repo <name>
66
64
  ```
67
65
 
68
- After 0.3.0 is published, consumers can install or invoke that exact release:
66
+ The current directory must be the workspace that owns `public-release.json`. From this package's
67
+ source checkout, `node bin/skill-family-doc-render.mjs` is the equivalent local CLI entry.
69
68
 
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
- ```
69
+ `--check-project` requires exactly one `--repo <name>`. It cannot be combined with `--check`,
70
+ `--status`, `--refresh-coverage`, `--assert-git`, or `--help`. It checks input and public-content
71
+ safety, the mechanical Markdown contract, coverage freshness, the in-memory render and baseline,
72
+ then internal links and resources. It writes nothing. A failure identifies the repo,
73
+ `public-release.json`, the reason, and a copyable `skill-family-docs-render-site` recovery prompt.
74
74
 
75
- Exit codes: `0` success; `1` drift class — `--check` drift, leak-scan hit, `--assert-git`
75
+ Exit codes: `0` success; `1` drift/status class — `--check` drift, leak-scan hit, `--assert-git`
76
76
  missing files, missing/corrupt render baseline, or missing/stale coverage snapshot; `2`
77
77
  configuration/tool class — missing/invalid `public-release.json` or `pages.json`, JSON parse
78
78
  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
79
+ Git failure (the message includes the JSON field path). `--check-project` uses the same classes:
80
+ public-safety, coverage, render, link, or resource findings return `1`; invalid input,
81
+ configuration, or tool errors return `2`.
82
+ When several repos are
80
83
  rendered in one run, each repo is isolated: a failure in one does not stop the others, and the
81
84
  run ends with a summary of successes/failures plus a non-zero exit (2 if any failure was
82
85
  configuration-class, otherwise 1).
@@ -85,8 +88,8 @@ configuration-class, otherwise 1).
85
88
 
86
89
  The config file lives at the workspace root (resolved from `process.cwd()`). It is validated
87
90
  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.
91
+ anything runs. The paired `format` and `template` fields in the example below require version
92
+ 0.4.0 or later.
90
93
 
91
94
  ```json
92
95
  {
@@ -102,6 +105,8 @@ anything runs. `coverage` and `versionSources` in the example below belong to th
102
105
  "dir": "site-src",
103
106
  "target": "docs",
104
107
  "pages": "pages.json",
108
+ "format": "markdown-v1",
109
+ "template": "editorial",
105
110
  "coverage": {
106
111
  "lock": "site-coverage-lock.json",
107
112
  "inputs": ["package.json", "src/**", "schemas/**"]
@@ -128,10 +133,12 @@ anything runs. `coverage` and `versionSources` in the example below belong to th
128
133
 
129
134
  - Only repos with a `site` field are rendered; `--repo <name>` selects one by `name`
130
135
  (`--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_-]*`.
136
+ - `site.format` and `site.template` are paired. New sites use only `markdown-v1` and `editorial`.
137
+ Omitting both selects the legacy HTML compatibility path.
138
+ - `site.pages` points to a manifest validated against `schemas/pages.schema.json`. A
139
+ `markdown-v1` manifest declares site metadata, navigation groups, and each page's `id`, title,
140
+ reader role, layout kind, group, unique order, and `inPager` state. The page `id` is restricted
141
+ to `[A-Za-z0-9][A-Za-z0-9_-]*`.
135
142
  - `forbiddenPublicPaths` / `privateLiterals` append project-specific literals to the leak scan.
136
143
  - `site.coverage` declares reviewed product inputs relative to `repo.source`. Each input accepts
137
144
  `*` and `**` glob syntax and must match at least one file after the coverage lock itself is
@@ -141,14 +148,37 @@ anything runs. `coverage` and `versionSources` in the example below belong to th
141
148
  the scalar value without copying the version into configuration. Every referenced JSON file
142
149
  automatically joins the coverage inputs.
143
150
 
144
- ## Placeholders
151
+ ## Input formats
152
+
153
+ ### Restricted Markdown and the editorial template
145
154
 
146
- Source pages use three HTML-comment injection points, replaced at render time:
155
+ Each `markdown-v1` page is `site.dir/<id>.md`, with an exact one-to-one match to the manifest.
156
+ The format supports paragraphs, level 2–4 headings, ordered and unordered lists, tables, inline
157
+ links and emphasis, fenced code with a language, `NOTE` / `TIP` / `WARNING` callouts, and local
158
+ SVG images. The manifest supplies the only H1. A heading may end with `{#stable-id}`; otherwise a
159
+ deterministic slug is generated. Raw HTML, MDX, task or nested lists, indented or unlabelled code,
160
+ ordinary blockquotes, and arbitrary attributes are rejected before output replacement.
161
+
162
+ The editorial template owns HTML, CSS, JavaScript, icons, navigation, search, and the non-circular
163
+ tutorial pager. Projects using `markdown-v1` do not supply theme CSS or JavaScript. Project images
164
+ are limited to contained SVG files. Before rendering, SVG safety checks reject scripts, event
165
+ attributes, `foreignObject`, DTD/entity declarations, and external or data resources; SVG remains
166
+ an external image and is never inlined.
167
+
168
+ ### Legacy HTML compatibility
169
+
170
+ When `format` and `template` are omitted, source pages remain complete HTML and use three comment
171
+ injection points:
147
172
 
148
173
  - `<!--NAV-->` — table-of-contents nav (active page gets `class="active"`)
149
- - `<!--PAGER-->` — circular prev/next pager
174
+ - `<!--PAGER-->` — legacy circular prev/next pager
150
175
  - `<!--FOOTER-->` — site footer (owner + license)
151
176
 
177
+ Legacy HTML remains available for existing consumers, but it does not receive the editorial
178
+ theme. New sites should not start on this compatibility path.
179
+
180
+ ## Version placeholders
181
+
152
182
  Version tokens are derived automatically from the repo's `package.json` version and `name`
153
183
  (uppercased, non-alphanumeric runs collapsed to `_`):
154
184
 
@@ -189,7 +219,7 @@ changes.
189
219
 
190
220
  ## Leak scan
191
221
 
192
- Every output file — rendered pages **and** copied assets (`.html`/`.css`/`.js`/`.svg`) — is
222
+ Every output file — rendered pages **and** copied or template assets (`.html`/`.css`/`.js`/`.svg`) — is
193
223
  scanned **before** any write (`scripts/lib/leak-scan.mjs`). The scanner decodes HTML entities
194
224
  (two passes, defeating double encoding), JS escape sequences and URL percent-encoding first,
195
225
  then matches case-insensitively against built-in private literals (absolute home paths) and
@@ -221,8 +251,8 @@ The digest is a deterministic set-level sha256 over the contained resources.
221
251
 
222
252
  ## Foundation dependencies
223
253
 
224
- This package is a thin domain layer over the skill-family foundation and adds no other runtime
225
- dependencies:
254
+ This package is a thin domain layer over the skill-family foundation. In addition to the two
255
+ Foundation packages below, it uses exact `marked@18.0.11` for Markdown tokenization:
226
256
 
227
257
  - [`skill-family-harness-node`](https://github.com/ifoohoo/skill-family-harness-node) — all file
228
258
  reads go through `resolveContained` / `readFileContained` (path traversal, symlink and realpath
@@ -244,9 +274,9 @@ dependencies:
244
274
  Apache-2.0
245
275
 
246
276
  <!-- 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:
277
+ > **Start here:** run `npx skill-family-doc-render@0.4.1 --check-project --repo <name>` after
278
+ > confirming that exact release in the official registry. From a local checkout, use
279
+ > `node bin/skill-family-doc-render.mjs --check-project --repo <name>`. The check is read-only:
250
280
  > it renders in memory, compares against the committed `site-baseline.json`, writes
251
281
  > nothing and touches no network or credentials. The CLI performs no Git writes; only an
252
282
  > explicit `--assert-git` performs a read-only query of the Git index.
@@ -255,7 +285,7 @@ Apache-2.0
255
285
  > **External write boundary:** rendering writes the declared target and a controlled sibling
256
286
  > staging directory, both within the target's canonical parent, with contained path checks. An
257
287
  > explicit `--refresh-coverage` writes only the declared coverage lock through an atomic file
258
- > replacement. `--status` and `--check` write nothing.
288
+ > replacement. `--status`, `--check`, and `--check-project` write nothing.
259
289
  > Render, scan, baseline and `--assert-git` preflight failures happen before target changes.
260
290
  > Staging is removed after success or a clearly pre-commit failure; post-commit or
261
291
  > indeterminate failures may retain it to preserve diagnosable state.
@@ -265,12 +295,9 @@ Apache-2.0
265
295
 
266
296
  ## Minimal example
267
297
 
268
- Use the local checkout while 0.3.0 remains unpublished:
298
+ Display help and run a read-only project check:
269
299
 
270
300
  ```sh
271
- node bin/skill-family-doc-render.mjs --help
272
- node bin/skill-family-doc-render.mjs --check
301
+ npx skill-family-doc-render@0.4.1 --help
302
+ npx skill-family-doc-render@0.4.1 --check-project --repo <name>
273
303
  ```
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.1",
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
+ }