1(async () => {
2 const readme = document.querySelector("#readme .markdown");
3 if (!readme || !window.markdownit || !window.DOMPurify) return;
4
5 const repo = document.querySelector('meta[name="astheno.shale.repo.name"]')?.content;
6 if (!repo) return;
7 const tree = await fetch(`/${encodeURIComponent(repo)}/tree/-/`);
8 if (!tree.ok) return;
9 const page = new DOMParser().parseFromString(await tree.text(), "text/html");
10 const file = [...page.querySelectorAll(".objblob a[href]")]
11 .find(link => /^readme\.md$/i.test(link.textContent.trim()));
12 if (!file) return;
13 const source = new URL(file.getAttribute("href"), tree.url);
14 const linkBase = new URL("./", source);
15 source.pathname = source.pathname.replace(`/${encodeURIComponent(repo)}/tree/`, `/${encodeURIComponent(repo)}/plain/`);
16 const response = await fetch(source);
17 if (!response.ok) return;
18
19 const markdown = await response.text();
20 const rendered = window.markdownit({ html: true }).render(markdown);
21 const fragment = window.DOMPurify.sanitize(rendered, {
22 // https://github.com/gjtorikian/html-pipeline/blob/main/lib/html_pipeline/sanitization_filter.rb
23 ALLOWED_TAGS: [
24 "h1", "h2", "h3", "h4", "h5", "h6", "br", "b", "i", "strong", "em", "a", "pre", "code", "img", "tt",
25 "div", "ins", "del", "sup", "sub", "p", "picture", "ol", "ul", "table", "thead", "tbody", "tfoot",
26 "blockquote", "dl", "dt", "dd", "kbd", "q", "samp", "var", "hr", "ruby", "rt", "rp", "li", "tr", "td",
27 "th", "s", "strike", "summary", "details", "caption", "figure", "figcaption", "abbr", "bdo", "cite",
28 "dfn", "mark", "small", "source", "span", "time", "wbr",
29 ],
30 ALLOWED_ATTR: [
31 "href", "src", "longdesc", "loading", "alt", "itemscope", "itemtype", "cite", "srcset",
32 "abbr", "accept", "accept-charset", "accesskey", "action", "align", "aria-describedby", "aria-hidden",
33 "aria-label", "aria-labelledby", "axis", "border", "char", "charoff", "charset", "checked", "clear",
34 "cols", "colspan", "compact", "coords", "datetime", "dir", "disabled", "enctype", "for", "frame",
35 "headers", "height", "hreflang", "hspace", "ismap", "label", "lang", "maxlength", "media", "method",
36 "multiple", "nohref", "noshade", "nowrap", "open", "progress", "prompt", "readonly", "rel", "rev",
37 "role", "rows", "rowspan", "rules", "scope", "selected", "shape", "size", "span", "start", "summary",
38 "tabindex", "title", "type", "usemap", "valign", "value", "width", "itemprop", "style",
39 ],
40 ALLOW_DATA_ATTR: false,
41 ALLOW_ARIA_ATTR: false,
42 RETURN_DOM_FRAGMENT: true,
43 });
44 for (const element of fragment.querySelectorAll("[style]")) {
45 const ratio = element.tagName === "IMG" ? element.style.aspectRatio : "";
46 element.removeAttribute("style");
47 if (/^(?:auto\s+)?\d+(?:\.\d+)?(?:\s*\/\s*\d+(?:\.\d+)?)?$/.test(ratio)) {
48 element.style.aspectRatio = ratio;
49 }
50 }
51 for (const element of fragment.querySelectorAll("[align]")) {
52 const align = element.getAttribute("align").toLowerCase();
53 if (element.tagName === "IMG") {
54 if (["top", "middle", "bottom"].includes(align)) element.style.verticalAlign = align;
55 if (["left", "right"].includes(align)) element.style.float = align;
56 } else if (["left", "center", "right", "justify"].includes(align)) {
57 element.style.textAlign = align;
58 }
59 }
60
61 const mediaBase = new URL("./", source);
62 const rebase = (value, base) =>
63 value && !value.startsWith("/") && !value.startsWith("#") && !/^[a-z][a-z\d+.-]*:/i.test(value)
64 ? new URL(value, base).href
65 : value;
66
67 await Promise.all([...fragment.querySelectorAll("img[src]")].map(async image => {
68 image.src = rebase(image.getAttribute("src"), mediaBase);
69 for (const dimension of ["width", "height"]) {
70 const value = image.getAttribute(dimension);
71 if (value && /^\d+$/.test(value)) image.style[dimension] = `${value}px`;
72 }
73 if (image.style.width) {
74 if (!image.style.aspectRatio && image.style.height) {
75 image.style.aspectRatio = `auto ${image.getAttribute("width")} / ${image.getAttribute("height")}`;
76 }
77 image.style.height = "auto";
78 }
79 // Shale serves raw SVG as text/plain, which browsers reject as an image.
80 const url = new URL(image.src);
81 if (url.origin === location.origin && url.pathname.startsWith(mediaBase.pathname) && /\.svg$/i.test(url.pathname)) {
82 const source = await fetch(url).then(response => response.ok ? response.text() : null).catch(() => null);
83 if (source) {
84 const svg = window.DOMPurify.sanitize(source, { USE_PROFILES: { svg: true, svgFilters: true } });
85 image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
86 }
87 }
88 }));
89 for (const pictureSource of fragment.querySelectorAll("img[srcset], source[srcset]")) {
90 pictureSource.srcset = pictureSource.srcset.split(",").map(candidate => {
91 const [, path, descriptor] = candidate.trim().match(/^(\S+)(.*)$/) || [];
92 return path ? rebase(path, mediaBase) + descriptor : candidate;
93 }).join(", ");
94 }
95 for (const link of fragment.querySelectorAll("a[href]")) {
96 link.href = rebase(link.getAttribute("href"), linkBase);
97 }
98 readme.replaceChildren(fragment);
99})().catch(console.error);