/**
* The ONE client-side markdown renderer for every SSR page.
*
* Replaces five near-identical copies that were inlined into page
* templates (repo README card, repo blob viewer, repo docs tab, site
* docs page, org About card). Two defect classes came from inlining:
*
* 1. INJECTION / BLANK RENDER. Each copy shipped its source as
* `var src = ${JSON.stringify(content)}` inside an inline <script>.
* JSON.stringify does not escape `</script`, so any markdown file
* containing that sequence terminated the script element early —
* SyntaxError, viewer left permanently empty (37 of 4188 markdown
* files across the served repos). It was also an XSS vector: the
* rest of the file was parsed as HTML in the viewer's origin.
* Fixed structurally: the source now travels as base64 in a data
* attribute (base64's alphabet cannot close a tag or an attribute),
* which is the pattern issues.js already used.
*
* 2. TEMPLATE-LITERAL ESCAPE ROT. A regex written `/…|\/\/|#/` inside
* a template literal loses its backslashes on the way out (`\/` is
* just `/` to the template), so the emitted regex was invalid and
* the whole script failed to parse. That is what broke the repo
* Docs tab for every repo. In a real .js file there is no template
* layer to escape through, so the bug cannot recur here.
*
* Contract — put these on any element and it renders itself:
*
* data-md-src-b64 (required) base64 of the utf8 markdown source
* data-md-dir directory of the source file within the repo,
* '' for repo root. Relative links resolve
* against it, so a link in docs/a.md to `b.md`
* lands on docs/b.md.
* data-md-link-base prefix for relative link hrefs. Absent = no
* rewriting (links pass through verbatim).
* data-md-img-base prefix for relative image srcs. Defaults to
* data-md-link-base. Repos set this to the /raw/
* route, since /blob/ is an HTML page and would
* render as a broken <img>.
* data-md-sibling-base where a path that climbs above the repo root
* goes — '../other' in a repo README means the
* sibling REPO `other`, i.e. /repos/other.
* Absent = such links pass through unchanged.
* data-md-strip-ext comma-separated extensions to drop from a
* rewritten LINK target (not images). The site
* docs are addressed by slug, so a doc's
* './origin-setup.md' has to become
* /docs/origin-setup, not /docs/origin-setup.md.
*
* Rendering is idempotent (data-md-rendered guard) so callers may
* re-invoke window.pgv3RenderMarkdown() after swapping DOM in.
*/
(function () {
function decodeB64Utf8 (b64) {
// atob() yields a binary string; escape/unescape round-trips it back
// to UTF-8. Deprecated but universally available, and the same
// approach issues.js has been shipping.
try { return decodeURIComponent(escape(atob(b64))) } catch (e) {
try { return atob(b64) } catch (e2) { return '' }
}
}
function escapeText (s) {
return String(s).replace(/[<>&]/g, function (c) {
return c === '<' ? '<' : c === '>' ? '>' : '&'
})
}
function isAbsolute (url) {
// Defensive: marked may hand us a non-string href (token objects
// without href, fragment-only links, …). Anything that isn't a
// string counts as absolute so we never rewrite or throw.
if (typeof url !== 'string') return true
if (url.charAt(0) === '/') return true
return /^([a-z][a-z0-9+.-]*:|\/\/|#)/i.test(url)
}
/**
* Resolve `url` (relative, from a file in directory `dir`) against
* `base`. Returns the url untouched when it is absolute, or when it
* climbs above the repo root and no sibling base was configured.
*/
function resolve (url, dir, base, siblingBase, isImage, stripExt) {
if (!url || isAbsolute(url) || !base) return url
// Split off #fragment / ?query so they survive path encoding.
var hashAt = url.indexOf('#')
var queryAt = url.indexOf('?')
var cut = hashAt >= 0 && (queryAt < 0 || hashAt < queryAt)
? hashAt
: queryAt >= 0 ? queryAt : -1
var path = cut >= 0 ? url.slice(0, cut) : url
var tail = cut >= 0 ? url.slice(cut) : ''
if (!path) return url
// Normalize against the source file's directory, collapsing . and ..
var segs = (dir ? dir.split('/') : []).concat(path.split('/'))
var out = []
var climbed = 0
for (var i = 0; i < segs.length; i++) {
var s = segs[i]
if (!s || s === '.') continue
if (s === '..') {
if (out.length) out.pop()
else climbed++
continue
}
out.push(s)
}
// Climbed above the repo root: '../other/x.md' in a repo README is a
// reference to the SIBLING REPO `other`, not to a path in this repo.
if (climbed > 0) {
if (!siblingBase || !out.length) return url
var sibling = encodeURIComponent(out.shift())
var target = siblingBase + sibling
if (out.length) {
target += (isImage ? '/raw/' : '/blob/') +
out.map(encodeURIComponent).join('/')
}
return target + tail
}
if (!out.length) return url
// Slug-addressed targets: drop the source extension from the last
// segment. Links only — an image must keep pointing at real bytes.
if (stripExt && stripExt.length && !isImage) {
var last = out[out.length - 1]
var dot = last.lastIndexOf('.')
if (dot > 0 && stripExt.indexOf(last.slice(dot + 1).toLowerCase()) >= 0) {
out[out.length - 1] = last.slice(0, dot)
}
}
return base + out.map(encodeURIComponent).join('/') + tail
}
/**
* marked v5+ (we ship v14) passes a single token object to
* renderer.link / renderer.image: { href, title, text, raw, tokens }.
* The older API was positional (href, title, text). Handle both —
* shallow-clone the token so we never mutate marked's own input.
*/
function rewritingRenderer (dir, linkBase, imgBase, siblingBase, stripExt) {
var renderer = new marked.Renderer()
var origLink = renderer.link.bind(renderer)
var origImage = renderer.image.bind(renderer)
function wrap (orig, base, isImage) {
return function (tokOrHref, title, text) {
if (tokOrHref && typeof tokOrHref === 'object' && 'href' in tokOrHref) {
var clone = {}
for (var k in tokOrHref) clone[k] = tokOrHref[k]
clone.href = resolve(tokOrHref.href, dir, base, siblingBase, isImage, stripExt)
return orig(clone)
}
return orig(resolve(tokOrHref, dir, base, siblingBase, isImage, stripExt), title, text)
}
}
renderer.link = wrap(origLink, linkBase, false)
renderer.image = wrap(origImage, imgBase, true)
return renderer
}
function renderOne (el) {
var b64 = el.getAttribute('data-md-src-b64')
if (b64 == null) return
if (el.getAttribute('data-md-rendered') === '1') return
var src = decodeB64Utf8(b64)
var dir = el.getAttribute('data-md-dir') || ''
var linkBase = el.getAttribute('data-md-link-base') || ''
var imgBase = el.getAttribute('data-md-img-base') || linkBase
var siblingBase = el.getAttribute('data-md-sibling-base') || ''
var stripExt = (el.getAttribute('data-md-strip-ext') || '')
.split(',').map(function (s) { return s.trim().toLowerCase() })
.filter(Boolean)
if (typeof marked !== 'undefined') {
try {
el.innerHTML = marked.parse(src, {
gfm: true,
breaks: true,
renderer: rewritingRenderer(dir, linkBase, imgBase, siblingBase, stripExt)
})
el.setAttribute('data-md-rendered', '1')
return
} catch (err) {
// A marked failure must never leave a blank card — fall through
// to plain text so the content is at least readable.
try {
console.error('[pgv3-markdown] marked.parse failed; falling back to plain text:',
err && err.message ? err.message : err)
} catch (_) {}
}
}
el.innerHTML = '<pre style="white-space:pre-wrap">' + escapeText(src) + '</pre>'
el.setAttribute('data-md-rendered', '1')
}
function renderAll (root) {
var scope = root || document
var els = scope.querySelectorAll('[data-md-src-b64]')
for (var i = 0; i < els.length; i++) renderOne(els[i])
}
// Exposed so pages that swap markdown into the DOM (the repo Docs tab
// switcher) can render the newly-visible node.
window.pgv3RenderMarkdown = renderAll
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { renderAll() })
} else {
renderAll()
}
})()