diff --git a/packages/package.njk b/packages/package.njk index d3d0f882e..19712a4c8 100644 --- a/packages/package.njk +++ b/packages/package.njk @@ -156,6 +156,29 @@ isPackagePage: true {% if pkg.readme and not pkg.removed %}
+ {% endif %} diff --git a/static/readme.js b/static/readme.js index ce114f88c..06e9cdfca 100644 --- a/static/readme.js +++ b/static/readme.js @@ -1,17 +1,76 @@ import DOMPurify from 'https://cdn.jsdelivr.net/npm/dompurify/dist/purify.es.mjs' import { marked } from 'https://cdn.jsdelivr.net/npm/marked/lib/marked.esm.js' +const markdownAlertExtension = { + name: 'markdownAlert', + level: 'block', + start(src) { + const match = src.match(/^> ?\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/m) + return match ? match.index : undefined + }, + tokenizer(src) { + const rule = /^> ?\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][^\n]*(?:\n> ?.*)*/ + const match = rule.exec(src) + if (!match) return + + const raw = match[0] + const alertType = match[1].toLowerCase() + const lines = raw.split('\n').map(line => line.replace(/^> ?/, '')) + lines.shift() + const text = lines.join('\n').replace(/^\n+/, '') + const tokens = this.lexer.blockTokens(text, []) + + return { + type: 'markdownAlert', + raw, + alertType, + tokens, + } + }, + renderer(token) { + const title = token.alertType.charAt(0).toUpperCase() + token.alertType.slice(1) + const body = this.parser.parse(token.tokens) + return `\ +
\ +

${title}

\ +

${body}

\ +
` + }, +} + +let headingSlugCounts = new Map() + +const readmeHeadingRenderer = { + heading({ text, depth: level } = {}) { + if (level > 4) { + return `${text}` + } + + const slug = unique_heading_slug(text) + const id = `readme-${slug}` + return `\ +${text}\ +\ +\ +\ +` + }, +} + +marked.use({ extensions: [markdownAlertExtension], renderer: readmeHeadingRenderer }) + const target = document.getElementById('md') const source = target.dataset.readmeUrl const cacheKey = 'md:' + source -const cached = JSON.parse(sessionStorage.getItem(cacheKey) || 'null') +const cached = false && JSON.parse(sessionStorage.getItem(cacheKey) || 'null') const now = Math.floor(Date.now() / 1000) const ttl = 60 * 60 // 1 hour in seconds if (cached && (now - cached.time) < ttl) { target.innerHTML = cached.html + scroll_readme_anchor() } else { fetch(source) @@ -21,10 +80,14 @@ else { }) .then((md) => { if (DOMPurify.isSupported && is_markdown(source)) { + headingSlugCounts = new Map() const html = marked.parse(md) const html_ = post_process_html(html, source) - const safe_content = DOMPurify.sanitize(html_) + const safe_content = DOMPurify.sanitize(html_, { + USE_PROFILES: { html: true, svg: true, svgFilters: true }, + }) target.innerHTML = safe_content + scroll_readme_anchor() } else { const escaped = md @@ -54,12 +117,56 @@ function is_markdown(url) { return /(readme|\.md|\.mkd|\.mdown|\.markdown|\.txt)$/i.test(url) } +function scroll_readme_anchor() { + const url = new URL(window.location.href) + if (!url.searchParams.has('readme')) { + return + } + + const slug = url.hash.replace(/^#/, '') + if (!slug) { + return + } + + const anchor = document.getElementById(`readme-${slug}`) + if (anchor) { + anchor.scrollIntoView({ block: 'start' }) + } +} + +function unique_heading_slug(text) { + // Avoid duplicate ids/anchors when headings share the same text. + const base = slugify_heading(text) + const count = headingSlugCounts.get(base) ?? 0 + headingSlugCounts.set(base, count + 1) + if (count === 0) { + return base + } + + return `${base}-${count}` +} + +function slugify_heading(raw) { + return String(raw ?? '') + .toLowerCase() + .trim() + .replace(/<[^>]*>/g, '') + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') +} + function post_process_html(html, base_url) { const parser = new DOMParser() const doc = parser.parseFromString(html, 'text/html') const base = new URL(base_url) doc.querySelectorAll('a[href], img[src], video[src]').forEach((el) => { + if (el.matches('a.markdown-anchor')) { + return + } + const attr = el.hasAttribute('href') ? 'href' : 'src' const val = el.getAttribute(attr) if (val && !val.match(/^([a-z]+:|#|\/)/i)) { diff --git a/static/style/readme.css b/static/style/readme.css index c0abf84fb..ed20cf551 100644 --- a/static/style/readme.css +++ b/static/style/readme.css @@ -56,7 +56,7 @@ border-radius: 6px; line-height: 1.4rem; padding: 0px 4px; - box-shadow: inset 0px -1px 0px var(--foreground-4); + box-shadow: inset 0px -1px 0px var(--background-1); background: var(--highlight); } @@ -74,4 +74,75 @@ padding: 6px 13px; } } + + a { + color: var(--foreground-1); + text-decoration-color: var(--foreground-4); + text-underline-offset: 4px; + transition: color .1s ease-in-out, outline .1s ease-in-out, text-decoration-color .1s ease-in-out; + + :root[data-theme='dark'] & { + text-decoration-color: var(--foreground-2); + + &:hover { + text-decoration-color: var(--orange-2); + } + } + + &:hover { + color: var(--orange-2); + text-decoration-color: var(--orange-2); + } + } + + .markdown-alert { + border-left: 4px solid var(--foreground-2); + padding-left: 1em; + + &.markdown-alert-note, + &.markdown-alert-tip, + &.markdown-alert-important, + &.markdown-alert-warning, + &.markdown-alert-caution { + border-left-color: hsl(183.19deg 75.11% 44.29%); + + :root[data-theme='dark'] & { + } + } + + .markdown-alert-title { + font-weight: 700; + + &:after { + content: ':'; + } + } + } + + h1, h2, h3, h4 { + position: relative; + padding-left: 2.4rem; + margin-left: -2.4rem; + + &:hover, &:focus-within { + .markdown-anchor { + opacity: 1; + } + } + } + + .markdown-anchor { + opacity: 0; + display: flex; + align-items: center; + justify-content: center; + position: absolute; + left: 0; + bottom: 0.25em; + color: hsl(240deg 6.3% 43.09%); + color: var(--foreground-2); + width: 28px; + height: 28px; + text-decoration: none; + } }