import sanitizeHtml from "sanitize-html";
import { isRepoRelative, mediaSrcset, mediaUrl, 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"
});
const resolveMedia = (
attribs: Record,
ctx: MarkupContext
): Record => {
const resolved = { ...attribs };
for (const key of ["src", "poster"]) {
const value = resolved[key];
if (value) resolved[key] = mediaUrl(value, ctx);
}
if (resolved.srcset) resolved.srcset = mediaSrcset(resolved.srcset, ctx);
return resolved;
};
export const sanitizeMarkup = (html: string, ctx: MarkupContext): string =>
sanitizeHtml(html, optionsFor(ctx));