返回 CodeWhale
install-guide-lib.mjs
根目录 / web / scripts / install-guide-lib.mjs
1 import { createHash } from "node:crypto";
2 import { Marked, Renderer } from "marked";
3 import GithubSlugger from "github-slugger";
4
5 const DOCS_URL = "https://github.com/codewhale-hq/CodeWhale/blob/main/docs/";
6
7 function docLink(href) {
8 const url = new URL(href, DOCS_URL);
9 if (!["https:", "http:", "mailto:"].includes(url.protocol)) {
10 throw new Error(`Unsupported install-guide link protocol: ${url.protocol}`);
11 }
12 if (href.startsWith("#")) return href;
13 if (url.href.startsWith(`${DOCS_URL}INSTALL.md#`)) return url.hash;
14 return url.href;
15 }
16
17 /** Render the checked-in guide at build time; fenced commands stay copyable. */
18 export function buildInstallGuide(source) {
19 const slugger = new GithubSlugger();
20 const anchors = [];
21 const fragments = [];
22 let tableNumber = 0;
23 const marked = new Marked({
24 gfm: true,
25 renderer: {
26 heading(token) {
27 const id = slugger.slug(token.text);
28 anchors.push(id);
29 return `<h${token.depth} id="${id}">${this.parser.parseInline(token.tokens)}</h${token.depth}>\n`;
30 },
31 html({ text }) {
32 // INSTALL.md uses explicit aliases for old incoming links. No other
33 // raw HTML is needed; reject it rather than widening the HTML surface.
34 const anchor = text.match(/^<a id="([a-z0-9-]+)">$/);
35 if (anchor) {
36 anchors.push(anchor[1]);
37 return text;
38 }
39 if (text === "</a>") return text;
40 throw new Error(`Unsupported raw HTML in install guide: ${text.slice(0, 80)}`);
41 },
42 table(token) {
43 const label = `Installation table ${++tableNumber}: ${token.header.map((cell) => cell.text).join(" / ")}`
44 .replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
45 return `<div class="install-guide-table" role="region" aria-label="${label}" tabindex="0">${Renderer.prototype.table.call(this, token)}</div>\n`;
46 },
47 },
48 walkTokens(token) {
49 if (token.type === "link" || token.type === "image") {
50 token.href = docLink(token.href);
51 if (token.href.startsWith("#")) fragments.push(decodeURIComponent(token.href.slice(1)));
52 }
53 },
54 });
55 const tokens = marked.lexer(source);
56 marked.walkTokens(tokens, marked.defaults.walkTokens);
57 const chunks = [];
58 let htmlTokens = [];
59 const flush = () => {
60 if (htmlTokens.length) {
61 chunks.push({ kind: "html", text: marked.parser(htmlTokens) });
62 htmlTokens = [];
63 }
64 };
65 for (const token of tokens) {
66 if (token.type === "code") {
67 flush();
68 chunks.push({ kind: "code", text: token.text });
69 } else {
70 htmlTokens.push(token);
71 }
72 }
73 flush();
74 const missing = fragments.filter((id) => !anchors.includes(id));
75 if (missing.length) throw new Error(`Missing INSTALL.md anchors: ${missing.join(", ")}`);
76 return { sourceHash: createHash("sha256").update(source).digest("hex"), anchors, chunks };
77 }
78
79 export function installAnchorErrors(text, anchors) {
80 return [...text.matchAll(/INSTALL\.md#([\w%.-]+)/g)]
81 .map((match) => decodeURIComponent(match[1]))
82 .filter((id) => !anchors.includes(id));
83 }
84
85 export function renderInstallGuideModule(source) {
86 const guide = buildInstallGuide(source);
87 return `// Generated from docs/INSTALL.md by scripts/derive-install.mjs. Do not edit.\n\nexport const INSTALL_GUIDE = ${JSON.stringify(guide, null, 2)} as const;\n`;
88 }
89
89 lines Plain Text