1import MarkdownIt from "markdown-it";2import { type Html, trusted } from "./html.ts";3import { pathUrl } from "./layout.ts";456789export type MarkdownBase = { repo: string; dir: string; at: string | null; own?: boolean };1011const MEDIA_PREFIX = "/static/assets/media/";12const VIDEO = /\.(mp4|webm)$/i;13const WIDTHS = [25, 33, 50, 66, 75, 100];14const OPTION = /^(width=\d{1,3}|left|center|right|loop)$/;1516const SCHEME = /^([a-z][a-z0-9+.-]*):/i;17const ALLOWED_SCHEMES = ["http", "https", "mailto"];181920const md = new MarkdownIt({ html: false, linkify: true });21md.validateLink = (url) => {22 const scheme = SCHEME.exec(url.trim());23 return !scheme || ALLOWED_SCHEMES.includes((scheme[1] as string).toLowerCase());24};25md.core.ruler.push("kepr_raw_links", (state) => {26 const base = state.env as MarkdownBase;27 28 if (!base.repo) return;29 const visit = (tokens: typeof state.tokens) => {30 for (const token of tokens) {31 const attr = token.type === "link_open" ? "href" : token.type === "image" ? "src" : null;32 const url = attr && token.attrGet(attr);33 if (attr && typeof url === "string") token.attrSet(attr, rawUrl(url, base));34 if (token.children) visit(token.children);35 }36 };37 visit(state.tokens);38});3940function rawUrl(url: string, base: MarkdownBase): string {41 if (SCHEME.test(url) || url.startsWith("#") || url.startsWith("//")) return url;42 if (base.own && url.startsWith(MEDIA_PREFIX)) return url;43 44 const resolved = new URL(url, `http://repo/${base.dir ? `${pathUrl(base.dir)}/` : ""}`);45 const at = base.at ? `?at=${base.at}` : "";46 return `/${base.repo}/raw${resolved.pathname}${at}${resolved.hash}`;47}4849505152const image = md.renderer.rules.image;53md.renderer.rules.image = (tokens, idx, options, env, self) => {54 const token = tokens[idx] as (typeof tokens)[number];55 const words = String(token.attrGet("title") ?? "")56 .split(/\s+/)57 .filter(Boolean);58 const styled = words.length > 0 && words.every((word) => OPTION.test(word));59 const src = String(token.attrGet("src") ?? "");60 const video = VIDEO.test(src.split(/[?#]/)[0] as string);61 if (!styled && !video) return image ? image(tokens, idx, options, env, self) : "";62 const asked = Number(words.find((word) => word.startsWith("width="))?.slice(6) ?? 100);63 const width = WIDTHS.find((w) => w >= asked) ?? 100;64 const align = words.find((word) => /^(left|center|right)$/.test(word)) ?? "center";65 const classes = `media media-w${width} media-${align}`;66 if (!video) {67 token.attrs = (token.attrs ?? []).filter(([name]) => name !== "title");68 token.attrSet("class", classes);69 return image ? image(tokens, idx, options, env, self) : "";70 }71 const alt = md.utils.escapeHtml(token.content);72 const play = words.includes("loop")73 ? "autoplay muted loop playsinline"74 : 'controls preload="metadata"';75 return `<video class="${classes}" src="${md.utils.escapeHtml(src)}" ${play} aria-label="${alt}"></video>`;76};7778export function renderMarkdown(source: string, base: MarkdownBase): Html {79 return trusted(md.render(source, base));80}818283export function renderSiteMarkdown(source: string): {84 html: Html;85 sections: { id: string; title: string }[];86} {87 const tokens = md.parse(source, {});88 const sections = tokens.flatMap((token, i) => {89 if (token.type !== "heading_open" || token.tag !== "h2") return [];90 const title = tokens[i + 1]?.content ?? "";91 const id = title.toLowerCase().replace(/[^a-z0-9]+/g, "-");92 token.attrSet("id", id);93 return [{ id, title }];94 });95 return { html: trusted(md.renderer.render(tokens, md.options, {})), sections };96}979899100101102export function renderParagraph(source: string, base: MarkdownBase): { html: Html; text: string } {103 const children = md.parseInline(source, base)[0]?.children ?? [];104 const text = children105 .map((token) =>106 token.type === "text" || token.type === "code_inline"107 ? token.content108 : token.type === "softbreak"109 ? " "110 : "",111 )112 .join("");113 return { html: trusted(md.renderer.renderInline(children, md.options, base)), text };114}115116117export function renderLine(source: string, base: MarkdownBase): Html {118 const tokens = md.parseInline(source, base)[0]?.children ?? [];119 const kept = tokens.filter((token) => !/^(link_open|link_close|image)$/.test(token.type));120 return trusted(md.renderer.renderInline(kept, md.options, base));121}