Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/package.njk
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,29 @@ isPackagePage: true
{% if pkg.readme and not pkg.removed %}
<link rel="stylesheet" href="{{ '/static/style/readme.css' | bust}}">
<section id="md" class="readme" data-readme-url="{{ pkg.readme }}" data-list-target="main-content"></section>
<script>
(() => {
const target = document.getElementById('md')
if (!target) return

const source = target.dataset.readmeUrl
if (!source) return

const cacheKey = 'md:' + source
try {
const cached = JSON.parse(sessionStorage.getItem(cacheKey) || 'null')
if (!cached) return

const now = Math.floor(Date.now() / 1000)
const ttl = 60 * 60
if ((now - cached.time) < ttl) {
target.innerHTML = cached.html
}
} catch {
// Ignore cache parse failures.
}
})()
</script>
<script src="{{ '/static/readme.js' | bust }}" type="module" async></script>
{% endif %}

Expand Down
111 changes: 109 additions & 2 deletions static/readme.js
Original file line number Diff line number Diff line change
@@ -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 `\
<div class="markdown-alert markdown-alert-${token.alertType}">\
<p class="markdown-alert-title">${title}</p>\
<p class="markdown-alert-content">${body}</p>\
</div>`
},
}

let headingSlugCounts = new Map()

const readmeHeadingRenderer = {
heading({ text, depth: level } = {}) {
if (level > 4) {
return `<h${level}>${text}</h${level}>`
}

const slug = unique_heading_slug(text)
const id = `readme-${slug}`
return `\
<h${level} id="${id}">${text}\
<a class="markdown-anchor" href="?readme#${slug}" aria-labelledby="${id}">\
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="24" height="24"><path d="m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z"></path></svg>\
</a>\
</h${level}>`
},
}

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')

Check failure on line 66 in static/readme.js

View workflow job for this annotation

GitHub Actions / lint-and-test-and-build

Unexpected constant truthiness on the left-hand side of a `&&` expression

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)
Expand All @@ -21,10 +80,14 @@
})
.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
Expand Down Expand Up @@ -54,12 +117,56 @@
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)) {
Expand Down
73 changes: 72 additions & 1 deletion static/style/readme.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -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;
}
}
Loading