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 +74 -0
- package/README.md +84 -33
- package/bin/skill-family-doc-render.mjs +9 -25
- package/package.json +4 -3
- package/schemas/pages.schema.json +27 -0
- package/schemas/site-baseline.schema.json +6 -2
- package/scripts/lib/leak-scan.mjs +42 -12
- package/scripts/render-public-site.mjs +285 -93
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
|
|
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
|
|
35
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
126
|
+
After staging, the renderer records `site-baseline.json` in the target directory:
|
|
98
127
|
|
|
99
128
|
```json
|
|
100
|
-
{
|
|
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`
|
|
104
|
-
|
|
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),
|
|
114
|
-
|
|
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
|
|
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:** `
|
|
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
|
|
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
|
|
130
|
-
>
|
|
131
|
-
>
|
|
132
|
-
>
|
|
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
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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.
|
|
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.
|
|
28
|
-
"skill-family-harness-node": "0.
|
|
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
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
// 泄漏命中专用错误类型:调用方可据 instanceof 归类退出码,不与配置错误混淆。
|
|
26
|
+
export class LeakScanError extends Error {}
|
|
27
|
+
|
|
28
|
+
// HTML 实体解码单趟。允许常见实体省略数字实体末尾分号(浏览器兼容形式)。
|
|
29
|
+
function entityDecode(s) {
|
|
30
|
+
return s
|
|
27
31
|
.replace(/&/g, '&')
|
|
28
32
|
.replace(/</g, '<')
|
|
29
33
|
.replace(/>/g, '>')
|
|
30
34
|
.replace(/"/g, '"')
|
|
31
|
-
.replace(
|
|
32
|
-
.replace(/&#(
|
|
33
|
-
|
|
35
|
+
.replace(///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
|
-
|
|
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 (
|
|
48
|
-
|
|
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
|
|
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
|
-
//
|
|
5
|
+
// 写盘前对所有产物(页面 + assets)做内容级泄漏扫描(解码后再匹配,防 HTML 实体 /
|
|
6
|
+
// JS 反转义 / URL 百分号编码绕过),并 fail-fast 检查未替换的 @TOKEN@ 占位符残留。
|
|
6
7
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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 {
|
|
31
|
+
import { execFileSync } from 'node:child_process';
|
|
16
32
|
import {
|
|
17
33
|
resolveContained,
|
|
18
34
|
readFileContained,
|
|
19
|
-
|
|
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,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
96
|
-
|
|
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 '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
246
|
-
const assertGit = argv
|
|
247
|
-
|
|
248
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|