skill-family-doc-render 0.1.1 → 0.2.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 ADDED
@@ -0,0 +1,74 @@
1
+ # 变更日志
2
+
3
+ ## 0.2.1 - 2026-09-06
4
+
5
+ ### 变更
6
+
7
+ - 生成的主导航带有可访问名称,并用 `aria-current="page"` 标出当前页。
8
+ - 教程翻页导航带有可访问名称;不参加翻页链的页面不再生成空导航元素。
9
+
10
+ ## 0.2.0 - 2026-09-05
11
+
12
+ 发布坐标:tag `skill-family-doc-render-v0.2.0`。
13
+
14
+ ### 不兼容变更
15
+
16
+ - `site-baseline.json` 基线格式升级:新增必填字段 `digests`(渲染产物逐文件
17
+ sha256)。`--check` 由「磁盘 docs/ 与基线比对」改为三重比对:内存渲染产物
18
+ 逐文件摘要对 `digests`、磁盘实际文件清单对 `files` 集合、磁盘树摘要对
19
+ `sha256`。0.1.x 旧格式基线(无 `digests`)会被新 schema 拒绝(exit 2);
20
+ 升级后必须重渲一次(`npx skill-family-doc-render@0.2.0`)更新基线。
21
+ - 退出码语义统一:exit 1 归漂移/泄漏类(`--check` 漂移、泄漏扫描命中、
22
+ `--assert-git` 缺文件、基线缺失/损坏);exit 2 归配置类(配置缺失或非法、
23
+ JSON 解析失败、`@TOKEN@` 占位符残留、`--repo` 误用)。0.1.x 中泄漏失败与
24
+ 配置失败同为 exit 1,且 JSON 解析失败会以裸异常漏出。
25
+ - `--repo` 后缺有效值现在直接报错 exit 2;0.1.1 会静默当作「渲染全部」。
26
+
27
+ ### 新增
28
+
29
+ - `pages.json` 走 JSON Schema(`schemas/pages.schema.json`):缺 `pages` 字段
30
+ 或非法 JSON 由裸 TypeError 变为 fail-fast exit 2。
31
+ - `--check` 能抓到「源改未重渲」与「渲染器回归」两类漂移(0.1.x 只能抓
32
+ 产物被手改)。
33
+
34
+ ### 变更
35
+
36
+ - 泄漏扫描覆盖 assets(`.html`/`.css`/`.js`/`.svg`)写盘前扫描;多趟解码
37
+ 防绕过(HTML 实体双趟、JS 反转义、URL 百分号解码),字面量大小写不敏感。
38
+ 默认字面量与正则一字未动;扩展仍只经 `forbiddenPublicPaths` /
39
+ `privateLiterals` 配置追加。
40
+ - 未替换的 `@TOKEN@` 占位符残留 fail-fast(exit 2),列出 token 与所在文件。
41
+ - 写盘前预检:在 target 的 canonical parent 内创建 sibling staging,全部产物经 Foundation
42
+ `writeFileAtomic` 写入。渲染、扫描、基线计算和 `--assert-git` 预检全部成功后才发布:
43
+ target 已存在时用 `replaceFixedSetAtomic` 单次交换,成功后旧 target 位于
44
+ `displacedTargetPath`,仅清理该路径;target 不存在时先用
45
+ `createFixedSetPublicationManifest` 生成清单,再用 `publishFixedSet` 发布。预检或未提交
46
+ 失败时清理 staging,既有 target 保持不变;交换成功后的旧 target 清理失败不回滚已完成的发布。
47
+ - 多仓遍历失败隔离:逐仓收集结果,单仓失败不中断其他仓,末尾汇总并非零退出。
48
+ - `package.json` 缺失或缺 `version` 时输出醒目 WARNING 并回退 0.0.0
49
+ (0.1.x 静默回退);`package.json` 非法 JSON 为 fail-fast exit 2。
50
+ - `--assert-git` 在写盘前检查,期望清单从实际渲染产物派生,不再写死文件名;改用
51
+ `execFileSync` 数组参数,消除 shell 注入面。
52
+ - Foundation 依赖统一精确锁定到 0.18.0:`skill-family-harness-node`、
53
+ `skill-family-contracts` 与工作区使用的 `skill-family-engineering-kit` 均须从官方
54
+ npm registry 安装并复核;不接受 `latest`、本地工作树、候选 tarball 或浮动版本。
55
+
56
+ ## 0.1.1 - 2026-08-12
57
+
58
+ ### 变更
59
+
60
+ - 自 0.1.0 以来:CLI 新增 `--help` 输出与 README 最小示例(`34c8bfd`);
61
+ 引入 release-skill 发布配置与 README 合同标记(`d194801`)。
62
+ - 版本号随技能族 codebuddy 市场分发路径调整统一抬升,渲染器实现本身无行为变化。
63
+
64
+ 发布坐标:tag `skill-family-doc-render-v0.1.1`(公开仓 commit `8b9c3d67`)。
65
+
66
+ ## 0.1.0 - 2026-08-12
67
+
68
+ ### 新增
69
+
70
+ - 初始版本:配置驱动的 GitHub Pages 知识站渲染器。读取工作区根
71
+ `public-release.json`,遍历带 `site` 字段的 repo 渲染到各自 target;
72
+ nav / pager / footer 注入、版本占位符替换、assets 拷贝、`.nojekyll` 与
73
+ `site-baseline.json` 树基线、`--check` 漂移检测、`--repo` 单仓选择、
74
+ 写盘前内容级泄漏扫描。
package/README.md CHANGED
@@ -5,34 +5,52 @@ Config-driven GitHub Pages knowledge-site renderer for skill-family projects (mu
5
5
  Reads `public-release.json` from the current working directory, renders every repo carrying a
6
6
  `site` field from its site source directory to its target directory (typically `docs/`), injects
7
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 before any write.
8
+ tree baseline (`site-baseline.json`), and runs a content-level leak scan over every output file
9
+ (pages **and** assets) before any target change. Render, scan, baseline computation and the
10
+ `--assert-git` preflight all finish before the target is changed, so failures in those steps
11
+ leave the existing target untouched. The final directory commit uses Foundation's fixed-set
12
+ publication APIs. A failure before commit removes staging and leaves the existing target
13
+ untouched. After commit, or when publication is indeterminate, staging or displaced data may
14
+ remain for diagnosis; the renderer does not guess or roll back.
9
15
 
10
16
  ## Install
11
17
 
12
18
  ```sh
13
- npm install skill-family-doc-render
19
+ npm install skill-family-doc-render@0.2.0
14
20
  ```
15
21
 
16
22
  Requires Node.js `>=22.22.2 <23` (aligned with the skill-family foundation packages).
17
23
 
24
+ 0.2.0 consumes the three Foundation packages at the exact `0.18.0` pin:
25
+ `skill-family-contracts@0.18.0`, `skill-family-harness-node@0.18.0` and
26
+ `skill-family-engineering-kit@0.18.0` (the last one is used by the workspace profile check).
27
+ The 0.2.0 baseline format and exit-code changes are breaking changes; existing 0.1.x users
28
+ must re-render their baseline before using `--check`. See the changelog for the full migration
29
+ notes.
30
+
18
31
  ## CLI
19
32
 
20
33
  ```sh
21
34
  # render all repos with a site field (writes to disk)
22
- npx skill-family-doc-render
35
+ npx skill-family-doc-render@0.2.0
23
36
 
24
37
  # drift check only: compare in-memory render against committed site-baseline.json, write nothing
25
- npx skill-family-doc-render --check
38
+ npx skill-family-doc-render@0.2.0 --check
26
39
 
27
40
  # render / check a single repo by name
28
- npx skill-family-doc-render --repo <name>
41
+ npx skill-family-doc-render@0.2.0 --repo <name>
29
42
 
30
43
  # additionally assert every rendered file is tracked in the git index
31
- npx skill-family-doc-render --assert-git
44
+ npx skill-family-doc-render@0.2.0 --assert-git
32
45
  ```
33
46
 
34
- Exit codes: `0` success, `1` drift / check failure / leak-scan hit, `2` configuration or
35
- schema validation error (the message includes the JSON field path).
47
+ Exit codes: `0` success; `1` drift class — `--check` drift, leak-scan hit, `--assert-git`
48
+ missing files, missing/corrupt baseline; `2` configuration class — missing/invalid
49
+ `public-release.json` or `pages.json`, JSON parse failures, unreplaced `@TOKEN@` placeholders,
50
+ invalid `--repo` usage (the message includes the JSON field path). When several repos are
51
+ rendered in one run, each repo is isolated: a failure in one does not stop the others, and the
52
+ run ends with a summary of successes/failures plus a non-zero exit (2 if any failure was
53
+ configuration-class, otherwise 1).
36
54
 
37
55
  ## Configuration: `public-release.json`
38
56
 
@@ -63,9 +81,12 @@ anything runs.
63
81
  }
