import MarkdownIt from "markdown-it";
import type { MarkdownIt as MarkdownRenderer, StateCore } from "markdown-it";
import { highlight } from "sugar-high";
import { FilterXSS, safeAttrValue } from "xss";
import { linkTextReferences } from "./references";
export type MarkdownContext = {
owner: string;
repo: string;
ref: string;
/** Directory of the document inside the repo, used to resolve relative links. */
basePath: string;
};
const ATTACHMENT_PREFIX = "/attachments/";
const ABSOLUTE_URL = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i;
function createRenderer(): MarkdownRenderer {
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: false,
highlight: (code) => `
${highlight(code)}
`,
});
md.validateLink = (url) =>
!/^(javascript|vbscript|data):/i.test(url.trim()) || /^data:image\/(png|gif|jpe?g|webp);/i.test(url.trim());
return md;
}
const renderer = createRenderer();
const ALIGNED = ["align"];
const CELL = ["align", "colspan", "rowspan"];
const HEADING = ["align", "id"];
/** The raw HTML subset GitHub permits in markdown; everything else (scripts, styles, handlers) is stripped. */
const ALLOWED_HTML: Record = {
a: ["href", "title", "name", "id"],
img: ["src", "alt", "title", "width", "height", "align"],
picture: [],
source: ["srcset", "media", "type", "width", "height"],
p: ALIGNED,
div: ALIGNED,
center: [],
span: [],
br: [],
hr: [],
h1: HEADING,
h2: HEADING,
h3: HEADING,
h4: HEADING,
h5: HEADING,
h6: HEADING,
details: ["open"],
summary: [],
blockquote: [],
pre: [],
code: [],
kbd: [],
samp: [],
var: [],
sup: [],
sub: [],
b: [],
i: [],
strong: [],
em: [],
u: [],
s: [],
del: [],
ins: [],
strike: [],
small: [],
mark: [],
abbr: ["title"],
q: [],
dl: [],
dt: [],
dd: [],
ul: [],
ol: ["start", "type"],
li: [],
table: ["align"],
thead: [],
tbody: [],
tfoot: [],
tr: ALIGNED,
th: CELL,
td: CELL,
caption: [],
};
const sanitizer = new FilterXSS({
whiteList: ALLOWED_HTML,
stripIgnoreTag: true,
stripIgnoreTagBody: ["script", "style", "iframe", "object", "embed", "template", "noscript"],
css: false,
safeAttrValue(tag, name, value, cssFilter) {
const rewritten =
name === "href" || name === "src"
? rewriteRelative(activeContext, value, name === "src" ? "image" : "link")
: value;
return safeAttrValue(tag, name, rewritten, cssFilter);
},
onTagAttr(tag, name, value) {
if (tag === "a" && name === "href" && /^https?:/i.test(value)) {
return `href="${value.replace(/"/g, """)}" rel="nofollow noopener noreferrer"`;
}
return undefined;
},
});
renderer.renderer.rules.html_block = (tokens, index) => sanitizer.process(tokens[index].content);
renderer.renderer.rules.html_inline = (tokens, index) => sanitizer.process(tokens[index].content);
function resolveRepoPath(basePath: string, target: string): string {
const parts = (target.startsWith("/") ? [] : basePath.split("/").filter(Boolean)).concat(target.split("/"));
const out: string[] = [];
for (const part of parts) {
if (!part || part === ".") continue;
if (part === "..") out.pop();
else out.push(part);
}
return out.join("/");
}
function rewriteRelative(env: MarkdownContext | undefined, url: string, kind: "link" | "image"): string {
if (!env || !url || ABSOLUTE_URL.test(url) || url.startsWith(ATTACHMENT_PREFIX)) return url;
const [pathPart, hash] = url.split("#", 2);
const resolved = resolveRepoPath(env.basePath, pathPart);
const mode = kind === "image" ? "raw" : "blob";
return `/${env.owner}/${env.repo}/${mode}/${env.ref}/${resolved}${hash ? `#${hash}` : ""}`;
}
let activeContext: MarkdownContext | undefined;
renderer.core.ruler.push("gitfor_links", (state: StateCore) => {
const context = activeContext;
for (const token of state.tokens) {
for (const child of token.children ?? []) {
if (child.type === "link_open") {
const raw = child.attrGet("href");
const href = raw === null ? null : String(raw);
if (href) child.attrSet("href", rewriteRelative(context, href, "link"));
if (href && /^https?:/i.test(href)) child.attrSet("rel", "nofollow noopener noreferrer");
}
if (child.type === "image") {
const src = child.attrGet("src");
if (src !== null) child.attrSet("src", rewriteRelative(context, String(src), "image"));
}
}
}
});
const ALERT_MARKER = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*/i;
const ALERT_TITLES: Record = {
note: "Note",
tip: "Tip",
important: "Important",
warning: "Warning",
caution: "Caution",
};
/** GitHub alert blockquotes: `> [!NOTE]` becomes a titled, styled callout. */
renderer.core.ruler.push("gitfor_alerts", (state: StateCore) => {
const tokens = state.tokens;
for (let index = 0; index < tokens.length - 2; index++) {
if (tokens[index].type !== "blockquote_open" || tokens[index + 1].type !== "paragraph_open") continue;
const inline = tokens[index + 2];
const first = inline.children?.[0];
const match = first?.type === "text" ? ALERT_MARKER.exec(first.content) : null;
if (!inline.children || !first || !match) continue;
const kind = match[1].toLowerCase();
first.content = first.content.slice(match[0].length);
if (first.content === "" && inline.children[1]?.type === "softbreak") inline.children.splice(0, 2);
tokens[index].attrJoin("class", `markdown-alert markdown-alert-${kind}`);
const title = new state.Token("gitfor_alert_title", "", 0);
title.meta = { kind };
tokens.splice(index + 1, 0, title);
}
});
renderer.renderer.rules.gitfor_alert_title = (tokens, index) => {
const kind = String(tokens[index].meta?.kind ?? "note");
return `${ALERT_TITLES[kind] ?? "Note"}
\n`;
};
const SKIP_TAGS = /^<(\/?)(code|pre|a)\b/i;
function linkReferences(html: string, context: Pick | null): string {
let skipDepth = 0;
return html
.split(/(<[^>]+>)/)
.map((segment) => {
if (segment.startsWith("<")) {
const tag = SKIP_TAGS.exec(segment);
if (tag) skipDepth = Math.max(0, skipDepth + (tag[1] ? -1 : 1));
return segment;
}
if (skipDepth > 0) return segment;
return linkTextReferences(segment, context);
})
.join("");
}
/** Renders untrusted markdown to HTML, sanitizing raw HTML to GitHub's allowed subset, with repo-aware links. */
export function renderMarkdown(source: string, context: MarkdownContext | null): string {
activeContext = context ?? undefined;
try {
return linkReferences(renderer.render(source), context);
} finally {
activeContext = undefined;
}
}