// Rendering a repository's markdown. // // The text comes out of somebody's repository and the result is put into this // page, so nothing in it may become markup of its own choosing. Two rules // hold that line: // // HTML the document writes is read and written again from an allowlist, // never passed through. `#/lib/html.js` holds that list and explains it. // // A link or image address must name a scheme this page is willing to // follow. `javascript:` is the one that matters, and an allowlist refuses // it along with everything else nobody expected. // // What is left is markup these two files generated from a fixed set of tags, // which is a surface small enough to read. import { Marked } from 'marked'; import { highlightBlock, languageFor } from '#/lib/highlight.js'; import { escapeAttribute, sanitizeHtml } from '#/lib/html.js'; /** Schemes a rendered link or image may point at. */ const SAFE_SCHEMES = new Set(['http:', 'https:', 'mailto:']); /** * Whether an address is one this page will follow. A relative address names * no scheme and stays inside the repository, so it is allowed and resolved * by the caller. * @param {string} href * @returns {boolean} */ export function isSafeHref(href) { const value = String(href ?? '').trim(); if (value === '') return false; // A control character can hide a scheme from a careless reader, and the // URL parser strips some of them before deciding what the scheme is. // Refuse any address carrying one rather than reason about which. for (let i = 0; i < value.length; i++) { const code = value.charCodeAt(i); if (code <= 0x1f || code === 0x7f) return false; } if (value.startsWith('#')) return true; try { // A base is needed to parse a relative address at all. One that parses // relative to it has no scheme of its own, so it is repository-relative. const parsed = new URL(value, 'https://repo.invalid/'); if (!/^[a-z][a-z0-9+.-]*:/i.test(value)) return true; return SAFE_SCHEMES.has(parsed.protocol); } catch { return false; } } /** @param {string} href */ const isRelative = (href) => !/^[a-z][a-z0-9+.-]*:/i.test(href) && !href.startsWith('/') && !href.startsWith('#'); /** * Resolve a repository-relative address against the directory the document * sits in, so `../img.png` in `docs/guide.md` names `img.png`. * @param {string} basePath - directory of the document, '' at the root * @param {string} href * @returns {string} */ export function resolveRepoPath(basePath, href) { const segments = basePath ? basePath.split('/') : []; for (const part of href.split('/')) { if (part === '' || part === '.') continue; if (part === '..') segments.pop(); else segments.push(part); } return segments.join('/'); } /** * @typedef {Object} MarkdownContext * @property {string} [repo] * @property {string} [refName] * @property {string} [basePath] - directory the document sits in */ /** * The address of a repository file in this app. * @param {MarkdownContext} ctx * @param {string} path * @returns {string} */ const blobHref = (ctx, path) => `/${encodeURIComponent(String(ctx.repo))}/blob/${encodeURI(String(ctx.refName))}/${encodeURI(path)}`; /** * Render markdown to HTML for the page. * * The context is what a document in a tree has and prose about the work does * not: a repository and a ref to resolve a relative address against. Without * it a relative link or image keeps its path and finds nothing, which is the * honest answer for a review that names no tree. * @param {string} text * @param {MarkdownContext} [ctx] * @returns {string} HTML */ export function renderMarkdown(text, ctx = {}) { const basePath = ctx.basePath ?? ''; // Whether a relative address has anywhere to resolve to. Prose about the // work names no tree, so a path beside it points at nothing and is left as // the words it was written as. const inTree = Boolean(ctx.repo && ctx.refName); const marked = new Marked({ gfm: true, breaks: false, // Between an inline `` and its closing tag, marked marks the text // escaped and prints it as it stands. An unterminated tag in there would // then become a tag of the document's own. Clearing the mark sends the // text through the escape every other piece of text takes. walkTokens(token) { if (token.type === 'text' && token.escaped) token.escaped = false; }, }); /** * Addresses for the tags the document writes itself. A relative one with * no tree to resolve against is dropped rather than pointed at nothing, * which is the answer the renderer gives its own links and images. */ const htmlContext = { /** @param {string} href */ link(href) { if (!isSafeHref(href)) return null; if (!isRelative(href)) return { href, external: true }; if (!inTree) return null; return { href: blobHref(ctx, resolveRepoPath(basePath, href)), external: false, }; }, /** @param {string} src @returns {Record|null} */ image(src) { if (!isSafeHref(src)) return null; if (!isRelative(src)) return { src }; if (!inTree) return null; return { 'data-repo-path': resolveRepoPath(basePath, src) }; }, }; marked.use({ renderer: { /** Read the document's own tags and write back the ones allowed here. */ html({ text: raw }) { return sanitizeHtml(raw, htmlContext); }, /** Fenced code reads the same as a file does elsewhere in the app. */ code({ text: source, lang }) { const language = lang ? languageFor(`x.${lang.split(/\s+/)[0]}`) : ''; return `
${highlightBlock(source, language)}
`; }, link({ href, title, tokens }) { const label = this.parser.parseInline(tokens); if (!isSafeHref(href)) return label; if (isRelative(href) && !inTree) return label; const target = isRelative(href) ? blobHref(ctx, resolveRepoPath(basePath, href)) : href; const attrs = title ? ` title="${escapeAttribute(title)}"` : ''; // An address outside the repository opens away from this page and is // told not to carry it along. const away = isRelative(href) ? '' : ' target="_blank" rel="noopener noreferrer"'; return `${label}`; }, image({ href, title, text: alt }) { if (!isSafeHref(href)) return escapeAttribute(alt ?? ''); const attrs = title ? ` title="${escapeAttribute(title)}"` : ''; if (!isRelative(href)) { return `${escapeAttribute(alt ?? '')}`; } // The file is in the repository, whose objects are already in memory. // The path travels on the tag and the view fills in the bytes. const path = resolveRepoPath(basePath, href); return `${escapeAttribute(alt ?? '')}`; }, }, }); return marked.parse(text, { async: false }); }