64
82
  ```
65
83
 
66
- - Only repos with a `site` field are rendered; `--repo <name>` selects one by `name`.
67
- - `site.pages` points to a pages manifest: `{ "pages": [{ "id", "title", "order", "inPager" }] }`.
68
- Pages are sorted by `order`; `inPager: false` excludes a page from the prev/next pager.
84
+ - Only repos with a `site` field are rendered; `--repo <name>` selects one by `name`
85
+ (`--repo` without a valid value is an error, never a silent "render all").
86
+ - `site.pages` points to a pages manifest: `{ "pages": [{ "id", "title", "order", "inPager" }] }`,
87
+ validated against `schemas/pages.schema.json`. Pages are sorted by `order`;
88
+ `inPager: false` excludes a page from the prev/next pager. Page `id` is restricted to
89
+ `[A-Za-z0-9][A-Za-z0-9_-]*`.
69
90
  - `forbiddenPublicPaths` / `privateLiterals` append project-specific literals to the leak scan.
70
91
 
71
92
  ## Placeholders
@@ -82,26 +103,45 @@ Version tokens are derived automatically from the repo's `package.json` version
82
103
  - `@{NAME}_TAG@` — e.g. `my-project` → `@MY_PROJECT_TAG@` = `tagPrefix` + version
83
104
  - `@{NAME}_VERSION@` — the bare version
84
105
 
85
- `site.tokens` adds arbitrary static placeholder replacements on top.
106
+ `site.tokens` adds arbitrary static placeholder replacements on top. If a rendered page still
107
+ contains an unreplaced `@TOKEN@` (`@[A-Z0-9_]+@`), the run fails fast (exit 2) naming the
108
+ tokens and the file — placeholder typos never slip into the published site silently.
109
+
110
+ If a repo has no `package.json` (or no valid `version`), the version falls back to `0.0.0`
111
+ with a loud `WARNING` on stderr, because that fallback is baked into the output and the
112
+ baseline; a malformed `package.json` fails fast (exit 2).
86
113
 
87
114
  ## Leak scan
88
115
 
89
- Every rendered page is scanned **before** any write (`scripts/lib/leak-scan.mjs`). The scanner
90
- decodes HTML entities and JS escape sequences first, then matches the decoded text against
91
- built-in private literals (absolute home paths) and secret regexes (private keys, GitHub /
92
- Slack / AWS / OpenAI tokens), plus the configured `forbiddenPublicPaths` / `privateLiterals`.
93
- Any hit fails the run fast — encoding tricks do not slip through.
116
+ Every output file — rendered pages **and** copied assets (`.html`/`.css`/`.js`/`.svg`) — is
117
+ scanned **before** any write (`scripts/lib/leak-scan.mjs`). The scanner decodes HTML entities
118
+ (two passes, defeating double encoding), JS escape sequences and URL percent-encoding first,
119
+ then matches case-insensitively against built-in private literals (absolute home paths) and
120
+ secret regexes (private keys, GitHub / Slack / AWS / OpenAI tokens), plus the configured
121
+ `forbiddenPublicPaths` / `privateLiterals`. Any hit fails the run fast (exit 1) — encoding
122
+ tricks do not slip through.
94
123
 
95
124
  ## Tree baseline
96
125
 
97
- After writing, the renderer records `site-baseline.json` in the target directory:
126
+ After staging, the renderer records `site-baseline.json` in the target directory:
98
127
 
99
128
  ```json
100
- { "sha256": "<closure digest>", "files": ["<sorted relative paths, excluding the baseline itself>"] }
129
+ {
130
+ "sha256": "<closure digest over the staged tree>",
131
+ "files": ["<sorted relative paths, excluding the baseline itself>"],
132
+ "digests": { "<path>": "<per-file sha256 of the rendered bytes>" }
133
+ }
101
134
  ```
102
135
 
103
- `--check` recomputes the digest from the in-memory render and fails on any drift. The digest is
104
- a deterministic set-level sha256 over the contained resources.
136
+ `--check` is read-only and fails on any of three drift classes:
137
+
138
+ 1. **Stale render / renderer regression** — per-file sha256 of the in-memory render vs
139
+ `digests` (catches "source changed but never re-rendered" and "renderer output changed");
140
+ 2. **Hand-added or deleted files** — the actual on-disk file listing of the target vs the
141
+ `files` set;
142
+ 3. **Hand-modified output** — the on-disk tree closure digest vs `sha256`.
143
+
144
+ The digest is a deterministic set-level sha256 over the contained resources.
105
145
 
106
146
  ## Foundation dependencies
107
147
 
@@ -110,30 +150,41 @@ dependencies:
110
150
 
111
151
  - [`skill-family-harness-node`](https://github.com/ifoohoo/skill-family-harness-node) — all file
112
152
  reads go through `resolveContained` / `readFileContained` (path traversal, symlink and realpath
113
- escapes rejected), all writes through `writeFileAtomic` (containment + atomic rename), and the
114
- tree baseline is computed with `computeResourceClosure`.
153
+ escapes rejected), per-file digests use `digestBytes`, the tree baseline is computed with
154
+ `computeResourceClosure`. Output is staged in a sibling directory under the target's canonical
155
+ parent, with contained writes through `writeFileAtomic`. A new target uses
156
+ `createFixedSetPublicationManifest` followed by `publishFixedSet`; an existing target uses
157
+ `replaceFixedSetAtomic`. Staging is removed after success or a clearly pre-commit failure;
158
+ post-commit or indeterminate failures may retain it for diagnosis. These APIs do not add a
159
+ broader atomicity guarantee than Foundation provides.
115
160
  - [`skill-family-contracts`](https://github.com/ifoohoo/skill-family-contracts) —
116
- `validateDocument` (JSON Schema 2020-12, strict policy) validates `public-release.json` and
117
- `site-baseline.json`; validation failure aborts with exit code 2.
161
+ `validateDocument` (JSON Schema 2020-12, strict policy) validates `public-release.json`,
162
+ `pages.json` and `site-baseline.json`; validation failure aborts with exit code 2.
118
163
 
119
164
  ## License
120
165
 
121
166
  Apache-2.0
122
167
 
123
168
  <!-- release-skill:capability:safe-first-command -->
124
- > **Start here:** `npx skill-family-doc-render --check` — the drift check is read-only:
169
+ > **Start here:** after installing the exact `skill-family-doc-render@0.2.0`, run
170
+ > `./node_modules/.bin/skill-family-doc-render --check` — the drift check is read-only:
125
171
  > it renders in memory, compares against the committed `site-baseline.json`, writes
126
- > nothing, and touches no network, git, or credentials.
172
+ > nothing and touches no network or credentials. The CLI performs no Git writes; only an
173
+ > explicit `--assert-git` performs a read-only query of the Git index.
127
174
 
128
175
  <!-- release-skill:capability:external-write-boundary -->
129
- > **External write boundary:** this CLI writes only inside the site target directories
130
- > declared by the project's own `public-release.json` (via contained, atomic writes).
131
- > It performs no git operations, no network calls, and no writes outside those
132
- > directories. The pre-write leak scan fails closed on private literals.
176
+ > **External write boundary:** this CLI writes the declared target and a controlled sibling
177
+ > staging directory, both within the target's canonical parent, with contained path checks.
178
+ > Render, scan, baseline and `--assert-git` preflight failures happen before target changes.
179
+ > Staging is removed after success or a clearly pre-commit failure; post-commit or
180
+ > indeterminate failures may retain it to preserve diagnosable state.
181
+ > It performs no Git writes, no network calls, and no writes outside that parent scope.
182
+ > Only an explicit `--assert-git` performs a read-only query of the Git index. The pre-write
183
+ > leak scan fails closed on private literals.
133
184
 
134
185
  ## Minimal example
135
186
 
136
187
  ```sh
