import { parse } from "comark"; import type { ComarkElement, ComarkNode, ComarkTree } from "comark"; import mermaidPlugin from "comark/plugins/mermaid"; import rosePineTheme from "@shikijs/themes/rose-pine"; import { parse as parseYaml } from "@std/yaml"; import { highlightTree } from "./highlight.ts"; import { LINK_ICON_FALLBACK, SOCIAL_ICONS } from "./icons.ts"; import { renderHTML } from "@comark/html"; import { generateFavicon } from "./favicon.ts"; import { Eta } from "@eta-dev/eta"; import { resolve } from "@std/path"; import { compileCss } from "@morkdeck/theme"; import { detectLayout, extractNotes, isElement } from "./layout.ts"; import { MermaidRenderError, renderMermaid } from "./mermaid.ts"; import type { Includes, RenderOptions } from "./types.ts"; const eta = new Eta({ views: resolve(import.meta.dirname ?? "", "./templates"), }); function escapeHtml(text: string): string { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } /** * Deck-level metadata loaded from optional YAML frontmatter at the top * of the .md source. Drives the marquee + colophon on the title and * outro slides; absent fields hide their slot, an absent block hides * the colophon entirely. * * --- * event: TechTalks Boston * date: May 2026 * location: Boston * speaker: Graham Barber * links: * github: gambarber * bluesky: graham.bsky.social * mastodon: "@graham@example.com" * website: grahambarber.com * --- */ interface DeckMeta { event?: string; date?: string; location?: string; speaker?: string; /** Optional pronouns rendered inline after the speaker name. */ pronouns?: string; /** * Optional handle of the speaker's choice, rendered as mono text * after the pronouns (e.g. a Bluesky `@graham.systems` or any * other primary identifier). Display only — to make it linkable, * also include it under `links`. */ handle?: string; /** * Optional URL / path to a portrait image. Rendered as a circular * headshot — large above the speaker name on the outro slide, * small to the left of the attribution on the title slide. */ avatar?: string; /** * Opt-in QR code on the outro slide. `true` generates a code * pointing at the document's own URL at view time (so the QR * always tracks where the deck is actually hosted). A string * value pins the QR to a fixed canonical URL instead. */ qr?: boolean | string; links?: Record; } /** * Split YAML frontmatter from the markdown body. Returns the parsed * metadata (or null when there isn't a leading `---` fence) plus the * remaining markdown. Parsing errors are non-fatal — we fall back to * "no metadata" rather than failing the whole build over malformed * YAML. */ function extractFrontmatter( source: string, ): { meta: DeckMeta | null; body: string } { // Normalize CRLF locally for the fence test; the rest of the body // round-trips unmodified. const head = source.slice(0, 4); if (head !== "---\n" && head !== "---\r") return { meta: null, body: source }; // Find the closing fence: a line that's just `---`, preceded by a // newline. We search from after the opening fence. const closeRe = /\r?\n---[ \t]*(\r?\n|$)/; const rest = source.slice(4); const m = closeRe.exec(rest); if (!m) return { meta: null, body: source }; const yamlText = rest.slice(0, m.index); const remainder = rest.slice(m.index + m[0].length); try { const parsed = parseYaml(yamlText) as DeckMeta | null; return { meta: parsed ?? null, body: remainder }; } catch { return { meta: null, body: source }; } } /** * Construct the href for a social platform from its handle. Known * platforms get conventional URLs; an `@user@host` Mastodon handle is * parsed for its host; websites are passed through with a default * `https://` if no scheme is present; emails get `mailto:`. */ function socialHref(kind: string, handle: string): string { switch (kind) { case "github": return `https://github.com/${handle.replace(/^@/, "")}`; case "bluesky": return `https://bsky.app/profile/${handle.replace(/^@/, "")}`; case "twitter": case "x": return `https://twitter.com/${handle.replace(/^@/, "")}`; case "threads": return `https://threads.net/@${handle.replace(/^@/, "")}`; case "linkedin": return `https://linkedin.com/in/${handle.replace(/^@/, "")}`; case "mastodon": { // Accept either "@user@host" or "user@host"; produce the host's // canonical profile URL. const stripped = handle.replace(/^@/, ""); const [user, host] = stripped.split("@"); if (user && host) return `https://${host}/@${user}`; return handle; } case "email": return `mailto:${handle}`; case "website": return /^https?:\/\//.test(handle) ? handle : `https://${handle}`; default: return handle; } } /** * Display label for a social handle — drops the scheme + leading * `www.` for websites so the URL reads cleanly, keeps the canonical * shape for everything else. */ function socialLabel(kind: string, handle: string): string { if (kind === "website") { return handle.replace(/^https?:\/\//, "").replace(/^www\./, "").replace( /\/$/, "", ); } return handle; } /** * Marquee strip rendered along the top of title/outro slides. Slow * horizontal scroll of mono-uppercase event metadata. We duplicate * the track so the CSS animation can translate by -50% for a seamless * loop. Returns an empty string when there isn't enough metadata to * carry — a marquee of one word looks broken. */ function renderMarquee(meta: DeckMeta): string { const items = [meta.event, meta.date, meta.location, meta.speaker].filter( (v): v is string => typeof v === "string" && v.length > 0, ); if (items.length < 2) return ""; const escapedItems = items.map(escapeHtml); // One pass of the track. We render it twice so the keyframes can // shift by -50% without revealing a gap at the seam. const pass = escapedItems .map((item) => `${item}` + `` ) .join(""); return ``; } /** * Colophon row rendered at the bottom of title/outro slides. Two * stacked lines of attribution on the left (speaker name above; the * event / location / date as a single dot-separated context line * below in mono-caps) and an icon row of social links on the right. * * Compact by design — the wordmark is the focal point of these * slides, the colophon is the marginalia. Each piece is optional; * the whole block disappears if there's nothing to show. */ function renderColophon(meta: DeckMeta): string { const contextParts = [meta.event, meta.location, meta.date].filter( (v): v is string => typeof v === "string" && v.length > 0, ); const hasAttribution = !!meta.speaker || contextParts.length > 0; const links = meta.links ?? {}; const linkItems: string[] = []; for (const [kind, handle] of Object.entries(links)) { if (typeof handle !== "string" || handle.length === 0) continue; const href = escapeHtml(socialHref(kind, handle)); const tooltip = escapeHtml(`${kind}: ${socialLabel(kind, handle)}`); const icon = SOCIAL_ICONS[kind] ?? LINK_ICON_FALLBACK; // Icon-only: handles spell themselves out in the marquee strip and // the title slide is consumed at a glance, not read in detail. The // tooltip + aria-label keep the handle reachable for screen // readers and hover. linkItems.push( `
  • ${icon}
  • `, ); } const avatarHtml = meta.avatar ? `${
      escapeHtml(meta.speaker ?? ` : ""; if (!hasAttribution && linkItems.length === 0 && !avatarHtml) return ""; // Author block: avatar + the text stack (speaker, byline, context) // emitted as siblings inside a single .colophon-author container. // Per-layout CSS arranges them: title pairs the avatar with the // speaker name in a 2-column grid (avatar | name on row 1, byline // and context spanning both columns on rows 2 and 3) so the // avatar's vertical center sits at the name's vertical center — // not at the byline (the smallest line) the way a flex `center` // alignment against the whole 3-line block would have produced. // Outro keeps the avatar above the text stack as a portrait. let authorHtml = ""; if (hasAttribution || avatarHtml) { const speakerHtml = meta.speaker ? `

    ${escapeHtml(meta.speaker)}

    ` : ""; const bylinePieces: string[] = []; if (meta.pronouns) { bylinePieces.push( `${escapeHtml(meta.pronouns)}`, ); } if (meta.handle) { bylinePieces.push( `${escapeHtml(meta.handle)}`, ); } const bylineHtml = bylinePieces.length === 0 ? "" : `

    ${ bylinePieces.join( ` `, ) }

    `; const contextHtml = contextParts.length === 0 ? "" : `

    ${ contextParts.map(escapeHtml).join( ` `, ) }

    `; authorHtml = `
    ` + `${avatarHtml}${speakerHtml}${bylineHtml}${contextHtml}` + `
    `; } const linksHtml = linkItems.length === 0 ? "" : ``; return `
    ${authorHtml}${linksHtml}
    `; } /** * QR block rendered on the outro slide when `meta.qr` is truthy. * The component generates the SVG on the client so it can pick up * the document's live URL — handing the audience a scan that * always points back to wherever they're currently viewing the * deck. A string-valued `meta.qr` pins the encoded value instead. * * The `` element accepts a slotted label so this * stays content-driven; we hand it the conventional "scan for * these slides" so the call-to-action is legible without the * audience having to interpret a bare QR. */ function renderQrBlock(meta: DeckMeta): string { if (!meta.qr) return ""; const value = typeof meta.qr === "string" ? ` value="${escapeHtml(meta.qr)}"` : ""; // Wrap the slot text in a so the QR component's // ::slotted() rule can reach it — bare text nodes can't be styled // through the shadow boundary. return `
    ` + `scan for these slides` + `
    `; } export async function renderPresentationHtml( path: string, options?: RenderOptions, ) { const file = await Deno.readTextFile(path); const { meta, body: markdown } = extractFrontmatter(file); // Comark plugins do the heavy fence handling at parse time: // - mermaid: rewrites ```mermaid fences into // elements whose content we render to inline SVG via the headless // Chromium pool in core/mermaid.ts. // // Syntax highlighting runs separately, post-parse, via highlightTree. // We walk the AST, collect the fence languages this deck actually // uses, dynamically import only those Shiki modules, and then apply // the highlighter — so a deck with one Python block doesn't pay to // load Rust, Go, SQL, etc. const rawTree = await parse(markdown, { plugins: [mermaidPlugin()] }); const tree = await highlightTree(rawTree, { themes: { light: rosePineTheme, dark: rosePineTheme }, }); const slideGroups: ComarkNode[][] = []; let currentGroup: ComarkNode[] = []; for (const node of tree.nodes) { if (isElement(node) && node[0] === "hr") { slideGroups.push(currentGroup); currentGroup = []; } else { currentGroup.push(node); } } slideGroups.push(currentGroup); const uuid = crypto.randomUUID(); const includes: Includes = new Set(); const total = slideGroups.length; const pad = String(total).length; const slideElements: ComarkElement[] = slideGroups.map((rawChildren, i) => { const { content, notes } = extractNotes(rawChildren); const { layout, children } = detectLayout( content, i === 0, i === slideGroups.length - 1, ); // Speaker notes are emitted as a