| 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); |