/* Just enough Markdown for the template notebooks. * * Surveyed across all 249 markdown cells in the corpus, the prose uses eight * constructs and no more: headings, inline code, bold, italic, bullet lists, * numbered lists, fenced code, and paragraphs. No tables, no links, no raw * HTML, no math, no blockquotes. * * So this renders those eight and nothing else, rather than pulling in a * library. The pages ship `script-src 'self'` with no bundler, so a dependency * here means another vendored file to keep current — a poor trade for 60 lines. * If a future template needs tables, add them; do not reach for marked.js on * the strength of one cell. * * Everything is escaped before any markup is inserted. The corpus is committed * and comes from a swan checkout, so this is not the security boundary that * matters — but a renderer that emits unescaped input is the kind of thing that * gets copied somewhere it does matter, and `<` appearing literally in prose * about generics should not silently eat the rest of a paragraph either. */ /* The repo's own escaper, not a second one. check_escaping.mjs recognises * esc()/escAttr()/escHtml() by name, so a private equivalent here is both a * duplicate and invisible to the check that exists to catch exactly this. */ export { esc as escapeHtml } from './util.js'; import { esc as escapeHtml } from './util.js'; /* The notebook cells contain no HTML — surveyed, all 249 of them. The READMEs * do: several wrap their troubleshooting in
/, which escaping * faithfully rendered as literal "
" down the page. * * Dropped rather than honoured, and only these tags by name. Their text is * content and is kept; the collapsing is presentation this page does not need. * A blanket tag-stripper would eat the "<" in prose about generics, and letting * them through as real markup would mean rendering unescaped input — which this * file deliberately never does. */ const STRUCTURAL = /<\/?(?:details|summary|br|div|p)\b[^>]*>/gi; const stripStructural = (s) => String(s).replace(STRUCTURAL, ''); /** Inline spans, applied to already-escaped text. */ function inline(text) { return text // Code first, so ** or * inside a code span is not read as emphasis. .replace(/`([^`]+)`/g, (_, code) => `${code}`) .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/(^|[^*])\*([^*\n]+)\*/g, '$1$2'); } /** * Render a markdown cell to HTML. * * Line-based rather than a parser: the grammar above has no nesting to track, * and a line-based reader is one that can be read in full before trusting it. */ export function renderMarkdown(src) { const lines = String(src).replace(/\r\n/g, '\n').split('\n'); const out = []; let para = []; // paragraph lines awaiting a blank line let list = null; // 'ul' | 'ol' while one is open let fence = null; // code fence body while one is open const flushPara = () => { if (!para.length) return; out.push(`

${inline(escapeHtml(para.join(' ')))}

`); para = []; }; const closeList = () => { if (list) { out.push(``); list = null; } }; for (const raw of lines) { // Prose only. Inside a fence the text is code, and a template showing how // to emit a
should keep its
. const line = fence === null ? stripStructural(raw) : raw; if (fence !== null) { if (/^\s*```/.test(line)) { out.push(`
${escapeHtml(fence.join('\n'))}
`); fence = null; } else fence.push(line); continue; } if (/^\s*```/.test(line)) { flushPara(); closeList(); fence = []; continue; } if (!line.trim()) { flushPara(); closeList(); continue; } const heading = line.match(/^(#{1,6})\s+(.*)$/); if (heading) { flushPara(); closeList(); // Notebook headings start at h1 and would outrank the page's own title, // so everything shifts down one and stops at h6. const level = Math.min(heading[1].length + 1, 6); out.push(`${inline(escapeHtml(heading[2].trim()))}`); continue; } const bullet = line.match(/^\s*[-*+]\s+(.*)$/); const numbered = line.match(/^\s*\d+[.)]\s+(.*)$/); if (bullet || numbered) { flushPara(); const want = bullet ? 'ul' : 'ol'; if (list !== want) { closeList(); out.push(`<${want}>`); list = want; } out.push(`
  • ${inline(escapeHtml((bullet || numbered)[1]))}
  • `); continue; } closeList(); para.push(line.trim()); } // An unterminated fence still renders its body: dropping it would lose prose // over a missing three characters. if (fence !== null) out.push(`
    ${escapeHtml(fence.join('\n'))}
    `); flushPara(); closeList(); return out.join('\n'); } /** The first paragraph, as plain text — for cards and summaries. */ export function firstParagraph(src) { for (const block of String(src).replace(/\r\n/g, '\n').split(/\n\s*\n/)) { const text = block.trim(); if (!text || text.startsWith('#') || text.startsWith('```')) continue; return inline(escapeHtml(text.replace(/\n/g, ' '))) .replace(/<[^>]+>/g, '') // strip the markup back off .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') .replace(/"/g, '"'); } return ''; }