render-markdown.js

  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, '&amp;')
148    .replace(/</g, '&lt;')
149    .replace(/>/g, '&gt;')
150    .replace(/"/g, '&quot;')
151    .replace(/'/g, '&#39;');
152}
153
154function escapeAttr(str) {
155  return String(str).replace(/"/g, '&quot;');
156}