");
+ });
+
+ it("renders task lists as disabled checkboxes", () => {
+ const html = render("- [x] done\n- [ ] not");
+ expect(html).toContain('type="checkbox"');
+ expect(html).toContain('checked="checked"');
+ expect(html).toContain('disabled="disabled"');
+ });
+
+ it("renders footnotes with their backlinks", () => {
+ const html = render("a claim[^1]\n\n[^1]: the source");
+ expect(html).toContain('class="footnote-ref"');
+ expect(html).toContain('class="footnote-backref"');
+ expect(html).toContain("the source");
+ });
+
+ it("renders github style alerts", () => {
+ const html = render("> [!WARNING]\n> careful");
+ expect(html).toContain('class="markdown-alert markdown-alert-warning"');
+ expect(html).toContain("careful");
+ });
+
+ it("renders emoji shortcodes", () => {
+ expect(render("ship it :tada:")).toContain("🎉");
+ });
+
+ it("keeps the language on a fenced block", () => {
+ expect(render("```rust\nfn main() {}\n```")).toContain('class="language-rust"');
+ });
+
+ it("strips scripts, event handlers and javascript urls", () => {
+ expect(render("")).not.toContain("alert");
+ expect(render('
')).not.toContain("onerror");
+ // markdown-it refuses the destination outright, so this stays plain text
+ expect(render("[x](javascript:alert(1))")).not.toContain("x')).not.toContain("data:");
+ });
+
+ it("drops classes it does not recognise", () => {
+ expect(render('boo
')).not.toContain("inset-0");
+ });
+
+ it("drops inputs that are not task list checkboxes", () => {
+ expect(render('')).not.toContain(" {
+ const html = render(
+ 'infra
\n\nmore
\n\nhidden\n\n '
+ );
+ expect(html).toContain('');
+ expect(html).toContain("more
");
+ });
+});
diff --git a/web/src/lib/markup/markdown.ts b/web/src/lib/markup/markdown.ts
new file mode 100644
index 00000000..2c5a692d
--- /dev/null
+++ b/web/src/lib/markup/markdown.ts
@@ -0,0 +1,124 @@
+import { alert } from "@mdit/plugin-alert";
+import { footnote } from "@mdit/plugin-footnote";
+import { tasklist } from "@mdit/plugin-tasklist";
+import MarkdownIt from "markdown-it";
+import anchor from "markdown-it-anchor";
+import { full as emoji } from "markdown-it-emoji";
+import { sanitizeMarkup } from "./sanitize";
+import type { MarkupContext } from "./paths";
+
+// past this a document renders as plain text, a readme this big is pathological
+export const SOURCE_LIMIT = 512 * 1024;
+
+// github's heading slugs, that is what fragment links in a readme are written
+// against
+const slugify = (text: string): string =>
+ text
+ .trim()
+ .toLowerCase()
+ .replace(/[^\p{L}\p{N}\p{M}\s_-]+/gu, "")
+ .replace(/\s+/g, "-");
+
+// the dotted dns handle appview/pages/markup/extension/atlink.go matches
+const MENTION = /^@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z][a-zA-Z0-9-]*\b/;
+
+const mentions = (md: MarkdownIt): void => {
+ md.inline.ruler.before("link", "mention", (state, silent) => {
+ // inside a link label a mention would nest an anchor in an anchor. markdown-it
+ // tracks this but its published types leave the field out
+ if ((state as unknown as { linkLevel: number }).linkLevel > 0) return false;
+ if (state.src.charCodeAt(state.pos) !== 0x40) return false;
+ const before = state.pos === 0 ? " " : state.src[state.pos - 1];
+ if (before !== " " && before !== "\n" && before !== "(") return false;
+ const match = MENTION.exec(state.src.slice(state.pos, state.posMax));
+ if (!match) return false;
+
+ if (!silent) {
+ const open = state.push("link_open", "a", 1);
+ open.attrs = [
+ ["href", `/${match[0].slice(1)}`],
+ ["class", "mention"]
+ ];
+ state.push("text", "", 0).content = match[0];
+ state.push("link_close", "a", -1);
+ }
+ state.pos += match[0].length;
+ return true;
+ });
+};
+
+const SHA = /^[0-9a-f]{7,40}$/;
+
+// `https://host/owner/repo/commit/` is a mouthful to read inline
+const shortSha = (href: string, host: string): string | null => {
+ let url: URL;
+ try {
+ url = new URL(href);
+ } catch {
+ return null;
+ }
+ if (url.host !== host) return null;
+ const parts = url.pathname.replace(/^\/+|\/+$/g, "").split("/");
+ if (parts.length !== 4 || parts[2] !== "commit" || !SHA.test(parts[3])) return null;
+ return parts[3].slice(0, 8);
+};
+
+const commitLinks =
+ (host: string) =>
+ (md: MarkdownIt): void => {
+ md.core.ruler.push("commit_link", (state) => {
+ for (const token of state.tokens) {
+ if (token.type !== "inline" || !token.children) continue;
+ for (let i = 0; i < token.children.length; i++) {
+ const open = token.children[i];
+ // only bare urls, a link someone gave a label to keeps it
+ if (open.type !== "link_open" || open.info !== "auto") continue;
+ const text = token.children[i + 1];
+ if (text?.type !== "text" || token.children[i + 2]?.type !== "link_close") continue;
+ const sha = shortSha(open.attrGet("href") ?? "", host);
+ if (!sha) continue;
+ const code = new state.Token("code_inline", "code", 0);
+ code.content = sha;
+ token.children[i + 1] = code;
+ }
+ }
+ });
+ };
+
+// todo: no syntax highlighting, mermaid or math yet. the first two want a client
+// side renderer, math wants mathjax loaded on demand the way the appview does it
+const build = (host: string): MarkdownIt => {
+ const md = new MarkdownIt({
+ html: true,
+ linkify: true,
+ // the appview only rewrites dashes in the blog, readmes keep their text
+ typographer: false
+ })
+ .use(anchor, {
+ slugify,
+ permalink: anchor.permalink.linkInsideHeader({ class: "anchor", symbol: "#" })
+ })
+ .use(footnote)
+ .use(tasklist, { disabled: true, label: true })
+ .use(alert)
+ .use(emoji)
+ .use(mentions);
+
+ if (host) md.use(commitLinks(host));
+ return md;
+};
+
+const renderers = new Map();
+
+// building an instance means running every plugin, so keep one per host
+const rendererFor = (host: string): MarkdownIt => {
+ let md = renderers.get(host);
+ if (!md) {
+ md = build(host);
+ renderers.set(host, md);
+ }
+ return md;
+};
+
+export const renderMarkdown = (source: string, ctx: MarkupContext): string =>
+ sanitizeMarkup(rendererFor(ctx.host ?? "").render(source), ctx);
diff --git a/web/src/lib/markup/paths.ts b/web/src/lib/markup/paths.ts
new file mode 100644
index 00000000..c19c69d2
--- /dev/null
+++ b/web/src/lib/markup/paths.ts
@@ -0,0 +1,56 @@
+export interface MarkupContext {
+ /** `owner/repo` */
+ repo: string;
+ ref: string;
+ dir?: string;
+ host?: string;
+}
+
+const ABSOLUTE = /^[a-z][a-z0-9+.-]*:|^\/\//i;
+
+export const isAbsoluteUrl = (url: string): boolean => ABSOLUTE.test(url);
+
+export const isRepoRelative = (url: string): boolean =>
+ url !== "" && !isAbsoluteUrl(url) && !url.startsWith("#");
+
+// `.` and `..` have to collapse here instead of reaching the url
+const normalize = (path: string): string => {
+ const parts: string[] = [];
+ for (const part of path.split("/")) {
+ if (part === "" || part === ".") continue;
+ if (part === "..") parts.pop();
+ else parts.push(part);
+ }
+ return parts.join("/");
+};
+
+const withinRepo = (path: string, ctx: MarkupContext): string =>
+ normalize(path.startsWith("/") ? path : `${ctx.dir ?? ""}/${path}`);
+
+const splitSuffix = (url: string): [string, string] => {
+ const at = url.search(/[?#]/);
+ return at === -1 ? [url, ""] : [url.slice(0, at), url.slice(at)];
+};
+
+// markdown-it percent-encodes destinations before we ever see them, so only the
+// ref still needs encoding
+const repoUrl = (kind: string, url: string, ctx: MarkupContext): string => {
+ const [path, suffix] = splitSuffix(url);
+ if (path === "") return url;
+ const ref = encodeURIComponent(ctx.ref);
+ return `/${ctx.repo}/${kind}/${ref}/${withinRepo(path, ctx)}${suffix}`;
+};
+
+export const treeUrl = (url: string, ctx: MarkupContext): string => repoUrl("tree", url, ctx);
+
+export const rawUrl = (url: string, ctx: MarkupContext): string => repoUrl("raw", url, ctx);
+
+export const rawSrcset = (srcset: string, ctx: MarkupContext): string =>
+ srcset
+ .split(",")
+ .map((candidate) => {
+ const [url, ...descriptors] = candidate.trim().split(/\s+/);
+ if (!isRepoRelative(url)) return candidate.trim();
+ return [rawUrl(url, ctx), ...descriptors].join(" ");
+ })
+ .join(", ");
diff --git a/web/src/lib/markup/render.ts b/web/src/lib/markup/render.ts
new file mode 100644
index 00000000..89e5e95b
--- /dev/null
+++ b/web/src/lib/markup/render.ts
@@ -0,0 +1,13 @@
+import { isMarkdownFile } from "./format";
+import type { MarkupContext } from "./paths";
+
+export const renderDocument = async (
+ filename: string,
+ contents: string,
+ ctx: MarkupContext
+): Promise => {
+ if (!isMarkdownFile(filename)) return null;
+ const { SOURCE_LIMIT, renderMarkdown } = await import("./markdown");
+ if (contents.length > SOURCE_LIMIT) return null;
+ return renderMarkdown(contents, ctx);
+};
diff --git a/web/src/lib/markup/sanitize.ts b/web/src/lib/markup/sanitize.ts
new file mode 100644
index 00000000..28b255e0
--- /dev/null
+++ b/web/src/lib/markup/sanitize.ts
@@ -0,0 +1,160 @@
+import sanitizeHtml from "sanitize-html";
+import { isRepoRelative, rawSrcset, rawUrl, treeUrl } from "./paths";
+import type { MarkupContext } from "./paths";
+
+const HEADINGS = ["h1", "h2", "h3", "h4", "h5", "h6"];
+
+// mirrors appview/pages/markup/sanitizer, which is bluemonday's UGC policy plus
+// the elements our own extensions emit. markdown renders with raw html enabled,
+// so anything hand written in a readme lands here too
+const ALLOWED_TAGS = [
+ ...HEADINGS,
+ "p",
+ "br",
+ "hr",
+ "div",
+ "span",
+ "section",
+ "blockquote",
+ "pre",
+ "code",
+ "kbd",
+ "samp",
+ "var",
+ "tt",
+ "b",
+ "strong",
+ "i",
+ "em",
+ "u",
+ "s",
+ "strike",
+ "del",
+ "ins",
+ "sub",
+ "sup",
+ "small",
+ "mark",
+ "a",
+ "img",
+ "picture",
+ "source",
+ "video",
+ "ul",
+ "ol",
+ "li",
+ "dl",
+ "dt",
+ "dd",
+ "table",
+ "thead",
+ "tbody",
+ "tfoot",
+ "tr",
+ "th",
+ "td",
+ "caption",
+ "colgroup",
+ "col",
+ "details",
+ "summary",
+ "figure",
+ "figcaption",
+ "abbr",
+ "bdo",
+ "cite",
+ "dfn",
+ "q",
+ "ruby",
+ "rt",
+ "rp",
+ "time",
+ "wbr",
+ "center",
+ "input",
+ "label"
+];
+
+// bluemonday's standard attributes
+const GLOBAL_ATTRIBUTES = ["id", "title", "dir", "lang", "align"];
+
+const ALLOWED_ATTRIBUTES: sanitizeHtml.IOptions["allowedAttributes"] = {
+ "*": GLOBAL_ATTRIBUTES,
+ a: ["href", "name", "rel", "aria-hidden"],
+ img: ["src", "srcset", "alt", "width", "height", "loading"],
+ source: ["src", "srcset", "type", "media"],
+ video: ["src", "poster", "controls", "autoplay", "loop", "muted", "width", "height"],
+ // the tasklist plugin renders disabled checkboxes tied to their labels
+ input: ["type", "checked", "disabled"],
+ label: ["for"],
+ th: ["colspan", "rowspan", "scope"],
+ td: ["colspan", "rowspan"],
+ col: ["span", "width"],
+ colgroup: ["span"],
+ ol: ["start", "type", "reversed"],
+ details: ["open"],
+ time: ["datetime"],
+ abbr: ["title"]
+};
+
+// classes are allowlisted per tag, so a readme cannot reach the app's own styles
+const ALLOWED_CLASSES: sanitizeHtml.IOptions["allowedClasses"] = {
+ a: ["anchor", "mention", "footnote-ref", "footnote-backref", "footnote-anchor"],
+ sup: ["footnote-ref"],
+ hr: ["footnotes-sep"],
+ section: ["footnotes"],
+ ol: ["footnotes-list", "task-list-container"],
+ ul: ["task-list-container"],
+ li: ["footnote-item", "task-list-item"],
+ input: ["task-list-item-checkbox"],
+ label: ["task-list-item-label"],
+ div: ["markdown-alert", "markdown-alert-*"],
+ p: ["markdown-alert-title"],
+ code: ["language-*"]
+};
+
+const externalRel = (href: string): string | undefined =>
+ isRepoRelative(href) || href.startsWith("#") ? undefined : "nofollow noopener noreferrer";
+
+const optionsFor = (ctx: MarkupContext): sanitizeHtml.IOptions => ({
+ allowedTags: ALLOWED_TAGS,
+ allowedAttributes: ALLOWED_ATTRIBUTES,
+ allowedClasses: ALLOWED_CLASSES,
+ allowedSchemes: ["http", "https", "mailto"],
+ allowedSchemesAppliedToAttributes: ["href", "src", "srcset", "poster"],
+ // resolving urls here rather than in a renderer rule catches the ones
+ // written as raw html too
+ transformTags: {
+ a: (tagName, attribs) => {
+ const href = attribs.href ?? "";
+ // a mention already points at a profile
+ const rewritten =
+ isRepoRelative(href) && attribs.class !== "mention" ? treeUrl(href, ctx) : href;
+ const rel = externalRel(rewritten);
+ return { tagName, attribs: { ...attribs, href: rewritten, ...(rel ? { rel } : {}) } };
+ },
+ img: (tagName, attribs) => ({ tagName, attribs: resolveMedia(attribs, ctx) }),
+ source: (tagName, attribs) => ({ tagName, attribs: resolveMedia(attribs, ctx) }),
+ video: (tagName, attribs) => ({ tagName, attribs: resolveMedia(attribs, ctx) })
+ },
+ // the tasklist checkboxes are the only inputs we render
+ exclusiveFilter: (frame) => frame.tag === "input" && frame.attribs.type !== "checkbox"
+});
+
+// todo: external images should go through camo like the appview does, which needs
+// the shared secret in web's config and moves rendering server side
+const resolveMedia = (
+ attribs: Record,
+ ctx: MarkupContext
+): Record => {
+ const resolved = { ...attribs };
+ for (const key of ["src", "poster"]) {
+ const value = resolved[key];
+ if (value && isRepoRelative(value)) resolved[key] = rawUrl(value, ctx);
+ }
+ if (resolved.srcset) resolved.srcset = rawSrcset(resolved.srcset, ctx);
+ return resolved;
+};
+
+export const sanitizeMarkup = (html: string, ctx: MarkupContext): string =>
+ sanitizeHtml(html, optionsFor(ctx));
diff --git a/web/src/markup.css b/web/src/markup.css
new file mode 100644
index 00000000..13f3551f
--- /dev/null
+++ b/web/src/markup.css
@@ -0,0 +1,235 @@
+/* styles for rendered markdown, see $lib/markup. the html is sanitised to a known
+ set of tags and classes, so everything here is addressed by element */
+@layer components {
+ .markup {
+ @apply text-paragraph-regular text-foreground-default;
+ }
+
+ .markup > :first-child {
+ @apply mt-0;
+ }
+
+ .markup > :last-child {
+ @apply mb-0;
+ }
+
+ .markup h1,
+ .markup h2,
+ .markup h3,
+ .markup h4,
+ .markup h5,
+ .markup h6 {
+ @apply mt-6 mb-3 font-medium text-foreground-default;
+ }
+
+ .markup h1 {
+ @apply border-b border-border-default pb-2 text-heading-3;
+ }
+
+ .markup h2 {
+ @apply border-b border-border-default pb-2 text-heading-4;
+ }
+
+ .markup h3 {
+ @apply text-paragraph-large font-semibold;
+ }
+
+ .markup h4,
+ .markup h5,
+ .markup h6 {
+ @apply text-paragraph-regular font-semibold;
+ }
+
+ .markup a.anchor {
+ @apply ml-2 text-foreground-placeholder no-underline opacity-0 transition-opacity;
+ }
+
+ .markup :hover > a.anchor,
+ .markup a.anchor:focus-visible {
+ @apply opacity-100;
+ }
+
+ .markup p,
+ .markup blockquote,
+ .markup ul,
+ .markup ol,
+ .markup pre,
+ .markup table,
+ .markup details {
+ @apply my-3;
+ }
+
+ .markup a {
+ @apply text-foreground-link-default underline hover:text-foreground-link-hover;
+ }
+
+ .markup a.mention {
+ @apply font-medium no-underline hover:underline;
+ }
+
+ .markup strong {
+ @apply font-semibold;
+ }
+
+ .markup ul,
+ .markup ol {
+ @apply pl-6;
+ }
+
+ .markup ul {
+ @apply list-disc;
+ }
+
+ .markup ol {
+ @apply list-decimal;
+ }
+
+ .markup li + li {
+ @apply mt-1;
+ }
+
+ .markup li > ul,
+ .markup li > ol {
+ @apply my-1;
+ }
+
+ .markup dt {
+ @apply mt-3 font-semibold;
+ }
+
+ .markup dd {
+ @apply pl-6;
+ }
+
+ .markup blockquote {
+ @apply border-l-2 border-border-strong pl-4 text-foreground-muted;
+ }
+
+ .markup code {
+ @apply rounded bg-background-inset px-1 py-0.5 font-mono text-monospace-small;
+ }
+
+ .markup pre {
+ @apply overflow-x-auto rounded border border-border-default bg-background-inset p-3;
+ }
+
+ /* pre above already has the padding and background, inline code would double it */
+ .markup pre code {
+ @apply bg-transparent p-0 font-mono text-monospace-small;
+ }
+
+ .markup kbd {
+ @apply rounded border border-border-default bg-background-subtle px-1.5 py-0.5 font-mono text-monospace-small;
+ }
+
+ .markup hr {
+ @apply my-6 border-t border-border-default;
+ }
+
+ .markup img,
+ .markup video {
+ @apply inline-block max-w-full;
+ }
+
+ .markup table {
+ @apply block w-max max-w-full border-collapse overflow-x-auto;
+ }
+
+ .markup th,
+ .markup td {
+ @apply border border-border-default px-3 py-1.5 text-left;
+ }
+
+ .markup th {
+ @apply bg-background-subtle font-semibold;
+ }
+
+ .markup summary {
+ @apply cursor-pointer font-medium;
+ }
+
+ .markup ul.task-list-container {
+ @apply list-none pl-0;
+ }
+
+ /* only the outermost list gives up its indent, a nested one still steps in */
+ .markup li > ul.task-list-container {
+ @apply pl-6;
+ }
+
+ /* the checkbox and its label stay in the inline flow, so a nested list still
+ drops to its own line */
+ .markup input[type="checkbox"] {
+ @apply mr-1 translate-y-0.5;
+ }
+
+ /* a footnote you jump to lands mid screen rather than up against the top */
+ .markup :is(a.footnote-anchor, li.footnote-item)[id] {
+ scroll-margin-top: 48vh;
+ }
+
+ .markup sup.footnote-ref a {
+ @apply no-underline hover:underline;
+ }
+
+ .markup hr.footnotes-sep {
+ @apply mt-8;
+ }
+
+ .markup section.footnotes {
+ @apply text-paragraph-small text-foreground-muted;
+ }
+
+ /* github style alerts, `> [!NOTE]` and friends */
+ .markup .markdown-alert {
+ @apply my-3 border-l-2 border-border-default pl-4;
+ }
+
+ .markup .markdown-alert-title {
+ @apply font-semibold;
+ }
+
+ .markup .markdown-alert > :last-child {
+ @apply mb-0;
+ }
+
+ .markup .markdown-alert-note {
+ @apply border-background-info-emphasis;
+ }
+
+ .markup .markdown-alert-note .markdown-alert-title {
+ @apply text-foreground-info;
+ }
+
+ .markup .markdown-alert-tip {
+ @apply border-background-success-emphasis;
+ }
+
+ .markup .markdown-alert-tip .markdown-alert-title {
+ @apply text-foreground-success;
+ }
+
+ .markup .markdown-alert-important {
+ @apply border-background-info-emphasis;
+ }
+
+ .markup .markdown-alert-important .markdown-alert-title {
+ @apply text-foreground-info;
+ }
+
+ .markup .markdown-alert-warning {
+ @apply border-background-warning-emphasis;
+ }
+
+ .markup .markdown-alert-warning .markdown-alert-title {
+ @apply text-foreground-warning;
+ }
+
+ .markup .markdown-alert-caution {
+ @apply border-background-danger-emphasis;
+ }
+
+ .markup .markdown-alert-caution .markdown-alert-title {
+ @apply text-foreground-danger;
+ }
+}
diff --git a/web/src/routes/[handle]/[repo]/+page.svelte b/web/src/routes/[handle]/[repo]/+page.svelte
index 271b13b2..5af7401c 100644
--- a/web/src/routes/[handle]/[repo]/+page.svelte
+++ b/web/src/routes/[handle]/[repo]/+page.svelte
@@ -159,5 +159,5 @@
{#if data.readme}
-
+
{/if}
diff --git a/web/src/routes/[handle]/[repo]/+page.ts b/web/src/routes/[handle]/[repo]/+page.ts
index ad4d5121..6a16bb99 100644
--- a/web/src/routes/[handle]/[repo]/+page.ts
+++ b/web/src/routes/[handle]/[repo]/+page.ts
@@ -12,6 +12,7 @@ import {
toTagSummary,
toTreeEntrySummary
} from "$lib/api/repo";
+import { renderDocument } from "$lib/markup";
import type { LanguageSlice } from "$lib/components/repo/types";
import type { PageLoad } from "./$types";
@@ -66,6 +67,15 @@ export const load: PageLoad = async (event) => {
const languages = toLanguageSlices(results.languages?.languages ?? []);
+ const readme = results.tree?.readme ?? null;
+ const readmeHtml = readme
+ ? await renderDocument(readme.filename, readme.contents, {
+ repo: `${parent.repo.ownerHandle}/${parent.repo.name}`,
+ ref,
+ host: event.url.host
+ })
+ : null;
+
// nothing answered, so the knot is down or doesn't know this repo
const knotUnreachable =
results.tree === null && results.log === null && results.branches === null;
@@ -77,7 +87,8 @@ export const load: PageLoad = async (event) => {
isEmpty,
knotUnreachable,
files,
- readme: results.tree?.readme ?? null,
+ readme,
+ readmeHtml,
commits,
tagsByCommit: tagsByCommitHash(commits, tags),
totalCommits: results.log?.total ?? commits.length,