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 `\
+
`
+ },
+}
+
+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;
+ }
}