| 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("&", "&").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">"); |
| 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 |