137
- npx skill-family-doc-render --help # show usage
138
- npx skill-family-doc-render --check # read-only drift check — the safe first command
188
+ npx skill-family-doc-render@0.2.0 --help # show usage
189
+ npx skill-family-doc-render@0.2.0 --check # read-only drift check — the safe first command
139
190
  ```
@@ -1,29 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  // SPDX-License-Identifier: Apache-2.0
3
- // CLI 入口:从 process.cwd() 解析 public-release.json,
4
- // 转发 --check / --repo <name> / --assert-git 到渲染器主流程。
3
+ // CLI 入口:全部 argv 原样委托给 main(),闭集解析与 --help/-h 呈现统一由 main() 裁决。
5
4
  import { main, RenderError } from '../scripts/render-public-site.mjs';
6
5
 
7
- const argv = process.argv.slice(2);
8
-
9
- if (argv.includes('--help') || argv.includes('-h')) {
10
- console.log(`skill-family-doc-render — config-driven GitHub Pages knowledge-site renderer
11
-
12
- Usage:
13
- skill-family-doc-render render every repo with a site field (writes)
14
- skill-family-doc-render --check read-only drift check against site-baseline.json
15
- skill-family-doc-render --repo <name> render / check a single repo by name
16
- skill-family-doc-render --assert-git also assert rendered files are git-tracked
17
-
18
- Reads public-release.json from the current working directory.`);
19
- process.exitCode = 0;
20
- } else {
21
- main(argv, { cwd: process.cwd() })
22
- .then((code) => {
23
- process.exitCode = code;
24
- })
25
- .catch((err) => {
26
- console.error(err.message);
27
- process.exitCode = err instanceof RenderError ? err.exitCode : 1;
28
- });
29
- }
6
+ main(process.argv.slice(2), { cwd: process.cwd() })
7
+ .then((code) => {
8
+ process.exitCode = code;
9
+ })
10
+ .catch((err) => {
11
+ console.error(err.message);
12
+ process.exitCode = err instanceof RenderError ? err.exitCode : 1;
13
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skill-family-doc-render",
3
- "version": "0.1.1",
3
+ "version": "0.2.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",
@@ -16,6 +16,7 @@
16
16
  "scripts",
17
17
  "schemas",
18
18
  "README.md",
19
+ "CHANGELOG.md",
19
20
  "LICENSE"
20
21
  ],
21
22
  "scripts": {
@@ -24,7 +25,7 @@
24
25
  "render:site:check": "node bin/skill-family-doc-render.mjs --check"
25
26
  },
26
27
  "dependencies": {
27
- "skill-family-contracts": "0.2.1",
28
- "skill-family-harness-node": "0.2.1"
28
+ "skill-family-contracts": "0.18.0",
29
+ "skill-family-harness-node": "0.18.0"
29
30
  }
30
31
  }
@@ -0,0 +1,27 @@
1
+ {
2
+ "$id": "https://github.com/ifoohoo/skill-family-doc-render/schemas/pages.schema.json",
3
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
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" }
23
+ }
24
+ }
25
+ }
26
+ }
27
+ }
@@ -2,15 +2,19 @@
2
2
  "$id": "https://github.com/ifoohoo/skill-family-doc-render/schemas/site-baseline.schema.json",
3
3
  "$schema": "https://json-schema.org/draft/2020-12/schema",
4
4
  "title": "site-baseline.json",
5
- "description": "站点树基线:closure 集合级 sha256 + 排序后的相对路径列表(排除基线自身)。",
5
+ "description": "站点树基线:closure 集合级 sha256 + 排序后的相对路径列表(排除基线自身)+ 逐文件 sha256 摘要(--check 用于内存渲染产物逐文件比对)。",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
- "required": ["sha256", "files"],
8
+ "required": ["sha256", "files", "digests"],
9
9
  "properties": {
10
10
  "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
11
11
  "files": {
12
12
  "type": "array",
13
13
  "items": { "type": "string", "minLength": 1 }
14
+ },
15
+ "digests": {
16
+ "type": "object",
17
+ "additionalProperties": { "type": "string", "pattern": "^[0-9a-f]{64}$" }
14
18
  }
15
19
  }
16
20
  }
@@ -1,7 +1,9 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
- // 内容级泄漏扫描:解码后再匹配,防 HTML 实体 / JS 反转义绕过。
2
+ // 内容级泄漏扫描:解码后再匹配,防 HTML 实体 / JS 反转义 / URL 百分号编码绕过。
3
3
  // 默认扫描绝对路径与私钥 / Token;项目特定的私有字面量通过调用方传入
4
4
  // (来自 public-release.json 的 forbiddenPublicPaths / privateLiterals)。
5
+ // 字面量匹配大小写不敏感;实体解码跑两趟(防双重编码)。
6
+ // 命中抛 LeakScanError,由调用方归入各自的错误分类体系。
5
7
  export const DEFAULT_LITERALS = [
6
8
  '/Users/',
7
9
  'C:\\Users\\',
@@ -20,37 +22,65 @@ export const DEFAULT_REGEXES = [
20
22
  /sk-[A-Za-z0-9]{32,}/,
21
23
  ];
22
24
 
23
- // 对文本做 HTML 实体解码 + JS 反转义,得到规范化文本,与原文拼接后扫描,
24
- // 使绕过者无法用实体 / 转义把敏感字面量藏过简单 includes 检查。
25
- export function decodeText(s) {
26
- let d = s
25
+ // 泄漏命中专用错误类型:调用方可据 instanceof 归类退出码,不与配置错误混淆。
26
+ export class LeakScanError extends Error {}
27
+
28
+ // HTML 实体解码单趟。允许常见实体省略数字实体末尾分号(浏览器兼容形式)。
29
+ function entityDecode(s) {
30
+ return s
27
31
  .replace(/&amp;/g, '&')
28
32
  .replace(/&lt;/g, '<')
29
33
  .replace(/&gt;/g, '>')
30
34
  .replace(/&quot;/g, '"')
31
- .replace(/&#x([0-9a-fA-F]+);/g, (m, h) => String.fromCodePoint(parseInt(h, 16)))
32
- .replace(/&#(\d+);/g, (m, d) => String.fromCodePoint(parseInt(d, 10)));
33
- d = d
35
+ .replace(/&sol;/gi, '/')
36
+ .replace(/&#x([0-9a-fA-F]+);?/gi, (m, h) => String.fromCodePoint(parseInt(h, 16)))
37
+ .replace(/&#(\d+);?/g, (m, d) => String.fromCodePoint(parseInt(d, 10)));
38
+ }
39
+
40
+ // JS 转义序列反转义(\xNN / \uNNNN / \u{N} / 八进制 \NNN)。
41
+ function jsUnescape(s) {
42
+ return s
34
43
  .replace(/\\x([0-9a-fA-F]{2})/g, (m, h) => String.fromCharCode(parseInt(h, 16)))
35
44
  .replace(/\\u\{([0-9a-fA-F]+)\}/g, (m, h) => String.fromCodePoint(parseInt(h, 16)))
36
45
  .replace(/\\u([0-9a-fA-F]{4})/g, (m, h) => String.fromCharCode(parseInt(h, 16)))
37
46
  .replace(/\\([0-7]{3})/g, (m, o) => String.fromCharCode(parseInt(o, 8)));
38
- return s + '\n' + d;
47
+ }
48
+
49
+ // URL 百分号解码(逐字节 %XX → 字符;不调用 decodeURIComponent,畸形输入不抛)。
50
+ function pctDecode(s) {
51
+ return s.replace(/%([0-9a-fA-F]{2})/g, (m, h) => String.fromCharCode(parseInt(h, 16)));
52
+ }
53
+
54
+ // 对文本做多趟解码:HTML 实体两趟(防双重编码)→ JS 反转义 → URL 百分号解码,
55
+ // 各趟结果与原文拼接后统一扫描,使绕过者无法用各种编码把敏感字面量藏过匹配。
56
+ export function decodeText(s) {
57
+ const entities = entityDecode(entityDecode(s));
58
+ const unescaped = jsUnescape(entities);
59
+ // 固定两趟百分号解码,覆盖双重编码但不允许输入驱动的无界循环。
60
+ const percent = pctDecode(unescaped);
61
+ const percentTwice = pctDecode(percent);
62
+ return [s, entities, unescaped, percent, percentTwice].join('\n');
39
63
  }
40
64
 
41
65
  // relPath: 仅用于报错信息;text: 待扫描原文;opts.literals / opts.regexes 为追加项。
66
+ // 字面量匹配大小写不敏感(原文与小写化探针各匹配一趟,utf8 / utf16le 两编码)。
42
67
  export function scanLeak(relPath, text, { literals = [], regexes = [] } = {}) {
43
68
  const probe = decodeText(text);
69
+ const probeLower = probe.toLowerCase();
44
70
  const allLit = [...DEFAULT_LITERALS, ...literals];
45
71
  for (const lit of allLit) {
72
+ const litLower = lit.toLowerCase();
46
73
  for (const enc of ['utf8', 'utf16le']) {
47
- if (Buffer.from(probe, enc).includes(Buffer.from(lit, enc))) {
48
- throw new Error(`[泄漏扫描] ${relPath} 命中私有字面量: ${lit}`);
74
+ if (
75
+ Buffer.from(probe, enc).includes(Buffer.from(lit, enc)) ||
76
+ Buffer.from(probeLower, enc).includes(Buffer.from(litLower, enc))
77
+ ) {
78
+ throw new LeakScanError(`[泄漏扫描] ${relPath} 命中私有字面量: ${lit}`);
49
79
  }
50
80
  }
51
81
  }
52
82
  const allRe = [...DEFAULT_REGEXES, ...regexes];
53
83
  for (const re of allRe) {
54
- if (re.test(probe)) throw new Error(`[泄漏扫描] ${relPath} 命中私有正则: ${re}`);
84
+ if (re.test(probe)) throw new LeakScanError(`[泄漏扫描] ${relPath} 命中私有正则: ${re}`);
55
85
  }
56
86
  }
@@ -2,25 +2,45 @@
2
2
  // 通用 GitHub Pages 知识站渲染器(配置驱动,多仓库)。
3
3
  // 遍历 public-release.json 中所有带 site 字段的 repo,渲染各自 docs/public/site/ 源到 docs/。
4
4
  // 注入 nav / pager / footer、替换版本占位符、拷贝 assets、写 .nojekyll 与 tree 基线。
5
- // 写盘前做内容级泄漏扫描(解码后再匹配,防 HTML 实体 / JS 反转义绕过)。
5
+ // 写盘前对所有产物(页面 + assets)做内容级泄漏扫描(解码后再匹配,防 HTML 实体 /
6
+ // JS 反转义 / URL 百分号编码绕过),并 fail-fast 检查未替换的 @TOKEN@ 占位符残留。
6
7
  //
7
- // harness 化:所有配置 / 源文件读取经 readFileContained,所有写盘经 writeFileAtomic
8
- // (收容 + 原子写,见 skill-family-harness-node);树基线摘要改用 computeResourceClosure。
9
- // 配置与基线文件先经 skill-family-contracts validateDocument(JSON Schema 2020-12,
10
- // strict 策略)校验,校验失败 fail-fast。
8
+ // 写盘事务化:在 target canonical parent 下创建 sibling staging,全部产物先写入其中,
9
+ // 使用 writeFileAtomic;目标不存在时走 createFixedSetPublicationManifest + publishFixedSet,
10
+ // 目标存在时走 replaceFixedSetAtomic。提交前失败清理 staging;提交后或状态不确定时保留现场。
11
+ // --check 为只读:对内存渲染产物逐文件算 sha256(harness digestBytes)与基线逐文件比对,
12
+ // 并把磁盘 target 实际文件清单与 baseline.files 做集合比对,不写任何字节。
13
+ //
14
+ // harness 化:所有配置 / 源文件读取经 readFileContained,staging 的相对输出经
15
+ // writeFileAtomic 收容并原子写入(见 skill-family-harness-node);树基线摘要用
16
+ // computeResourceClosure,逐文件摘要用 digestBytes。
17
+ // 配置、pages.json 与基线文件先经 skill-family-contracts validateDocument
18
+ // (JSON Schema 2020-12,strict 策略)校验,校验失败 fail-fast。
19
+ //
20
+ // 错误分类(退出码语义):
21
+ // exit 1 — 漂移类:--check 检出漂移、泄漏扫描命中、--assert-git 缺失、
22
+ // 基线缺失 / 基线 JSON 损坏;
23
+ // exit 2 — 配置类:public-release.json / pages.json 缺失、非法或 JSON 解析失败、
24
+ // package.json JSON 解析失败、@TOKEN@ 占位符残留、--repo 参数无效;
25
+ // 其余意外异常(HarnessError 等)原样抛出,CLI 归为 exit 1。
26
+ // 可预期失败统一封装为 RenderError 携带 exitCode。
11
27
  import { existsSync, readFileSync } from 'node:fs';
12
- import { readdir, rm } from 'node:fs/promises';
13
- import { resolve, relative, extname, join, dirname } from 'node:path';
28
+ import { readdir, rm, mkdtemp, realpath } from 'node:fs/promises';
29
+ import { resolve, relative, extname, join, dirname, basename } from 'node:path';
14
30
  import { fileURLToPath } from 'node:url';
15
- import { execSync } from 'node:child_process';
31
+ import { execFileSync } from 'node:child_process';
16
32
  import {
17
33
  resolveContained,
18
34
  readFileContained,
19
- writeFileAtomic,
35
+ digestBytes,
20
36
  computeResourceClosure,
37
+ writeFileAtomic,
38
+ createFixedSetPublicationManifest,
39
+ publishFixedSet,
40
+ replaceFixedSetAtomic,
21
41
  } from 'skill-family-harness-node';
22
42
  import { validateDocument } from 'skill-family-contracts';
23
- import { scanLeak } from './lib/leak-scan.mjs';
43
+ import { scanLeak, LeakScanError } from './lib/leak-scan.mjs';
24
44
 
25
45
  const __dirname = dirname(fileURLToPath(import.meta.url));
26
46
 
@@ -31,8 +51,11 @@ const publicReleaseSchema = JSON.parse(
31
51
  const siteBaselineSchema = JSON.parse(
32
52
  readFileSync(join(__dirname, '..', 'schemas', 'site-baseline.schema.json'), 'utf8'),
33
53
  );
54
+ const pagesSchema = JSON.parse(
55
+ readFileSync(join(__dirname, '..', 'schemas', 'pages.schema.json'), 'utf8'),
56
+ );
34
57
 
35
- // 可预期失败(配置缺失、校验失败、check 漂移等)以此类型抛出,携带退出码。
58
+ // 可预期失败(配置缺失、校验失败、check 漂移、泄漏命中等)以此类型抛出,携带退出码。
36
59
  export class RenderError extends Error {
37
60
  constructor(message, exitCode = 2) {
38
61
  super(message);
@@ -48,16 +71,38 @@ function assertValid(doc, schema, label) {
48
71
  }
49
72
  }
50
73
 
74
+ // JSON 解析失败属于配置类错误:包装为 RenderError(exit 2),其余异常原样抛出。
75
+ async function readJsonContained(root, rel, label, name) {
76
+ try {
77
+ return JSON.parse(await readFileContained(root, rel, { encoding: 'utf8' }));
78
+ } catch (err) {
79
+ if (err instanceof SyntaxError) {
80
+ throw new RenderError(`[render-public-site] ${name}: ${label} JSON 解析失败: ${err.message}`, 2);
81
+ }
82
+ throw err;
83
+ }
84
+ }
85
+
51
86
  function tokenName(name) {
52
87
  return name.toUpperCase().replace(/[^A-Z0-9]+/g, '_');
53
88
  }
54
89
 
55
- async function pkgVersion(root, src) {
56
- try {
57
- return JSON.parse(await readFileContained(root, join(src, 'package.json'), { encoding: 'utf8' })).version;
58
- } catch {
90
+ async function pkgVersion(root, source, name) {
91
+ const pkgRel = join(source, 'package.json');
92
+ const fallback = (why) => {
93
+ console.warn(
94
+ `[render-public-site] WARNING: ${name}: ${why},版本回退 0.0.0(将烤进产物与基线,请确认是否有意为之)`,
95
+ );
59
96
  return '0.0.0';
97
+ };
98
+ if (!existsSync(resolve(root, pkgRel))) {
99
+ return fallback(`未找到 ${pkgRel}`);
60
100
  }
101
+ const doc = await readJsonContained(root, pkgRel, 'package.json', name);
102
+ if (!doc || typeof doc.version !== 'string' || !doc.version) {
103
+ return fallback(`${pkgRel} 缺少有效 version 字段`);
104
+ }
105
+ return doc.version;
61
106
  }
62
107
 
63
108
  // --- 树摘要(基于 computeResourceClosure,排除 site-baseline.json 自身,防 hash 循环) ---
@@ -82,32 +127,56 @@ async function listTargetFiles(rootAbs) {
82
127
  return out.filter((f) => f !== 'site-baseline.json').sort();
83
128
  }
84
129
 
130
+ // 泄漏扫描统一入口:命中归为 RenderError(exit 1),其余异常原样抛出。
131
+ function scanOrThrow(relPath, text, leakLiterals) {
132
+ try {
133
+ scanLeak(relPath, text, { literals: leakLiterals });
134
+ } catch (err) {
135
+ if (err instanceof LeakScanError) throw new RenderError(err.message, 1);
136
+ throw err;
137
+ }
138
+ }
139
+
85
140
  // --- 渲染单仓 ---
86
141
  async function renderRepo(root, repo, release, leakLiterals, { check, assertGit }) {
87
142
  const site = repo.site;
88
143
  const dirRel = join(repo.source, site.dir);
89
144
  const pagesRel = join(dirRel, site.pages);
90
145
  const targetRel = join(repo.source, site.target);
146
+ const sourceAbs = resolve(root, repo.source);
147
+ const dirAbs = resolve(root, dirRel);
91
148
  const targetAbs = resolve(root, targetRel);
149
+ const rootSourceRel = relative(root, sourceAbs);
150
+ const sourceTargetRel = relative(sourceAbs, targetAbs);
151
+ const sourceDirRel = relative(sourceAbs, dirAbs);
152
+ const contained = (rel) => rel !== '' && rel !== '..' && !rel.startsWith('../') && !rel.startsWith('/');
153
+ const overlaps = (a, b) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
154
+ if (!(rootSourceRel === '' || contained(rootSourceRel)) || !contained(sourceTargetRel) || !contained(sourceDirRel) || overlaps(sourceTargetRel, sourceDirRel)) {
155
+ throw new RenderError(
156
+ `[render-public-site] ${repo.name}: site.target 必须严格收容于 repo.source,且不得与 site.dir 相同或重叠 (source=${repo.source}, dir=${site.dir}, target=${site.target})`,
157
+ 2,
158
+ );
159
+ }
92
160
  if (!existsSync(resolve(root, pagesRel))) {
93
161
  throw new RenderError(`[render-public-site] ${repo.name}: pages.json 未找到: ${pagesRel}`, 2);
94
162
  }
95
- const pages = JSON.parse(await readFileContained(root, pagesRel, { encoding: 'utf8' })).pages;
96
- const ordered = [...pages].sort((a, b) => a.order - b.order);
163
+ const pagesDoc = await readJsonContained(root, pagesRel, 'pages.json', repo.name);
164
+ assertValid(pagesDoc, pagesSchema, `${repo.name} pages.json`);
165
+ const ordered = [...pagesDoc.pages].sort((a, b) => a.order - b.order);
97
166
  const pagerPages = ordered.filter((p) => p.inPager !== false);
98
167
 
99
168
  function navHtml(currentId) {
100
169
  const items = ordered
101
- .map((p) => ` <a href="${p.id}.html"${p.id === currentId ? ' class="active"' : ''}>${p.title}</a>`)
170
+ .map((p) => ` <a href="${p.id}.html"${p.id === currentId ? ' class="active" aria-current="page"' : ''}>${p.title}</a>`)
102
171
  .join('\n');
103
- return `<nav class="toc">\n${items}\n </nav>`;
172
+ return `<nav class="toc" aria-label="主要导航">\n${items}\n </nav>`;
104
173
  }
105
174
  function pagerHtml(currentId) {
106
175
  const idx = pagerPages.findIndex((p) => p.id === currentId);
107
- if (idx < 0) return '<nav class="pager"></nav>';
176
+ if (idx < 0) return '';
108
177
  const prev = pagerPages[(idx - 1 + pagerPages.length) % pagerPages.length];
109
178
  const next = pagerPages[(idx + 1) % pagerPages.length];
110
- return `<nav class="pager">\n <a href="${prev.id}.html">← ${prev.title}</a>\n <span class="spacer"></span>\n <a href="${next.id}.html">${next.title} →</a>\n </nav>`;
179
+ return `<nav class="pager" aria-label="教程翻页">\n <a href="${prev.id}.html">← ${prev.title}</a>\n <span class="spacer"></span>\n <a href="${next.id}.html">${next.title} →</a>\n </nav>`;
111
180
  }
112
181
  function footerHtml() {
113
182
  const owner = release.owner || 'your-org';
@@ -115,7 +184,7 @@ async function renderRepo(root, repo, release, leakLiterals, { check, assertGit
115
184
  }
116
185
 
117
186
  // 版本占位符:按 repo 名自动派生(@{NAME}_TAG@ / @{NAME}_VERSION@)+ site.tokens 静态补充
118
- const version = await pkgVersion(root, repo.source);
187
+ const version = await pkgVersion(root, repo.source, repo.name);
119
188
  const tn = tokenName(repo.name);
120
189
  const tokens = {
121
190
  [`@${tn}_TAG@`]: (repo.tagPrefix || `${repo.name}-v`) + version,
@@ -128,7 +197,9 @@ async function renderRepo(root, repo, release, leakLiterals, { check, assertGit
128
197
  return out;
129
198
  }
130
199
 
131
- const rendered = [];
200
+ // --- 内存渲染全部产物(页面 + assets + .nojekyll);任何失败发生在写盘之前 ---
201
+ // outputs: { path: target 内相对路径, text: 扫描用文本, bytes: 落盘字节 }
202
+ const outputs = [];
132
203
  for (const p of ordered) {
133
204
  let html = await readFileContained(root, join(dirRel, p.id + '.html'), { encoding: 'utf8' });
134
205
  html = html
@@ -136,9 +207,37 @@ async function renderRepo(root, repo, release, leakLiterals, { check, assertGit
136
207
  .replace(/<!--PAGER-->/g, pagerHtml(p.id))
137
208
  .replace(/<!--FOOTER-->/g, footerHtml());
138
209
  html = replaceTokens(html);
139
- rendered.push({ p, html });
210
+ // 未替换的 @TOKEN@ 占位符残留 fail-fast(防占位符幻觉静默穿透进产物 / 基线 / 发布)
211
+ const residual = html.match(/@[A-Z0-9_]+@/g);
212
+ if (residual) {
213
+ throw new RenderError(
214
+ `[render-public-site] ${repo.name}: ${p.id}.html 残留未替换的占位符: ${[...new Set(residual)].join(', ')}`,
215
+ 2,
216
+ );
217
+ }
218
+ const out = '<!-- generated by skill-family-doc-render -->\n' + html;
219
+ outputs.push({ path: p.id + '.html', text: out, bytes: Buffer.from(out, 'utf8') });
220
+ }
221
+ // assets(allowlist:html/css/js/svg,不含 png;配图全部自绘 SVG)
222
+ const assetSrc = resolve(root, dirRel, 'assets');
223
+ if (existsSync(assetSrc)) {
224
+ for (const f of (await readdir(assetSrc)).sort()) {
225
+ if (['.html', '.css', '.js', '.svg'].includes(extname(f))) {
226
+ const bytes = await readFileContained(root, join(dirRel, 'assets', f));
227
+ outputs.push({ path: 'assets/' + f, text: bytes.toString('utf8'), bytes });
228
+ }
229
+ }
140
230
  }
141
- for (const { p, html } of rendered) scanLeak(`${repo.name}/${p.id}.html`, html, { literals: leakLiterals });
231
+ // .nojekyll:禁止 GitHub Pages 跑 Jekyll
232
+ outputs.push({ path: '.nojekyll', text: '', bytes: Buffer.alloc(0) });
233
+
234
+ // 写盘前统一泄漏扫描:页面与 assets 同一规则(对解码后内容)
235
+ for (const o of outputs) scanOrThrow(`${repo.name}/${o.path}`, o.text, leakLiterals);
236
+
237
+ // 逐文件 sha256(harness digestBytes),--check 与基线逐文件比对用
238
+ const digests = {};
239
+ for (const o of outputs) digests[o.path] = digestBytes(o.bytes);
240
+ const files = Object.keys(digests).sort();
142
241
 
143
242
  if (check) {
144
243
  if (!existsSync(targetAbs)) {
@@ -147,17 +246,6 @@ async function renderRepo(root, repo, release, leakLiterals, { check, assertGit
147
246
  1,
148
247
  );
149
248
  }
150
- // 不写盘,仅依据内存产物计算树摘要,与已提交的 site-baseline.json 比对
151
- const memFiles = [];
152
- for (const { p } of rendered) memFiles.push(p.id + '.html');
153
- const assetSrc = resolve(root, dirRel, 'assets');
154
- if (existsSync(assetSrc)) {
155
- for (const f of await readdir(assetSrc)) {
156
- if (['.html', '.css', '.js', '.svg'].includes(extname(f))) memFiles.push('assets/' + f);
157
- }
158
- }
159
- memFiles.push('.nojekyll');
160
- const memHash = await treeDigest(targetAbs, memFiles);
161
249
  const baselineRel = join(targetRel, 'site-baseline.json');
162
250
  if (!existsSync(resolve(root, baselineRel))) {
163
251
  throw new RenderError(
@@ -165,11 +253,42 @@ async function renderRepo(root, repo, release, leakLiterals, { check, assertGit
165
253
  1,
166
254
  );
167
255
  }
168
- const base = JSON.parse(await readFileContained(root, baselineRel, { encoding: 'utf8' }));
256
+ let base;
257
+ try {
258
+ base = JSON.parse(await readFileContained(root, baselineRel, { encoding: 'utf8' }));
259
+ } catch (err) {
260
+ if (err instanceof SyntaxError) {
261
+ throw new RenderError(
262
+ `[render-public-site --check] ${repo.name}: site-baseline.json JSON 解析失败(基线损坏,请重新渲染): ${err.message}`,
263
+ 1,
264
+ );
265
+ }
266
+ throw err;
267
+ }
169
268
  assertValid(base, siteBaselineSchema, `${repo.name} site-baseline.json`);
170
- if (base.sha256 !== memHash) {
269
+
270
+ const drift = [];
271
+ // 1) 内存渲染产物 vs 基线逐文件摘要:抓「源改未重渲」与「渲染器回归」
272
+ for (const f of files) {
273
+ if (!(f in base.digests)) drift.push(`基线缺少文件(源新增未重渲): ${f}`);
274
+ else if (base.digests[f] !== digests[f]) drift.push(`内容漂移(源已改未重渲或渲染器回归): ${f}`);
275
+ }
276
+ for (const f of Object.keys(base.digests)) {
277
+ if (!(f in digests)) drift.push(`基线仍有但源渲染已不产生: ${f}`);
278
+ }
279
+ // 2) 磁盘 target 实际文件清单 vs baseline.files:抓 docs/ 被手工增删文件
280
+ const diskFiles = await listTargetFiles(targetAbs);
281
+ const baseSet = new Set(base.files);
282
+ const diskSet = new Set(diskFiles);
283
+ for (const f of diskFiles) if (!baseSet.has(f)) drift.push(`target 多出未登记文件: ${f}`);
284
+ for (const f of base.files) if (!diskSet.has(f)) drift.push(`target 缺少基线文件: ${f}`);
285
+ // 3) 磁盘产物内容 vs 基线树摘要:抓产物被手工修改
286
+ const diskHash = await treeDigest(targetAbs, diskFiles);
287
+ if (diskHash !== base.sha256) drift.push('磁盘产物树摘要与基线不一致(产物被手工修改)');
288
+
289
+ if (drift.length) {
171
290
  throw new RenderError(
172
- `[render-public-site --check] ${repo.name}: 漂移:内存产物树摘要与基线不一致`,
291
+ `[render-public-site --check] ${repo.name}: 漂移:\n - ${drift.join('\n - ')}`,
173
292
  1,
174
293
  );
175
294
  }
@@ -177,75 +296,119 @@ async function renderRepo(root, repo, release, leakLiterals, { check, assertGit
177
296
  return;
178
297
  }
179
298
 
180
- // 写盘:清空 target 后重建(docs/ 即站点目录)。
181
- // harness 不提供删除原语,先经 resolveContained 证明目标收容在工作区内,再整目录重建;
182
- // 之后所有文件写入均走 writeFileAtomic(收容 + 原子写)。
183
- await resolveContained(root, targetRel);
184
- await rm(targetAbs, { recursive: true, force: true });
185
- for (const { p, html } of rendered) {
186
- await writeFileAtomic(
187
- root,
188
- join(targetRel, p.id + '.html'),
189
- '<!-- generated by skill-family-doc-render -->\n' + html,
190
- );
191
- }
192
- // 拷贝 assets(allowlist:html/css/js/svg,不含 png;配图全部自绘 SVG)
193
- const assetSrc = resolve(root, dirRel, 'assets');
194
- if (existsSync(assetSrc)) {
195
- for (const f of await readdir(assetSrc)) {
196
- if (['.html', '.css', '.js', '.svg'].includes(extname(f))) {
197
- const bytes = await readFileContained(root, join(dirRel, 'assets', f));
198
- await writeFileAtomic(root, join(targetRel, 'assets', f), bytes);
199
- }
200
- }
201
- }
202
- // .nojekyll:禁止 GitHub Pages 跑 Jekyll
203
- await writeFileAtomic(root, join(targetRel, '.nojekyll'), '');
204
- // 树基线(防漂移)
205
- const files = await listTargetFiles(targetAbs);
206
- const treeHash = await treeDigest(targetAbs, files);
207
- await writeFileAtomic(
208
- root,
209
- join(targetRel, 'site-baseline.json'),
210
- JSON.stringify({ sha256: treeHash, files }, null, 2),
211
- );
212
-
213
- console.log(`[render-public-site] ${repo.name}: 已渲染 ${rendered.length} 页 + assets 到 ${targetRel}`);
214
- console.log(`[render-public-site] ${repo.name}: tree-sha256=${treeHash.slice(0, 16)}…`);
215
-
216
- // 可选:对真实导出快照逐页断言
299
+ // assert-git 必须在任何 target 变更前完成,避免门禁失败留下部分替换结果。
217
300
  if (assertGit) {
218
- const expected = ordered.map((p) => `${targetRel}/${p.id}.html`);
219
- expected.push(`${targetRel}/assets/style.css`);
220
- expected.push(`${targetRel}/.nojekyll`);
221
- expected.push(`${targetRel}/index.html`);
301
+ const expected = files.map((f) => `${targetRel}/${f}`);
222
302
  let missing = 0;
223
303
  for (const f of expected) {
224
304
  try {
225
- execSync(`git ls-files --error-unmatch ${f}`, { cwd: root, stdio: 'pipe' });
305
+ execFileSync('git', ['ls-files', '--error-unmatch', f], { cwd: root, stdio: 'pipe' });
226
306
  } catch {
227
307
  console.error(`[assert-git] ${repo.name}: 未跟踪(需 git add 并提交): ${f}`);
228
308
  missing++;
229
309
  }
230
310
  }
231
- if (missing) {
232
- throw new RenderError(
233
- `[assert-git] ${repo.name}: ${missing} 个文件未进 Git 快照,站点无法进入发布`,
234
- 1,
235
- );
236
- }
311
+ if (missing) throw new RenderError(`[assert-git] ${repo.name}: ${missing} 个文件未进 Git 快照,站点无法进入发布`, 1);
237
312
  console.log(`[assert-git] ${repo.name}: 全部站点文件已在 Git 快照中`);
238
313
  }
314
+
315
+ // --- 写盘事务化:在目标目录的 canonical parent 内创建 sibling staging,
316
+ // 写入和发布均使用 Foundation 公共目录发布 API。 ---
317
+ await resolveContained(root, targetRel);
318
+ const targetParent = await realpath(dirname(targetAbs));
319
+ const targetSegment = basename(targetAbs);
320
+ const staging = await mkdtemp(join(targetParent, '.sf-doc-render-'));
321
+ let treeHash;
322
+ let publicationAttempted = false;
323
+ try {
324
+ for (const o of outputs) await writeFileAtomic(staging, o.path, o.bytes);
325
+ treeHash = await treeDigest(staging, files);
326
+ await writeFileAtomic(staging, 'site-baseline.json', JSON.stringify({ sha256: treeHash, files, digests }, null, 2));
327
+ const targetExists = existsSync(targetAbs);
328
+ if (!targetExists) {
329
+ const manifest = await createFixedSetPublicationManifest({ sourceRoot: staging, targetParent, targetSegment });
330
+ publicationAttempted = true;
331
+ const receipt = await publishFixedSet({ sourceRoot: staging, targetParent, targetSegment, manifest });
332
+ if (receipt.status !== 'succeeded') {
333
+ if (receipt.commitState === 'not-committed') await rm(staging, { recursive: true, force: true });
334
+ throw new Error(`[render-public-site] ${repo.name}: 目录发布失败 (${receipt.status}): ${receipt.error?.message ?? 'unknown'}`);
335
+ }
336
+ } else {
337
+ publicationAttempted = true;
338
+ const result = await replaceFixedSetAtomic({ sourceRoot: staging, targetParent, targetSegment });
339
+ try {
340
+ await rm(result.displacedTargetPath, { recursive: true, force: true });
341
+ } catch (cleanupError) {
342
+ throw new Error(`[render-public-site] ${repo.name}: 发布已成功、旧目标清理失败: ${result.displacedTargetPath}; ${cleanupError.message}`, { cause: cleanupError });
343
+ }
344
+ }
345
+ console.log(`[render-public-site] ${repo.name}: 已渲染 ${ordered.length} 页 + assets 到 ${targetRel}`);
346
+ console.log(`[render-public-site] ${repo.name}: tree-sha256=${treeHash.slice(0, 16)}…`);
347
+ } catch (err) {
348
+ const preCommit = err?.details?.phase === 'pre-commit' && err?.details?.publicationState === 'not-published' && err?.details?.commitState === 'not-committed';
349
+ if (!publicationAttempted || preCommit) await rm(staging, { recursive: true, force: true });
350
+ throw err;
351
+ }
352
+
239
353
  }
240
354
 
241
355
  // === 主流程 ===
356
+ // 闭集参数解析:允许参数只有 --check / --assert-git / --repo <name> / --help / -h。
357
+ // 未知选项、游离位置参数、重复取值参数、--repo 缺值或值本身是另一个选项,
358
+ // 均以 RenderError(exit 2)失败,错误信息指出具体参数。不引入 CLI 框架。
359
+ // --help / -h 由 main() 统一呈现:闭集解析先于配置读取与写盘,打印 USAGE_TEXT 后退出 0。
360
+ const USAGE_TEXT = `skill-family-doc-render — config-driven GitHub Pages knowledge-site renderer
361
+
362
+ Usage:
363
+ skill-family-doc-render render every repo with a site field (writes)
364
+ skill-family-doc-render --check read-only drift check against site-baseline.json
365
+ skill-family-doc-render --repo <name> render / check a single repo by name
366
+ skill-family-doc-render --assert-git also assert rendered files are git-tracked
367
+
368
+ Reads public-release.json from the current working directory.`;
369
+
370
+ function parseArgs(argv) {
371
+ const flags = { check: false, assertGit: false, repoArg: null, help: false };
372
+ let i = 0;
373
+ while (i < argv.length) {
374
+ const arg = argv[i];
375
+ if (arg === '--check') {
376
+ flags.check = true;
377
+ } else if (arg === '--assert-git') {
378
+ flags.assertGit = true;
379
+ } else if (arg === '--help' || arg === '-h') {
380
+ flags.help = true;
381
+ } else if (arg === '--repo') {
382
+ if (flags.repoArg !== null) {
383
+ throw new RenderError('[render-public-site] 参数错误: --repo 重复出现,取值语义不明确', 2);
384
+ }
385
+ const value = argv[i + 1];
386
+ if (value === undefined || value === '' || value.startsWith('-')) {
387
+ throw new RenderError('[render-public-site] --repo 后必须跟一个有效的 repo 名(不能缺省,且值不能是另一个选项)', 2);
388
+ }
389
+ flags.repoArg = value;
390
+ i += 1; // 消费取值
391
+ } else if (arg.startsWith('-')) {
392
+ throw new RenderError(`[render-public-site] 参数错误: 未知选项 ${arg}`, 2);
393
+ } else {
394
+ throw new RenderError(`[render-public-site] 参数错误: 未知位置参数 ${arg}`, 2);
395
+ }
396
+ i += 1;
397
+ }
398
+ return flags;
399
+ }
400
+
242
401
  // argv: CLI 参数(不含 node/script 名);cwd: 工作区根(public-release.json 所在目录)。
243
402
  // 返回退出码 0;可预期失败抛 RenderError(携带 exitCode),其余异常原样抛出。
403
+ // 多仓遍历失败隔离:每 repo try/catch 收集结果,全部遍历完后汇总,任一失败整体非零退出;
404
+ // 单个 repo 内部(含泄漏扫描命中)保持 fail-fast。
244
405
  export async function main(argv = process.argv.slice(2), { cwd = process.cwd() } = {}) {
245
- const check = argv.includes('--check');
246
- const assertGit = argv.includes('--assert-git');
247
- const repoIdx = argv.indexOf('--repo');
248
- const repoArg = repoIdx >= 0 ? argv[repoIdx + 1] : null;
406
+ // 闭集解析在读取任何配置或写盘之前完成;参数失败时目标树一个字节都不动。
407
+ const { check, assertGit, repoArg, help } = parseArgs(argv);
408
+ if (help) {
409
+ console.log(USAGE_TEXT);
410
+ return 0;
411
+ }
249
412
 
250
413
  const root = resolve(cwd);
251
414
 
@@ -254,7 +417,7 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd() }
254
417
  if (!existsSync(releasePath)) {
255
418
  throw new RenderError('[render-public-site] 未找到 public-release.json', 2);
256
419
  }
257
- const release = JSON.parse(await readFileContained(root, 'public-release.json', { encoding: 'utf8' }));
420
+ const release = await readJsonContained(root, 'public-release.json', 'public-release.json', '(workspace)');
258
421
  assertValid(release, publicReleaseSchema, 'public-release.json');
259
422
 
260
423
  // 泄漏扫描追加字面量(来自配置;路径类用 forbiddenPublicPaths,任意私有词用 privateLiterals)
@@ -276,7 +439,36 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd() }
276
439
  throw new RenderError('[render-public-site] public-release.json 中没有带 site 字段的 repo', 2);
277
440
  }
278
441
 
279
- for (const repo of selected) await renderRepo(root, repo, release, leakLiterals, { check, assertGit });
442
+ const results = [];
443
+ for (const repo of selected) {
444
+ try {
445
+ await renderRepo(root, repo, release, leakLiterals, { check, assertGit });
446
+ results.push({ name: repo.name, ok: true });
447
+ } catch (err) {
448
+ results.push({
449
+ name: repo.name,
450
+ ok: false,
451
+ exitCode: err instanceof RenderError ? err.exitCode : 1,
452
+ message: err.message,
453
+ cause: err,
454
+ });
455
+ }
456
+ }
457
+ const failed = results.filter((r) => !r.ok);
458
+ if (failed.length) {
459
+ // 汇总报告哪些成功哪些失败;退出码取失败中最严重一类(配置类 2 优先于漂移/泄漏类 1)
460
+ const exitCode = failed.some((f) => f.exitCode === 2) ? 2 : 1;
461
+ const lines = results.map((r) => (r.ok ? ` ✓ ${r.name}: 成功` : ` ✗ ${r.name}: ${r.message}`));
462
+ const aggregate = new RenderError(
463
+ `[render-public-site] ${failed.length}/${results.length} 个 repo 失败:\n${lines.join('\n')}`,
464
+ exitCode,
465
+ );
466
+ if (failed.length === 1 && failed[0].cause?.code) {
467
+ aggregate.code = failed[0].cause.code;
468
+ aggregate.details = failed[0].cause.details;
469
+ }
470
+ throw aggregate;
471
+ }
280
472
  return 0;
281
473
  }
282
474