skill-family-doc-render 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +84 -58
- package/package.json +3 -1
- package/schemas/pages.schema.json +76 -18
- package/schemas/public-release.schema.json +6 -0
- package/scripts/lib/markdown-v1.mjs +213 -0
- package/scripts/lib/rendered-links.mjs +124 -0
- package/scripts/lib/svg-safety.mjs +45 -0
- package/scripts/render-public-site.mjs +187 -36
- package/templates/editorial/README.md +129 -0
- package/templates/editorial/assets/mark.svg +1 -0
- package/templates/editorial/assets/site.js +220 -0
- package/templates/editorial/assets/style.css +330 -0
- package/templates/editorial/index.mjs +283 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// 只读检查已渲染站点的相对页面、资源、CSS 依赖与页内锚点。
|
|
3
|
+
import { dirname, extname, relative, resolve } from 'node:path';
|
|
4
|
+
import { readFileContained, resolveContained } from 'skill-family-harness-node';
|
|
5
|
+
|
|
6
|
+
export class RenderedLinksError extends Error {
|
|
7
|
+
constructor(failures) {
|
|
8
|
+
super(`${failures.length} 个链接失败:\n - ${failures.join('\n - ')}`);
|
|
9
|
+
this.failures = failures;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isExternal(value) {
|
|
14
|
+
return /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function anchorsOf(html) {
|
|
18
|
+
const anchors = new Set();
|
|
19
|
+
for (const match of html.matchAll(/\s(?:id|name)\s*=\s*["']([^"']+)["']/gi)) anchors.add(match[1]);
|
|
20
|
+
return anchors;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function referencesOf(html) {
|
|
24
|
+
const refs = [];
|
|
25
|
+
for (const match of html.matchAll(/\s(?:href|src)\s*=\s*["']([^"']+)["']/gi)) refs.push(match[1].trim());
|
|
26
|
+
return refs;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function cssReferencesOf(css) {
|
|
30
|
+
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
31
|
+
const refs = [];
|
|
32
|
+
for (const match of withoutComments.matchAll(/url\(\s*(["']?)(.*?)\1\s*\)/gi)) refs.push(match[2].trim());
|
|
33
|
+
for (const match of withoutComments.matchAll(/@import\s+(?!url\()(["'])(.*?)\1/gi)) refs.push(match[2].trim());
|
|
34
|
+
return refs;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {{targetRoot:string,pageIds:string[]}} input
|
|
39
|
+
* @returns {Promise<{pages:number}>}
|
|
40
|
+
*/
|
|
41
|
+
export async function checkRenderedLinks({ targetRoot, pageIds }) {
|
|
42
|
+
const targetDir = resolve(targetRoot);
|
|
43
|
+
const htmlFiles = pageIds.map((id) => `${id}.html`);
|
|
44
|
+
const textCache = new Map();
|
|
45
|
+
const loadText = async (path) => {
|
|
46
|
+
if (!textCache.has(path)) {
|
|
47
|
+
textCache.set(path, await readFileContained(targetDir, relative(targetDir, path), { encoding: 'utf8' }));
|
|
48
|
+
}
|
|
49
|
+
return textCache.get(path);
|
|
50
|
+
};
|
|
51
|
+
const failures = [];
|
|
52
|
+
const visitedCss = new Set();
|
|
53
|
+
const visitedHtml = new Set();
|
|
54
|
+
|
|
55
|
+
async function inspectReference({ originPath, originLabel, raw }) {
|
|
56
|
+
if (!raw || raw === '#' || isExternal(raw)) return;
|
|
57
|
+
if (raw.startsWith('/')) {
|
|
58
|
+
failures.push(`${originLabel}: ${raw} 使用站点根绝对路径,部署到仓库子路径后会失效`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const [withoutFragment, fragment = ''] = raw.split('#', 2);
|
|
62
|
+
const pathPart = withoutFragment.split('?', 1)[0];
|
|
63
|
+
let decoded;
|
|
64
|
+
try {
|
|
65
|
+
decoded = decodeURIComponent(pathPart);
|
|
66
|
+
} catch {
|
|
67
|
+
failures.push(`${originLabel}: ${raw} 含无效 URL 编码`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const candidate = decoded ? resolve(dirname(originPath), decoded) : originPath;
|
|
71
|
+
let target;
|
|
72
|
+
try {
|
|
73
|
+
target = candidate === originPath
|
|
74
|
+
? originPath
|
|
75
|
+
: await resolveContained(targetDir, relative(targetDir, candidate));
|
|
76
|
+
await readFileContained(targetDir, relative(targetDir, target));
|
|
77
|
+
} catch {
|
|
78
|
+
failures.push(`${originLabel}: ${raw} 指向不存在、越界或经过不安全符号链接的页面或资源`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const extension = extname(target).toLowerCase();
|
|
82
|
+
if (fragment && extension === '.html') {
|
|
83
|
+
const targetHtml = await loadText(target);
|
|
84
|
+
let decodedFragment;
|
|
85
|
+
try {
|
|
86
|
+
decodedFragment = decodeURIComponent(fragment);
|
|
87
|
+
} catch {
|
|
88
|
+
failures.push(`${originLabel}: ${raw} 含无效锚点编码`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (!anchorsOf(targetHtml).has(decodedFragment)) failures.push(`${originLabel}: ${raw} 指向不存在的页内锚点`);
|
|
92
|
+
}
|
|
93
|
+
if (extension === '.html') await inspectHtml(target, relative(targetDir, target).replaceAll('\\', '/'));
|
|
94
|
+
if (extension === '.css') {
|
|
95
|
+
const cssRel = relative(targetDir, target).replaceAll('\\', '/');
|
|
96
|
+
if (visitedCss.has(cssRel)) return;
|
|
97
|
+
visitedCss.add(cssRel);
|
|
98
|
+
const css = await loadText(target);
|
|
99
|
+
for (const cssRef of cssReferencesOf(css)) {
|
|
100
|
+
await inspectReference({ originPath: target, originLabel: cssRel, raw: cssRef });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function inspectHtml(pagePath, htmlFile, required = false) {
|
|
106
|
+
if (visitedHtml.has(htmlFile)) return;
|
|
107
|
+
visitedHtml.add(htmlFile);
|
|
108
|
+
let html;
|
|
109
|
+
try {
|
|
110
|
+
html = await loadText(pagePath);
|
|
111
|
+
} catch {
|
|
112
|
+
if (required) failures.push(`${htmlFile}: 生成页面不存在`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
for (const raw of referencesOf(html)) await inspectReference({ originPath: pagePath, originLabel: htmlFile, raw });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const htmlFile of htmlFiles) {
|
|
119
|
+
await inspectHtml(resolve(targetDir, htmlFile), htmlFile, true);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (failures.length) throw new RenderedLinksError(failures);
|
|
123
|
+
return { pages: htmlFiles.length };
|
|
124
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// 对项目内容 SVG 做保守的主动内容检查;模板自带图标不走此入口。
|
|
3
|
+
|
|
4
|
+
export class SvgSafetyError extends TypeError {
|
|
5
|
+
constructor(path, message) {
|
|
6
|
+
super(`unsafe SVG ${path}: ${message}`);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const reject = (path, message) => { throw new SvgSafetyError(path, message); };
|
|
11
|
+
|
|
12
|
+
function decodeCssEscapes(value) {
|
|
13
|
+
return value
|
|
14
|
+
.replace(/\\([0-9a-f]{1,6})\s?/gi, (_match, digits) => {
|
|
15
|
+
const codePoint = Number.parseInt(digits, 16);
|
|
16
|
+
return codePoint === 0 || codePoint > 0x10ffff ? '\uFFFD' : String.fromCodePoint(codePoint);
|
|
17
|
+
})
|
|
18
|
+
.replace(/\\([^\r\n\f])/g, '$1');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function assertSafeSvg(text, path) {
|
|
22
|
+
if (typeof text !== 'string' || !text.trim()) reject(path, 'file must be nonempty UTF-8 text');
|
|
23
|
+
if (text.includes('\u0000')) reject(path, 'NUL is not allowed');
|
|
24
|
+
if (!/^\s*(?:<\?xml\s[^?]*\?>\s*)?(?:<!--[\s\S]*?-->\s*)*<svg(?:\s|>)/i.test(text)) {
|
|
25
|
+
reject(path, 'root element must be svg');
|
|
26
|
+
}
|
|
27
|
+
if (/<!\s*(?:DOCTYPE|ENTITY)\b/i.test(text)) reject(path, 'DTD and entity declarations are not allowed');
|
|
28
|
+
if (/<\/?(?:[A-Za-z_][\w.-]*:)?(?:script|foreignObject)(?:\s|\/?>)/i.test(text)) {
|
|
29
|
+
reject(path, 'script and foreignObject elements are not allowed');
|
|
30
|
+
}
|
|
31
|
+
if (/\s(?:[A-Za-z_][\w.-]*:)?on[a-z0-9_.:-]*\s*=/i.test(text)) reject(path, 'event attributes are not allowed');
|
|
32
|
+
if (/<\?(?!xml\s)[\s\S]*?\?>/i.test(text)) reject(path, 'processing instructions are not allowed');
|
|
33
|
+
if (/\b(?:href|xlink:href|src)\s*=\s*(["'])(?!#)[\s\S]*?\1/i.test(text)) {
|
|
34
|
+
reject(path, 'external resource references are not allowed');
|
|
35
|
+
}
|
|
36
|
+
if (/\b(?:href|xlink:href|src)\s*=\s*[^\s"'=<>`]+/i.test(text)) {
|
|
37
|
+
reject(path, 'resource references must be quoted local fragments');
|
|
38
|
+
}
|
|
39
|
+
const decodedCss = decodeCssEscapes(text);
|
|
40
|
+
if (/@import\b/i.test(decodedCss)) reject(path, 'CSS imports are not allowed');
|
|
41
|
+
for (const match of decodedCss.matchAll(/url\(\s*(["']?)(.*?)\1\s*\)/gi)) {
|
|
42
|
+
if (!/^#[A-Za-z0-9][A-Za-z0-9_-]*$/.test(match[2])) reject(path, 'CSS URLs must be local fragments');
|
|
43
|
+
}
|
|
44
|
+
return text;
|
|
45
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// 通用 GitHub Pages 知识站渲染器(配置驱动,多仓库)。
|
|
3
|
-
// 遍历 public-release.json 中所有带 site 字段的 repo
|
|
4
|
-
//
|
|
3
|
+
// 遍历 public-release.json 中所有带 site 字段的 repo,渲染各自站点源到目标目录。
|
|
4
|
+
// format 缺省时兼容完整 HTML 注入;显式 markdown-v1 + editorial 时,将受限 Markdown
|
|
5
|
+
// 编译为语义块并交给包内统一模板。两条路径共用扫描、基线和原子发布。
|
|
5
6
|
// 写盘前对所有产物(页面 + assets)做内容级泄漏扫描(解码后再匹配,防 HTML 实体 /
|
|
6
7
|
// JS 反转义 / URL 百分号编码绕过),并 fail-fast 检查未替换的 @TOKEN@ 占位符残留。
|
|
7
8
|
//
|
|
@@ -21,11 +22,11 @@
|
|
|
21
22
|
// exit 1 — 漂移类:--check 检出漂移、覆盖快照缺失或落后、泄漏扫描命中、
|
|
22
23
|
// --assert-git 缺失、渲染基线缺失 / 基线 JSON 损坏;
|
|
23
24
|
// exit 2 — 配置或工具类:配置 / JSON / 覆盖输入非法、Git 调用失败、
|
|
24
|
-
// @TOKEN@
|
|
25
|
+
// @TOKEN@ 占位符残留、markdown-v1/SVG 输入非法、--repo 参数无效;
|
|
25
26
|
// 其余意外异常(HarnessError 等)原样抛出,CLI 归为 exit 1。
|
|
26
27
|
// 可预期失败统一封装为 RenderError 携带 exitCode。
|
|
27
28
|
import { existsSync, readFileSync } from 'node:fs';
|
|
28
|
-
import { readdir, rm, mkdtemp, realpath, stat, glob } from 'node:fs/promises';
|
|
29
|
+
import { readFile, readdir, rm, mkdtemp, realpath, stat, glob } from 'node:fs/promises';
|
|
29
30
|
import { resolve, relative, extname, join, dirname, basename, posix } from 'node:path';
|
|
30
31
|
import { fileURLToPath } from 'node:url';
|
|
31
32
|
import { execFileSync } from 'node:child_process';
|
|
@@ -42,6 +43,10 @@ import {
|
|
|
42
43
|
} from 'skill-family-harness-node';
|
|
43
44
|
import { validateDocument } from 'skill-family-contracts';
|
|
44
45
|
import { scanLeak, LeakScanError } from './lib/leak-scan.mjs';
|
|
46
|
+
import { compileMarkdownV1, replaceMarkdownTokens } from './lib/markdown-v1.mjs';
|
|
47
|
+
import { assertSafeSvg } from './lib/svg-safety.mjs';
|
|
48
|
+
import { checkRenderedLinks } from './lib/rendered-links.mjs';
|
|
49
|
+
import { renderEditorialSite } from '../templates/editorial/index.mjs';
|
|
45
50
|
|
|
46
51
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
47
52
|
|
|
@@ -436,8 +441,81 @@ function scanOrThrow(relPath, text, leakLiterals) {
|
|
|
436
441
|
}
|
|
437
442
|
}
|
|
438
443
|
|
|
444
|
+
function decodeUtf8(bytes, label) {
|
|
445
|
+
try {
|
|
446
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
447
|
+
} catch (cause) {
|
|
448
|
+
throw new TypeError(`${label} must be valid UTF-8`, { cause });
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function editorialMetadata(pagesDoc, tokens) {
|
|
453
|
+
const text = (value, label) => replaceMarkdownTokens(value, tokens, label);
|
|
454
|
+
return {
|
|
455
|
+
site: {
|
|
456
|
+
title: text(pagesDoc.site.title, 'site.title'),
|
|
457
|
+
description: text(pagesDoc.site.description, 'site.description'),
|
|
458
|
+
productKind: pagesDoc.site.productKind,
|
|
459
|
+
},
|
|
460
|
+
groups: pagesDoc.groups.map((group) => ({
|
|
461
|
+
...group,
|
|
462
|
+
title: text(group.title, `group ${group.id} title`),
|
|
463
|
+
})),
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function markdownOutputs(root, repo, dirRel, pagesDoc, tokens) {
|
|
468
|
+
if (repo.site.template !== 'editorial') {
|
|
469
|
+
throw new TypeError(`unsupported template: ${repo.site.template}`);
|
|
470
|
+
}
|
|
471
|
+
const containedDir = await resolveContained(root, dirRel);
|
|
472
|
+
const expected = new Set(pagesDoc.pages.map((page) => `${page.id}.md`));
|
|
473
|
+
const actual = new Set((await readdir(containedDir, { withFileTypes: true }))
|
|
474
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
|
|
475
|
+
.map((entry) => entry.name));
|
|
476
|
+
const mismatches = [
|
|
477
|
+
...[...expected].filter((name) => !actual.has(name)).map((name) => `missing ${name}`),
|
|
478
|
+
...[...actual].filter((name) => !expected.has(name)).map((name) => `undeclared ${name}`),
|
|
479
|
+
].sort();
|
|
480
|
+
if (mismatches.length) throw new TypeError(`Markdown source set does not match pages.json: ${mismatches.join(', ')}`);
|
|
481
|
+
|
|
482
|
+
const images = new Set();
|
|
483
|
+
const pages = [];
|
|
484
|
+
for (const page of pagesDoc.pages) {
|
|
485
|
+
const rel = join(dirRel, `${page.id}.md`);
|
|
486
|
+
const source = decodeUtf8(await readFileContained(root, rel), `${page.id}.md`);
|
|
487
|
+
const compiled = compileMarkdownV1(source, { pageId: page.id, tokens });
|
|
488
|
+
compiled.images.forEach((path) => images.add(path));
|
|
489
|
+
pages.push({
|
|
490
|
+
...page,
|
|
491
|
+
title: replaceMarkdownTokens(page.title, tokens, `page ${page.id} title`),
|
|
492
|
+
...(page.navTitle === undefined ? {} : {
|
|
493
|
+
navTitle: replaceMarkdownTokens(page.navTitle, tokens, `page ${page.id} navTitle`),
|
|
494
|
+
}),
|
|
495
|
+
blocks: compiled.blocks,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const rendered = renderEditorialSite({ ...editorialMetadata(pagesDoc, tokens), pages });
|
|
500
|
+
const outputs = rendered.pages.map((page) => {
|
|
501
|
+
const text = `<!-- generated by skill-family-doc-render -->\n${page.text}`;
|
|
502
|
+
return { path: page.path, text, bytes: Buffer.from(text, 'utf8') };
|
|
503
|
+
});
|
|
504
|
+
for (const asset of rendered.assets) {
|
|
505
|
+
const bytes = await readFile(asset.source);
|
|
506
|
+
outputs.push({ path: asset.path, text: decodeUtf8(bytes, asset.path), bytes });
|
|
507
|
+
}
|
|
508
|
+
for (const path of [...images].sort()) {
|
|
509
|
+
const bytes = await readFileContained(root, join(dirRel, path));
|
|
510
|
+
const text = decodeUtf8(bytes, path);
|
|
511
|
+
assertSafeSvg(text, path);
|
|
512
|
+
outputs.push({ path, text, bytes });
|
|
513
|
+
}
|
|
514
|
+
return outputs;
|
|
515
|
+
}
|
|
516
|
+
|
|
439
517
|
// --- 渲染单仓 ---
|
|
440
|
-
async function renderRepo(root, repo, release, leakLiterals, { check, assertGit }) {
|
|
518
|
+
async function renderRepo(root, repo, release, leakLiterals, { check, assertGit, inputOnly = false }) {
|
|
441
519
|
const site = repo.site;
|
|
442
520
|
const dirRel = join(repo.source, site.dir);
|
|
443
521
|
const pagesRel = join(dirRel, site.pages);
|
|
@@ -500,32 +578,44 @@ async function renderRepo(root, repo, release, leakLiterals, { check, assertGit
|
|
|
500
578
|
|
|
501
579
|
// --- 内存渲染全部产物(页面 + assets + .nojekyll);任何失败发生在写盘之前 ---
|
|
502
580
|
// outputs: { path: target 内相对路径, text: 扫描用文本, bytes: 落盘字节 }
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
.
|
|
509
|
-
.replace(/<!--FOOTER-->/g, footerHtml());
|
|
510
|
-
html = replaceTokens(html);
|
|
511
|
-
// 未替换的 @TOKEN@ 占位符残留 fail-fast(防占位符幻觉静默穿透进产物 / 基线 / 发布)
|
|
512
|
-
const residual = html.match(/@[A-Z0-9_]+@/g);
|
|
513
|
-
if (residual) {
|
|
514
|
-
throw new RenderError(
|
|
515
|
-
`[render-public-site] ${repo.name}: ${p.id}.html 残留未替换的占位符: ${[...new Set(residual)].join(', ')}`,
|
|
516
|
-
2,
|
|
517
|
-
);
|
|
581
|
+
let outputs;
|
|
582
|
+
if (site.format === 'markdown-v1') {
|
|
583
|
+
try {
|
|
584
|
+
outputs = await markdownOutputs(root, repo, dirRel, pagesDoc, tokens);
|
|
585
|
+
} catch (cause) {
|
|
586
|
+
throw configError(repo.name, `markdown-v1 输入错误: ${cause.message}`, cause);
|
|
518
587
|
}
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
588
|
+
} else {
|
|
589
|
+
if (pagesDoc.groups !== undefined) {
|
|
590
|
+
throw configError(repo.name, 'markdown-v1 页面清单必须显式配置 site.format 与 site.template');
|
|
591
|
+
}
|
|
592
|
+
outputs = [];
|
|
593
|
+
for (const p of ordered) {
|
|
594
|
+
let html = await readFileContained(root, join(dirRel, p.id + '.html'), { encoding: 'utf8' });
|
|
595
|
+
html = html
|
|
596
|
+
.replace(/<!--NAV-->/g, navHtml(p.id))
|
|
597
|
+
.replace(/<!--PAGER-->/g, pagerHtml(p.id))
|
|
598
|
+
.replace(/<!--FOOTER-->/g, footerHtml());
|
|
599
|
+
html = replaceTokens(html);
|
|
600
|
+
// 未替换的 @TOKEN@ 占位符残留 fail-fast(防占位符幻觉静默穿透进产物 / 基线 / 发布)
|
|
601
|
+
const residual = html.match(/@[A-Z0-9_]+@/g);
|
|
602
|
+
if (residual) {
|
|
603
|
+
throw new RenderError(
|
|
604
|
+
`[render-public-site] ${repo.name}: ${p.id}.html 残留未替换的占位符: ${[...new Set(residual)].join(', ')}`,
|
|
605
|
+
2,
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
const out = '<!-- generated by skill-family-doc-render -->\n' + html;
|
|
609
|
+
outputs.push({ path: p.id + '.html', text: out, bytes: Buffer.from(out, 'utf8') });
|
|
610
|
+
}
|
|
611
|
+
// 旧 HTML 兼容模式继续复制项目自有资源。
|
|
612
|
+
const assetSrc = resolve(root, dirRel, 'assets');
|
|
613
|
+
if (existsSync(assetSrc)) {
|
|
614
|
+
for (const f of (await readdir(assetSrc)).sort()) {
|
|
615
|
+
if (['.html', '.css', '.js', '.svg'].includes(extname(f))) {
|
|
616
|
+
const bytes = await readFileContained(root, join(dirRel, 'assets', f));
|
|
617
|
+
outputs.push({ path: 'assets/' + f, text: bytes.toString('utf8'), bytes });
|
|
618
|
+
}
|
|
529
619
|
}
|
|
530
620
|
}
|
|
531
621
|
}
|
|
@@ -534,6 +624,7 @@ async function renderRepo(root, repo, release, leakLiterals, { check, assertGit
|
|
|
534
624
|
|
|
535
625
|
// 写盘前统一泄漏扫描:页面与 assets 同一规则(对解码后内容)
|
|
536
626
|
for (const o of outputs) scanOrThrow(`${repo.name}/${o.path}`, o.text, leakLiterals);
|
|
627
|
+
if (inputOnly) return;
|
|
537
628
|
|
|
538
629
|
// 逐文件 sha256(harness digestBytes),--check 与基线逐文件比对用
|
|
539
630
|
const digests = {};
|
|
@@ -653,9 +744,41 @@ async function renderRepo(root, repo, release, leakLiterals, { check, assertGit
|
|
|
653
744
|
|
|
654
745
|
}
|
|
655
746
|
|
|
747
|
+
function projectRecoveryPrompt(repoName) {
|
|
748
|
+
return `请使用 skill-family-docs-render-site 处理仓库 ${repoName} 的知识站检查失败。先读取 public-release.json 和 skill-family-doc-render --check-project --repo ${repoName} 的实际输出,再判断需要内容刷新、纯渲染还是修正配置。不提前刷新覆盖快照,不手改生成产物。`;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function projectCheckError(repoName, cause, fallbackExitCode = 1) {
|
|
752
|
+
const exitCode = Number.isInteger(cause?.exitCode) ? cause.exitCode : fallbackExitCode;
|
|
753
|
+
return new RenderError(
|
|
754
|
+
`[render-public-site --check-project] ${repoName}: public-release.json 检查失败\n`
|
|
755
|
+
+ `实际原因: ${cause.message}\n`
|
|
756
|
+
+ `恢复提示: ${projectRecoveryPrompt(repoName)}`,
|
|
757
|
+
exitCode,
|
|
758
|
+
cause,
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async function checkProject(root, repo, release, leakLiterals) {
|
|
763
|
+
try {
|
|
764
|
+
await renderRepo(root, repo, release, leakLiterals, { check: false, assertGit: false, inputOnly: true });
|
|
765
|
+
console.log(`[render-public-site --check-project] ${repo.name}: 输入、公开安全与正文机械合同通过`);
|
|
766
|
+
await reportCoverageStatus(root, repo);
|
|
767
|
+
await renderRepo(root, repo, release, leakLiterals, { check: true, assertGit: false });
|
|
768
|
+
const sourceRoot = await repoSourceRoot(root, repo);
|
|
769
|
+
const targetDir = await containedOrConfig(sourceRoot, repo.site.target, repo.name, 'site.target');
|
|
770
|
+
const pagesDoc = await readJsonContained(sourceRoot, join(repo.site.dir, repo.site.pages), 'pages.json', repo.name);
|
|
771
|
+
await checkRenderedLinks({ targetRoot: targetDir, pageIds: pagesDoc.pages.map((page) => page.id) });
|
|
772
|
+
console.log(`[render-public-site --check-project] ${repo.name}: 内部链接与资源通过`);
|
|
773
|
+
console.log(`[render-public-site --check-project] ${repo.name}: public-release.json 只读项目检查通过`);
|
|
774
|
+
} catch (cause) {
|
|
775
|
+
throw projectCheckError(repo.name, cause);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
656
779
|
// === 主流程 ===
|
|
657
780
|
// 闭集参数解析:允许参数只有 --check / --assert-git / --status /
|
|
658
|
-
// --refresh-coverage / --repo <name> / --help / -h。
|
|
781
|
+
// --refresh-coverage / --check-project / --repo <name> / --help / -h。
|
|
659
782
|
// 未知选项、游离位置参数、重复取值参数、--repo 缺值或值本身是另一个选项,
|
|
660
783
|
// 均以 RenderError(exit 2)失败,错误信息指出具体参数。不引入 CLI 框架。
|
|
661
784
|
// --help / -h 由 main() 统一呈现:闭集解析先于配置读取与写盘,打印 USAGE_TEXT 后退出 0。
|
|
@@ -670,6 +793,8 @@ Usage:
|
|
|
670
793
|
read-only coverage status and Git changes
|
|
671
794
|
skill-family-doc-render --refresh-coverage --repo <name>
|
|
672
795
|
refresh only site-coverage-lock.json
|
|
796
|
+
skill-family-doc-render --check-project --repo <name>
|
|
797
|
+
read-only input, coverage, render and link checks
|
|
673
798
|
|
|
674
799
|
Reads public-release.json from the current working directory.`;
|
|
675
800
|
|
|
@@ -679,6 +804,7 @@ function parseArgs(argv) {
|
|
|
679
804
|
assertGit: false,
|
|
680
805
|
status: false,
|
|
681
806
|
refreshCoverage: false,
|
|
807
|
+
checkProject: false,
|
|
682
808
|
repoArg: null,
|
|
683
809
|
help: false,
|
|
684
810
|
};
|
|
@@ -693,6 +819,8 @@ function parseArgs(argv) {
|
|
|
693
819
|
flags.status = true;
|
|
694
820
|
} else if (arg === '--refresh-coverage') {
|
|
695
821
|
flags.refreshCoverage = true;
|
|
822
|
+
} else if (arg === '--check-project') {
|
|
823
|
+
flags.checkProject = true;
|
|
696
824
|
} else if (arg === '--help' || arg === '-h') {
|
|
697
825
|
flags.help = true;
|
|
698
826
|
} else if (arg === '--repo') {
|
|
@@ -721,6 +849,20 @@ function parseArgs(argv) {
|
|
|
721
849
|
if ((flags.status || flags.refreshCoverage) && flags.repoArg === null) {
|
|
722
850
|
throw new RenderError('[render-public-site] 参数错误: --status 与 --refresh-coverage 必须配合 --repo <name>', 2);
|
|
723
851
|
}
|
|
852
|
+
if (flags.checkProject && (flags.check || flags.assertGit || flags.status || flags.refreshCoverage || flags.help)) {
|
|
853
|
+
throw projectCheckError(
|
|
854
|
+
flags.repoArg ?? '<missing>',
|
|
855
|
+
new RenderError('参数错误: --check-project 不能与其他检查、覆盖、Git 或帮助模式组合', 2),
|
|
856
|
+
2,
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
if (flags.checkProject && flags.repoArg === null) {
|
|
860
|
+
throw projectCheckError(
|
|
861
|
+
'<missing>',
|
|
862
|
+
new RenderError('参数错误: --check-project 必须配合 --repo <name>', 2),
|
|
863
|
+
2,
|
|
864
|
+
);
|
|
865
|
+
}
|
|
724
866
|
return flags;
|
|
725
867
|
}
|
|
726
868
|
|
|
@@ -730,7 +872,7 @@ function parseArgs(argv) {
|
|
|
730
872
|
// 单个 repo 内部(含泄漏扫描命中)保持 fail-fast。
|
|
731
873
|
export async function main(argv = process.argv.slice(2), { cwd = process.cwd() } = {}) {
|
|
732
874
|
// 闭集解析在读取任何配置或写盘之前完成;参数失败时目标树一个字节都不动。
|
|
733
|
-
const { check, assertGit, status, refreshCoverage, repoArg, help } = parseArgs(argv);
|
|
875
|
+
const { check, assertGit, status, refreshCoverage, checkProject: projectCheck, repoArg, help } = parseArgs(argv);
|
|
734
876
|
if (help) {
|
|
735
877
|
console.log(USAGE_TEXT);
|
|
736
878
|
return 0;
|
|
@@ -741,10 +883,17 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd() }
|
|
|
741
883
|
// --- 读取发布配置 ---
|
|
742
884
|
const releasePath = resolve(root, 'public-release.json');
|
|
743
885
|
if (!existsSync(releasePath)) {
|
|
744
|
-
|
|
886
|
+
const cause = new RenderError('[render-public-site] 未找到 public-release.json', 2);
|
|
887
|
+
throw projectCheck ? projectCheckError(repoArg, cause, 2) : cause;
|
|
888
|
+
}
|
|
889
|
+
let release;
|
|
890
|
+
try {
|
|
891
|
+
release = await readJsonContained(root, 'public-release.json', 'public-release.json', '(workspace)');
|
|
892
|
+
assertValid(release, publicReleaseSchema, 'public-release.json');
|
|
893
|
+
} catch (cause) {
|
|
894
|
+
if (projectCheck) throw projectCheckError(repoArg, cause, 2);
|
|
895
|
+
throw cause;
|
|
745
896
|
}
|
|
746
|
-
const release = await readJsonContained(root, 'public-release.json', 'public-release.json', '(workspace)');
|
|
747
|
-
assertValid(release, publicReleaseSchema, 'public-release.json');
|
|
748
897
|
|
|
749
898
|
// 泄漏扫描追加字面量(来自配置;路径类用 forbiddenPublicPaths,任意私有词用 privateLiterals)
|
|
750
899
|
const leakLiterals = [
|
|
@@ -757,7 +906,8 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd() }
|
|
|
757
906
|
if (repoArg) {
|
|
758
907
|
const hit = selected.find((r) => r.name === repoArg);
|
|
759
908
|
if (!hit) {
|
|
760
|
-
|
|
909
|
+
const cause = new RenderError(`[render-public-site] 未找到带 site 的 repo: ${repoArg}`, 2);
|
|
910
|
+
throw projectCheck ? projectCheckError(repoArg, cause, 2) : cause;
|
|
761
911
|
}
|
|
762
912
|
selected = [hit];
|
|
763
913
|
}
|
|
@@ -770,6 +920,7 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd() }
|
|
|
770
920
|
try {
|
|
771
921
|
if (status) await reportCoverageStatus(root, repo);
|
|
772
922
|
else if (refreshCoverage) await refreshCoverageLock(root, repo);
|
|
923
|
+
else if (projectCheck) await checkProject(root, repo, release, leakLiterals);
|
|
773
924
|
else await renderRepo(root, repo, release, leakLiterals, { check, assertGit });
|
|
774
925
|
results.push({ name: repo.name, ok: true });
|
|
775
926
|
} catch (err) {
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Editorial 文档模板
|
|
2
|
+
|
|
3
|
+
本目录把已认可的阅读界面封装为渲染包内的纯模板函数。输入页面清单及语义内容块,返回静态 HTML 和固定资源位置;模板不读写项目文件。
|
|
4
|
+
|
|
5
|
+
这份说明面向渲染器集成者,假定读者了解 JavaScript 模块和当前项目的页面清单。该模板从 `skill-family-doc-render` 0.4.0 起由唯一 CLI 在显式 `markdown-v1` 模式中调用;使用前仍需从官方 npm registry 核对该精确版本是否可用。
|
|
6
|
+
|
|
7
|
+
## 最小调用
|
|
8
|
+
|
|
9
|
+
以下虚构项目从同一包的深层路径导入函数。它只有首页和一篇教程,可展示基础目录、首页入口与代码组件:
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
import { renderEditorialSite } from 'skill-family-doc-render/templates/editorial/index.mjs';
|
|
13
|
+
|
|
14
|
+
const output = renderEditorialSite({
|
|
15
|
+
site: {
|
|
16
|
+
title: '青苔笔记',
|
|
17
|
+
description: '虚构项目的操作说明',
|
|
18
|
+
productKind: 'plugin',
|
|
19
|
+
},
|
|
20
|
+
groups: [{ id: 'start', title: '入门' }],
|
|
21
|
+
pages: [
|
|
22
|
+
{
|
|
23
|
+
id: 'index', title: '从一项具体操作开始', navTitle: '开始使用',
|
|
24
|
+
role: 'overview', kind: 'home', group: 'start', order: 0, inPager: false,
|
|
25
|
+
blocks: [{ type: 'paragraph', content: ['准备项目后,整理第一份说明。'] }],
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: 'setup', title: '准备项目并确认输入', navTitle: '准备项目',
|
|
29
|
+
role: 'setup', kind: 'task', group: 'start', order: 1, inPager: true,
|
|
30
|
+
blocks: [
|
|
31
|
+
{ type: 'paragraph', content: ['提供项目位置及已有说明。'] },
|
|
32
|
+
{ type: 'heading', level: 2, id: 'request', content: ['发起准备请求'] },
|
|
33
|
+
{ type: 'code', language: 'prompt', text: '检查当前项目,保留已有配置。' },
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
});
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
调用不访问磁盘。示例返回两个页面;渲染器再执行资源收容、SVG 安全检查、泄漏扫描、基线计算和输出替换。
|
|
41
|
+
|
|
42
|
+
返回对象包含两个数组:
|
|
43
|
+
|
|
44
|
+
- `pages`:每项为 `{ path, text }`。页面路径固定为 `<id>.html`,文字是完整 HTML。
|
|
45
|
+
- `assets`:每项为 `{ path, source }`。`source` 是指向包内文件的 `URL` 对象;资源路径固定为 `assets/editorial/style.css`、`site.js` 和 `mark.svg`。
|
|
46
|
+
|
|
47
|
+
页面、资源和链接均使用相对路径,可部署在站点子路径。资源列表只列模板自带文件;正文图片由调用者根据内容源收集和检查。相同输入产生相同页面字节,数组按页面顺序输出,不使用日期、随机数、系统路径或网络结果。
|
|
48
|
+
|
|
49
|
+
## 页面清单
|
|
50
|
+
|
|
51
|
+
`site` 提供站点标题、简短描述和项目类型。`productKind` 支持 `plugin`、`library`;本函数按已确定的清单渲染,不根据类型另建导航。首次清单由 `skill-family-docs` 的本地接入与生成流程建立。
|
|
52
|
+
|
|
53
|
+
`groups` 按数组顺序定义分组,每组包含安全 ID 与显示标题。页面由 `group` 引用分组,在组内按 `order` 排序。页面字段及约束见下表。
|
|
54
|
+
|
|
55
|
+
| 字段 | 含义与约束 |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| `id` | 页面地址来源,全站唯一;允许字母、数字、下划线、短横线,首字符必须为字母或数字 |
|
|
58
|
+
| `title` | 唯一 H1、浏览器标题与搜索标题 |
|
|
59
|
+
| `navTitle` | 可选短导航标签;未提供时使用页面标题 |
|
|
60
|
+
| `role` | 读者任务,支持 overview、setup、first-success、maintenance、release、troubleshooting、upgrade、reference |
|
|
61
|
+
| `kind` | 固定版式,支持 home、task、reference、troubleshooting |
|
|
62
|
+
| `group` | 已声明的分组 ID |
|
|
63
|
+
| `order` | 全站唯一的安全整数,可以为负数 |
|
|
64
|
+
| `inPager` | 是否加入教程,只允许 task 页设为 true |
|
|
65
|
+
| `blocks` | 顺序排列的语义内容块,首项必须为非空段落 |
|
|
66
|
+
|
|
67
|
+
每站必须恰有一页 `home`,与 `overview` 职责成对出现。其余页面不强制凑齐职责;排错页和参考页使用相同阅读组件,但不生成教程操作入口。
|
|
68
|
+
|
|
69
|
+
首段只在页首显示一次,同时派生首页任务说明、页面描述与搜索文字,不另设摘要字段。首页从教程页中按顺序生成任务入口,主操作指向第一项;存在其他 `maintenance` 任务时显示次操作。教程前后页使用同一顺序,首尾不循环;只有一篇教程时不显示翻页组件。
|
|
70
|
+
|
|
71
|
+
## 语义内容块
|
|
72
|
+
|
|
73
|
+
`content` 等行内字段使用数组。最简单的数组只有字符串;需要强调或链接时,加入下面四种对象:
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
[
|
|
77
|
+
'操作完成后,查看',
|
|
78
|
+
{ type: 'link', href: 'setup.html#request', content: ['准备请求'] },
|
|
79
|
+
{ type: 'strong', content: ['必要条件'] },
|
|
80
|
+
{ type: 'em', content: ['可选说明'] },
|
|
81
|
+
{ type: 'code', text: 'project.config' },
|
|
82
|
+
]
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
强调节点允许继续包含行内内容;链接不能嵌套链接。模板转义所有文字,不把字符串解释为 HTML。
|
|
86
|
+
|
|
87
|
+
| 内容块 | 输入字段 | 输出 |
|
|
88
|
+
| --- | --- | --- |
|
|
89
|
+
| paragraph | `content` 行内数组 | 段落;首块用作导语 |
|
|
90
|
+
| heading | `level` 为 2–4,`id` 与 `content` | 稳定标题、章节链接及页内目录 |
|
|
91
|
+
| list | `ordered` 布尔值,`items` 为行内数组的数组 | 有序步骤或普通列表,不支持嵌套列表 |
|
|
92
|
+
| table | `headers` 为行内单元格数组,`rows` 为行数组 | 带列标题的表格,宽表在本地容器滚动 |
|
|
93
|
+
| code | `language` 语言标记,`text` 原文 | 代码块;prompt 标记显示提示词组件 |
|
|
94
|
+
| callout | `tone` 为 NOTE、TIP、WARNING,`content` | 说明、建议或注意提示框 |
|
|
95
|
+
| image | `src` 站内 SVG 路径,`alt` 非空替代说明 | 普通 img,不内联 SVG |
|
|
96
|
+
|
|
97
|
+
代码复制保留 `text` 的首尾空白与换行。模板不执行命令,也不做语法高亮。列表项和提示框仅支持行内内容;嵌套容器、页签、自定义布局和任意属性不在当前接口内。
|
|
98
|
+
|
|
99
|
+
标题 ID 与页面 ID 使用相同字符范围。同页标题不能重复,也不能与当前页面 ID 冲突。`editorial-` 前缀保留给页面外框及代码控件;输入使用此前缀时报告错误。标题更名应保留原 ID。
|
|
100
|
+
|
|
101
|
+
普通链接支持 `http`、`https` 和不含查询参数的 `mailto`。站内链接使用已声明的 `页面ID.html`、`页面ID.html#章节ID` 或 `#章节ID`,页面地址可加 `./`。
|
|
102
|
+
|
|
103
|
+
本站链接必须能在当前输入中找到目标。不接受上级目录、根相对地址、查询参数、协议相对地址或任意协议;外部网页不在本函数中联网验证。
|
|
104
|
+
|
|
105
|
+
图片路径限于 `assets/` 下的 `.svg` 文件,目录与文件名使用字母、数字、下划线、短横线;`assets/editorial/` 是模板资源保留目录。这里只校验引用形式,不读取或检查 SVG 字节。调用者仍须执行资源收容、主动内容检查和现有泄漏扫描。
|
|
106
|
+
|
|
107
|
+
无效清单、重复 ID、未知字段、非法链接及不支持的块会抛出 `TypeError`,不会返回部分产物。CLI 退出码和既有输出保留由后续唯一渲染入口处理。
|
|
108
|
+
|
|
109
|
+
## 浏览器增强与降级
|
|
110
|
+
|
|
111
|
+
不运行 JavaScript 时,正文、页面目录、章节链接及教程翻页都可用。手机目录采用原生 details;主题通过 CSS 跟随系统。无功能的按钮在初始 HTML 中隐藏,相关处理器安装后才显示。
|
|
112
|
+
|
|
113
|
+
脚本提供本地搜索、复制、外观选择、移动目录和当前章节高亮。搜索索引从完整公开语义内容派生,包含代码、表格和图片替代说明;结果按标题命中优先,再按页面与章节顺序排列。搜索只使用页面内嵌数据,不联网。
|
|
114
|
+
|
|
115
|
+
搜索支持方向键、回车、Escape 及中文输入法组合态保护。关闭模态窗口后恢复触发控件和阅读位置;正常页面跳转使用真实链接。外观偏好仅保存在浏览器本地,复制失败时提示手动复制,不虚报成功。
|
|
116
|
+
|
|
117
|
+
## Markdown 接线与验证
|
|
118
|
+
|
|
119
|
+
`markdown-v1` 使用精确依赖 `marked@18.0.11` 分词,再由本包的受限格式编译器生成上述内存对象。该对象是解析阶段到模板阶段的输入,不是另一份需要手工维护的内容源。新模式的项目只维护 Markdown 和允许的站内 SVG,不提供主题 CSS 或 JavaScript。
|
|
120
|
+
|
|
121
|
+
目录访问、内容图片收集与安全检查、泄漏扫描、覆盖快照、基线和输出替换继续采用现有流程与 Foundation 能力。模板本身不包含这些机制,也没有另建 CLI、配置读取器或发布器。旧站点未声明 `format` 时继续走完整 HTML 兼容路径;只有显式 `markdown-v1` 与 `editorial` 才调用本模板。
|
|
122
|
+
|
|
123
|
+
相邻测试位于 `test/editorial.test.mjs` 和 `test/markdown-v1.test.mjs`,虚构输入位于 `test/fixtures/editorial.mjs`。聚焦检查命令为:
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
node --test test/editorial.test.mjs test/markdown-v1.test.mjs
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
命令在渲染包目录、符合包内 `engines.node` 的运行时下执行。静态和输入测试不能代替浏览器的视觉、键盘及真实辅助技术验证。真实输入法、屏幕阅读器、跨应用粘贴和实际打印仍未完成设备验收。
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40"><rect x="1" y="1" width="38" height="38" rx="10" fill="#2f634c"/><path d="M12 12h11a5 5 0 0 1 5 5v12H17a5 5 0 0 1-5-5V12Z" fill="none" stroke="#f8faf6" stroke-width="1.5"/><path d="M17 12v17m4-12h3m-3 5h3" fill="none" stroke="#f8faf6" stroke-width="1.5" stroke-linecap="round"/></svg>
|