import MarkdownIt from "markdown-it"; import { type Html, trusted } from "./html.ts"; import { pathUrl } from "./layout.ts"; /** * Where a Markdown file lives, so its relative links and images can point at the repo's raw files. * `own` marks the operator's words, whose `/static/assets/media/` uploads stay as written. */ export type MarkdownBase = { repo: string; dir: string; at: string | null; own?: boolean }; const MEDIA_PREFIX = "/static/assets/media/"; const VIDEO = /\.(mp4|webm)$/i; const WIDTHS = [25, 33, 50, 66, 75, 100]; const OPTION = /^(width=\d{1,3}|left|center|right|loop)$/; const SCHEME = /^([a-z][a-z0-9+.-]*):/i; const ALLOWED_SCHEMES = ["http", "https", "mailto"]; // §25: raw HTML is off, so markdown-it's own escaping is the whole sanitizer. const md = new MarkdownIt({ html: false, linkify: true }); md.validateLink = (url) => { const scheme = SCHEME.exec(url.trim()); return !scheme || ALLOWED_SCHEMES.includes((scheme[1] as string).toLowerCase()); }; md.core.ruler.push("kepr_raw_links", (state) => { const base = state.env as MarkdownBase; // Kepr's own pages (docs, support) have no repo, so their links stay as written. if (!base.repo) return; const visit = (tokens: typeof state.tokens) => { for (const token of tokens) { const attr = token.type === "link_open" ? "href" : token.type === "image" ? "src" : null; const url = attr && token.attrGet(attr); if (attr && typeof url === "string") token.attrSet(attr, rawUrl(url, base)); if (token.children) visit(token.children); } }; visit(state.tokens); }); function rawUrl(url: string, base: MarkdownBase): string { if (SCHEME.test(url) || url.startsWith("#") || url.startsWith("//")) return url; if (base.own && url.startsWith(MEDIA_PREFIX)) return url; // A leading `/` means the repo root, as it does on a code host. const resolved = new URL(url, `http://repo/${base.dir ? `${pathUrl(base.dir)}/` : ""}`); const at = base.at ? `?at=${base.at}` : ""; return `/${base.repo}/raw${resolved.pathname}${at}${resolved.hash}`; } // A title made only of options sizes and places a picture or video: `![demo](x.mp4 "width=50 // left loop")`. They become classes, since the page's policy allows no inline style. A video // plays with controls, or, with `loop`, silently on repeat like a GIF. const image = md.renderer.rules.image; md.renderer.rules.image = (tokens, idx, options, env, self) => { const token = tokens[idx] as (typeof tokens)[number]; const words = String(token.attrGet("title") ?? "") .split(/\s+/) .filter(Boolean); const styled = words.length > 0 && words.every((word) => OPTION.test(word)); const src = String(token.attrGet("src") ?? ""); const video = VIDEO.test(src.split(/[?#]/)[0] as string); if (!styled && !video) return image ? image(tokens, idx, options, env, self) : ""; const asked = Number(words.find((word) => word.startsWith("width="))?.slice(6) ?? 100); const width = WIDTHS.find((w) => w >= asked) ?? 100; const align = words.find((word) => /^(left|center|right)$/.test(word)) ?? "center"; const classes = `media media-w${width} media-${align}`; if (!video) { token.attrs = (token.attrs ?? []).filter(([name]) => name !== "title"); token.attrSet("class", classes); return image ? image(tokens, idx, options, env, self) : ""; } const alt = md.utils.escapeHtml(token.content); const play = words.includes("loop") ? "autoplay muted loop playsinline" : 'controls preload="metadata"'; return ``; }; export function renderMarkdown(source: string, base: MarkdownBase): Html { return trusted(md.render(source, base)); } /** One of Kepr's own Markdown pages, such as the docs (§21.10), with an anchor on each section. */ export function renderSiteMarkdown(source: string): { html: Html; sections: { id: string; title: string }[]; } { const tokens = md.parse(source, {}); const sections = tokens.flatMap((token, i) => { if (token.type !== "heading_open" || token.tag !== "h2") return []; const title = tokens[i + 1]?.content ?? ""; const id = title.toLowerCase().replace(/[^a-z0-9]+/g, "-"); token.attrSet("id", id); return [{ id, title }]; }); return { html: trusted(md.renderer.render(tokens, md.options, {})), sections }; } /** * A paragraph of Markdown source (the README's first paragraph, found as the Atlas finds it) as * inline markup, and as plain text for link previews. */ export function renderParagraph(source: string, base: MarkdownBase): { html: Html; text: string } { const children = md.parseInline(source, base)[0]?.children ?? []; const text = children .map((token) => token.type === "text" || token.type === "code_inline" ? token.content : token.type === "softbreak" ? " " : "", ) .join(""); return { html: trusted(md.renderer.renderInline(children, md.options, base)), text }; } /** One line of Markdown with links and images flattened away, for a row that is itself a link. */ export function renderLine(source: string, base: MarkdownBase): Html { const tokens = md.parseInline(source, base)[0]?.children ?? []; const kept = tokens.filter((token) => !/^(link_open|link_close|image)$/.test(token.type)); return trusted(md.renderer.renderInline(kept, md.options, base)); }