event-horizon / src /markdown.js
maxdemarzi's picture
Deploy 4ed0390343adb220188e58f95ab1a8e7e1dd995e (manual: Actions blocked on billing) (part 2)
1b9ed71 verified
Raw
History Blame Contribute Delete
5.54 kB
/* 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 <details>/<summary>, which escaping
* faithfully rendered as literal "<details><summary>" 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>${code}</code>`)
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
}
/**
* 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(`<p>${inline(escapeHtml(para.join(' ')))}</p>`);
para = [];
};
const closeList = () => {
if (list) { out.push(`</${list}>`); list = null; }
};
for (const raw of lines) {
// Prose only. Inside a fence the text is code, and a template showing how
// to emit a <div> should keep its <div>.
const line = fence === null ? stripStructural(raw) : raw;
if (fence !== null) {
if (/^\s*```/.test(line)) {
out.push(`<pre class="md-code">${escapeHtml(fence.join('\n'))}</pre>`);
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(`<h${level}>${inline(escapeHtml(heading[2].trim()))}</h${level}>`);
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(`<li>${inline(escapeHtml((bullet || numbered)[1]))}</li>`);
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(`<pre class="md-code">${escapeHtml(fence.join('\n'))}</pre>`);
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(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&quot;/g, '"');
}
return '';
}