1/**
2 * Markdown → HTML rendering for sub-pages.
3 *
4 * Wraps `marked` with a custom link renderer that resolves cross-references
5 * between skill bodies and their references, and emits stable heading slugs
6 * so anti-pattern → skill section anchors work.
7 *
8 * Skeleton in commit 1. Link resolution and heading slugger are wired up in
9 * commit 3 (skills generator) when the data model lands.
10 */
11
12import { marked } from 'marked';
13
14/**
15 * Slugify a heading text into a stable anchor id.
16 * Matches the convention: lowercase, strip non-alphanum, spaces → dashes.
17 *
18 * @param {string} text
19 * @returns {string}
20 */
21export function slugify(text) {
22 return String(text)
23 .toLowerCase()
24 .trim()
25 .replace(/[^\w\s-]/g, '')
26 .replace(/[\s_]+/g, '-')
27 .replace(/^-+|-+$/g, '');
28}
29
30/**
31 * Build a marked renderer configured for impeccable's skill/tutorial bodies.
32 *
33 * @param {object} opts
34 * @param {Set<string>} [opts.knownSkillIds] - slugs of skills the site knows about; unknown /name mentions render as plain text
35 * @param {string} [opts.currentSkillId] - when rendering a skill body, resolve `reference/foo.md` to `#reference-foo` on the current page
36 * @returns {import('marked').Renderer}
37 */
38export function createRenderer({ knownSkillIds = new Set(), currentSkillId = null } = {}) {
39 const renderer = new marked.Renderer();
40
41 // Heading slugger — stable ids so we can anchor-link from elsewhere.
42 // Supports {#custom-id} suffix (kramdown/pandoc style) for explicit anchors.
43 renderer.heading = ({ tokens, depth }) => {
44 const raw = tokens.map((t) => t.raw || '').join('');
45 const customIdMatch = raw.match(/\s*\{#([a-z0-9_-]+)\}\s*$/i);
46 let id, text;
47 if (customIdMatch) {
48 id = customIdMatch[1];
49 // Strip the {#id} suffix from the rendered text
50 const cleanRaw = raw.slice(0, customIdMatch.index);
51 text = renderer.parser.parseInline(marked.lexer(cleanRaw, { gfm: true })[0]?.tokens || tokens);
52 } else {
53 id = slugify(raw);
54 text = renderer.parser.parseInline(tokens);
55 }
56 return `<h${depth} id="${id}">${text}</h${depth}>\n`;
57 };
58
59 // Link resolver.
60 renderer.link = ({ href, title, tokens }) => {
61 const text = renderer.parser.parseInline(tokens);
62 const resolved = resolveHref(href, { knownSkillIds, currentSkillId });
63 const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
64 const relAttr = resolved.external ? ' target="_blank" rel="noopener"' : '';
65 return `<a href="${escapeAttr(resolved.href)}"${titleAttr}${relAttr}>${text}</a>`;
66 };
67
68 // Fenced code blocks — minimal glass-terminal styling, no syntax highlighter in v1.
69 // Wrapped in a container with a copy button; click handling lives in the
70 // page-level inline script added by render-page.js.
71 renderer.code = ({ text, lang }) => {
72 const langClass = lang ? ` code-block--${escapeAttr(lang)}` : '';
73 const copyValue = escapeAttr(text);
74 return `<div class="code-block-wrap"><pre class="code-block${langClass}"><code>${escapeHtml(text)}</code></pre><button class="code-block-copy" type="button" data-copy="${copyValue}" aria-label="Copy to clipboard"></button></div>\n`;
75 };
76
77 return renderer;
78}
79
80/**
81 * Resolve a markdown link href against the site's URL scheme.
82 *
83 * - `http(s)://…` → unchanged, external
84 * - `reference/foo.md` → `#reference-foo` on current skill page
85 * - `/skill-id` (known) → `/skills/skill-id`
86 * - `#anchor` → unchanged (in-page anchor)
87 * - anything else → unchanged (will be caught by build warnings later)
88 *
89 * @param {string} href
90 * @param {{ knownSkillIds: Set<string>, currentSkillId: string|null }} ctx
91 * @returns {{ href: string, external: boolean }}
92 */
93function resolveHref(href, { knownSkillIds, currentSkillId }) {
94 if (!href) return { href: '', external: false };
95
96 // External links
97 if (/^https?:\/\//i.test(href) || /^mailto:/i.test(href)) {
98 return { href, external: true };
99 }
100
101 // In-page anchor
102 if (href.startsWith('#')) {
103 return { href, external: false };
104 }
105
106 // reference/foo.md → #reference-foo on the current skill page
107 const refMatch = href.match(/^reference\/([a-z0-9-]+)\.md$/i);
108 if (refMatch && currentSkillId) {
109 return { href: `#reference-${refMatch[1].toLowerCase()}`, external: false };
110 }
111
112 // /skill-id mentioned in prose (e.g. "run /polish")
113 const slashMatch = href.match(/^\/([a-z0-9-]+)$/i);
114 if (slashMatch && knownSkillIds.has(slashMatch[1])) {
115 return { href: `/skills/${slashMatch[1]}`, external: false };
116 }
117
118 // [text](other-skill) → /skills/other-skill
119 if (/^[a-z0-9-]+$/i.test(href) && knownSkillIds.has(href)) {
120 return { href: `/skills/${href}`, external: false };
121 }
122
123 // Unknown — pass through. Generator can warn separately.
124 return { href, external: false };
125}
126
127/**
128 * Render a markdown string to HTML.
129 *
130 * @param {string} markdown
131 * @param {object} [opts]
132 * @param {Set<string>} [opts.knownSkillIds]
133 * @param {string} [opts.currentSkillId]
134 * @returns {string} HTML
135 */
136export function renderMarkdown(markdown, opts = {}) {
137 const renderer = createRenderer(opts);
138 return marked.parse(markdown, {
139 renderer,
140 gfm: true,
141 breaks: false,
142 });
143}
144
145function escapeHtml(str) {
146 return String(str)
147 .replace(/&/g, '&')
148 .replace(/</g, '<')
149 .replace(/>/g, '>')
150 .replace(/"/g, '"')
151 .replace(/'/g, ''');
152}
153
154function escapeAttr(str) {
155 return String(str).replace(/"/g, '"');
156}