skill-family-doc-render 0.2.1 → 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.
@@ -0,0 +1,213 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // 将受限 markdown-v1 编译为 editorial 模板的语义内容块。
3
+ import { Lexer } from 'marked';
4
+
5
+ const TOKEN = /@[A-Z0-9_]+@/g;
6
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
7
+
8
+ export class MarkdownV1Error extends TypeError {
9
+ constructor(message) {
10
+ super(`markdown-v1: ${message}`);
11
+ }
12
+ }
13
+
14
+ const fail = (message) => { throw new MarkdownV1Error(message); };
15
+
16
+ /** Replace configured facts as literal data, never as Markdown syntax. */
17
+ export function replaceMarkdownTokens(value, tokens, label = 'text') {
18
+ if (typeof value !== 'string') fail(`${label} must be a string`);
19
+ const replaced = value.replace(TOKEN, (match) => (
20
+ Object.hasOwn(tokens, match) ? String(tokens[match]) : match
21
+ ));
22
+ const residual = replaced.match(TOKEN);
23
+ if (residual) fail(`${label} contains unresolved token(s): ${[...new Set(residual)].join(', ')}`);
24
+ return replaced;
25
+ }
26
+
27
+ function inline(tokens, facts, label) {
28
+ const result = [];
29
+ for (const token of tokens || []) {
30
+ switch (token.type) {
31
+ case 'text':
32
+ if (token.tokens?.length) result.push(...inline(token.tokens, facts, label));
33
+ else result.push(replaceMarkdownTokens(token.text, facts, label));
34
+ break;
35
+ case 'escape':
36
+ result.push(replaceMarkdownTokens(token.text, facts, label));
37
+ break;
38
+ case 'strong':
39
+ case 'em':
40
+ result.push({ type: token.type, content: inline(token.tokens, facts, label) });
41
+ break;
42
+ case 'codespan':
43
+ result.push({ type: 'code', text: replaceMarkdownTokens(token.text, facts, label) });
44
+ break;
45
+ case 'link':
46
+ if (token.title) fail(`${label} link titles are not supported`);
47
+ result.push({
48
+ type: 'link',
49
+ href: replaceMarkdownTokens(token.href, facts, `${label} link`),
50
+ content: inline(token.tokens, facts, label),
51
+ });
52
+ break;
53
+ case 'image':
54
+ fail(`${label} images must be the only content in their paragraph`);
55
+ break;
56
+ case 'html':
57
+ fail(`${label} raw HTML is not supported`);
58
+ break;
59
+ default:
60
+ fail(`${label} uses unsupported inline syntax: ${token.type}`);
61
+ }
62
+ }
63
+ return result;
64
+ }
65
+
66
+ function listItem(item, facts, label) {
67
+ if (item.task || item.checked !== undefined) fail(`${label} task lists are not supported`);
68
+ const content = item.tokens.filter((token) => token.type !== 'space');
69
+ if (content.length !== 1 || !['text', 'paragraph'].includes(content[0].type)) {
70
+ fail(`${label} nested or multi-paragraph list items are not supported`);
71
+ }
72
+ const token = content[0];
73
+ return inline(token.tokens || Lexer.lexInline(token.text, { gfm: true }), facts, label);
74
+ }
75
+
76
+ function explicitHeading(token) {
77
+ const match = /^(.*\S)\s+\{#([A-Za-z0-9][A-Za-z0-9_-]*)\}\s*$/.exec(token.text);
78
+ if (!match) {
79
+ if (/\s+\{[^}]*\}\s*$/.test(token.text)) fail('heading contains an unsupported or invalid attribute extension');
80
+ return null;
81
+ }
82
+ return { title: match[1], id: match[2] };
83
+ }
84
+
85
+ function slug(title) {
86
+ const value = title
87
+ .normalize('NFKD')
88
+ .toLowerCase()
89
+ .replace(/[^a-z0-9_-]+/g, '-')
90
+ .replace(/^-+|-+$/g, '');
91
+ return value || 'section';
92
+ }
93
+
94
+ function callout(token, facts, label) {
95
+ const meaningful = token.tokens.filter((item) => item.type !== 'space');
96
+ if (meaningful.length !== 1 || meaningful[0].type !== 'paragraph') {
97
+ fail(`${label} callout must contain one paragraph`);
98
+ }
99
+ const match = /^\[!(NOTE|TIP|WARNING)\]\s*\n([\s\S]*\S)\s*$/.exec(meaningful[0].text);
100
+ if (!match) fail(`${label} blockquotes must use NOTE, TIP, or WARNING callout syntax`);
101
+ return {
102
+ type: 'callout',
103
+ tone: match[1],
104
+ content: inline(Lexer.lexInline(match[2], { gfm: true }), facts, label),
105
+ };
106
+ }
107
+
108
+ /**
109
+ * @param {string} source
110
+ * @param {{pageId:string,tokens?:Record<string,string|number|boolean>}} options
111
+ * @returns {{blocks:object[],images:string[]}}
112
+ */
113
+ export function compileMarkdownV1(source, { pageId, tokens = {} }) {
114
+ if (typeof source !== 'string' || source.includes('\u0000')) fail(`${pageId}.md must be UTF-8 text without NUL`);
115
+ if (!SAFE_ID.test(pageId)) fail(`invalid page ID: ${pageId}`);
116
+
117
+ let lexed;
118
+ try {
119
+ lexed = Lexer.lex(source, { gfm: true, breaks: false, pedantic: false });
120
+ } catch (cause) {
121
+ throw new MarkdownV1Error(`${pageId}.md cannot be tokenized: ${cause.message}`);
122
+ }
123
+
124
+ const explicitIds = new Set([pageId]);
125
+ for (const token of lexed) {
126
+ if (token.type !== 'heading') continue;
127
+ if (token.depth === 1) fail(`${pageId}.md must not contain an H1`);
128
+ if (![2, 3, 4].includes(token.depth)) fail(`${pageId}.md heading levels must be H2-H4`);
129
+ const explicit = explicitHeading(token);
130
+ if (!explicit) continue;
131
+ if (explicit.id.startsWith('editorial-')) fail(`${pageId}.md uses a reserved heading ID: ${explicit.id}`);
132
+ if (explicitIds.has(explicit.id)) fail(`${pageId}.md contains duplicate heading ID: ${explicit.id}`);
133
+ explicitIds.add(explicit.id);
134
+ }
135
+
136
+ const usedIds = new Set(explicitIds);
137
+ const blocks = [];
138
+ const images = new Set();
139
+ for (const token of lexed) {
140
+ const label = `${pageId}.md`;
141
+ switch (token.type) {
142
+ case 'space':
143
+ break;
144
+ case 'paragraph': {
145
+ if (token.tokens.length === 1 && token.tokens[0].type === 'image') {
146
+ const image = token.tokens[0];
147
+ if (image.title) fail(`${label} image titles are not supported`);
148
+ const src = replaceMarkdownTokens(image.href, tokens, `${label} image`);
149
+ const alt = replaceMarkdownTokens(image.text, tokens, `${label} image alt`);
150
+ blocks.push({ type: 'image', src, alt });
151
+ images.add(src);
152
+ } else {
153
+ blocks.push({ type: 'paragraph', content: inline(token.tokens, tokens, label) });
154
+ }
155
+ break;
156
+ }
157
+ case 'heading': {
158
+ const explicit = explicitHeading(token);
159
+ const title = explicit?.title ?? token.text;
160
+ let headingId = explicit?.id;
161
+ if (!headingId) {
162
+ const candidate = slug(title);
163
+ const base = candidate.startsWith('editorial-') ? `section-${candidate}` : candidate;
164
+ headingId = base;
165
+ let suffix = 2;
166
+ while (usedIds.has(headingId)) {
167
+ headingId = `${base}-${suffix}`;
168
+ suffix += 1;
169
+ }
170
+ usedIds.add(headingId);
171
+ }
172
+ const headingTokens = Lexer.lexInline(title, { gfm: true });
173
+ blocks.push({ type: 'heading', level: token.depth, id: headingId, content: inline(headingTokens, tokens, label) });
174
+ break;
175
+ }
176
+ case 'list':
177
+ if (token.ordered && token.start !== 1) fail(`${label} ordered lists must start at 1`);
178
+ blocks.push({
179
+ type: 'list',
180
+ ordered: token.ordered,
181
+ items: token.items.map((item, index) => listItem(item, tokens, `${label} list item ${index + 1}`)),
182
+ });
183
+ break;
184
+ case 'table':
185
+ blocks.push({
186
+ type: 'table',
187
+ headers: token.header.map((cell) => inline(cell.tokens, tokens, label)),
188
+ rows: token.rows.map((row) => row.map((cell) => inline(cell.tokens, tokens, label))),
189
+ });
190
+ break;
191
+ case 'code':
192
+ if (!token.lang || token.codeBlockStyle === 'indented') fail(`${label} code fences require a language marker`);
193
+ blocks.push({
194
+ type: 'code',
195
+ language: token.lang,
196
+ text: replaceMarkdownTokens(token.text, tokens, `${label} code`),
197
+ });
198
+ break;
199
+ case 'blockquote':
200
+ blocks.push(callout(token, tokens, label));
201
+ break;
202
+ case 'html':
203
+ fail(`${label} raw HTML is not supported`);
204
+ break;
205
+ case 'def':
206
+ fail(`${label} reference definitions are not supported`);
207
+ break;
208
+ default:
209
+ fail(`${label} uses unsupported block syntax: ${token.type}`);
210
+ }
211
+ }
212
+ return { blocks, images: [...images].sort() };
213
+ }
@@ -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
+ }