OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / web / static / pgv3-markdown.js

220 lines · javascript ref:
/**
 * 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 === '<' ? '&lt;' : c === '>' ? '&gt;' : '&amp;'
    })
  }

  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()
  }
})()