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 +28 -0
- package/README.md +85 -58
- package/package.json +3 -1
- package/schemas/pages.schema.json +76 -18
- package/schemas/public-release.schema.json +6 -0
- package/scripts/lib/markdown-v1.mjs +213 -0
- package/scripts/lib/rendered-links.mjs +124 -0
- package/scripts/lib/svg-safety.mjs +45 -0
- package/scripts/render-public-site.mjs +187 -36
- package/templates/editorial/README.md +129 -0
- package/templates/editorial/assets/mark.svg +1 -0
- package/templates/editorial/assets/site.js +220 -0
- package/templates/editorial/assets/style.css +330 -0
- package/templates/editorial/index.mjs +283 -0
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
|
|
6
|
-
`site` field from its site source directory to its target directory (typically `docs/`)
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
> **
|
|
17
|
-
>
|
|
18
|
-
> `--
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
31
|
-
`skill-family-contracts@0.18.0`, `skill-family-harness-node@0.18.0`
|
|
32
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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).
|
|
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. `
|
|
89
|
-
0.
|
|
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.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
`
|
|
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
|
-
##
|
|
151
|
+
## Input formats
|
|
152
|
+
|
|
153
|
+
### Restricted Markdown and the editorial template
|
|
145
154
|
|
|
146
|
-
|
|
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
|
|
225
|
-
|
|
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:**
|
|
248
|
-
>
|
|
249
|
-
>
|
|
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
|
|
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
|
-
|
|
298
|
+
Display help and run a read-only project check:
|
|
269
299
|
|
|
270
300
|
```sh
|
|
271
|
-
|
|
272
|
-
|
|
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
|
+
"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": "
|
|
6
|
-
"
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
"
|
|
12
|
-
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
|
|
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
|
+
}
|