126 lines
5.0 KiB
JavaScript
126 lines
5.0 KiB
JavaScript
import { Marked } from 'marked'
|
|
import { stripSidenotes } from './content.js'
|
|
import { escapeHtml } from './html.js'
|
|
|
|
const languageLabels = { ini: 'INI', js: 'JavaScript', javascript: 'JavaScript', lua: 'Lua', sh: 'Shell', bash: 'Shell' }
|
|
|
|
const headingLabel = (tokens) => tokens.map((token) => {
|
|
if (token.type === 'image') return token.text ?? ''
|
|
if (token.tokens) return headingLabel(token.tokens)
|
|
return token.text ?? token.raw ?? ''
|
|
}).join('').trim()
|
|
|
|
const slug = (value) => String(value).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
|
|
|
const createSlugger = () => {
|
|
const counts = new Map()
|
|
return (tokens) => {
|
|
const base = slug(headingLabel(tokens)) || 'section'
|
|
const count = (counts.get(base) ?? 0) + 1
|
|
counts.set(base, count)
|
|
return count === 1 ? base : `${base}-${count}`
|
|
}
|
|
}
|
|
|
|
const withoutTitle = (source) => {
|
|
const lines = source.split('\n')
|
|
const index = lines.findIndex((line) => line.startsWith('# '))
|
|
if (index >= 0) lines.splice(index, 1)
|
|
return lines.join('\n')
|
|
}
|
|
|
|
const stripHeadingSidenotes = (source) => source.split('\n').map((line) => {
|
|
const content = line.trimStart()
|
|
return /^#{1,6} /.test(content)
|
|
? line.slice(0, line.length - content.length) + stripSidenotes(content)
|
|
: line
|
|
}).join('\n')
|
|
|
|
const renderToc = (headings) => {
|
|
if (headings.length === 0) return ''
|
|
const root = { depth: 0, children: [] }
|
|
const stack = [root]
|
|
for (const heading of headings) {
|
|
while (stack.length > 1 && stack.at(-1).depth >= heading.depth) stack.pop()
|
|
const node = { ...heading, children: [] }
|
|
stack.at(-1).children.push(node)
|
|
stack.push(node)
|
|
}
|
|
const renderItems = (items) => `<ol>${items.map(({ id, label, children }) =>
|
|
`<li><a href="#${id}">${escapeHtml(label)}</a>${children.length ? renderItems(children) : ''}</li>`).join('')}</ol>`
|
|
return renderItems(root.children)
|
|
}
|
|
|
|
const excerptFrom = (tokens, max = 280) => {
|
|
const paragraph = tokens.find((token) => token.type === 'paragraph')
|
|
if (!paragraph) return ''
|
|
const plainText = (items) => items.map((token) => token.type === 'sidenote'
|
|
? ''
|
|
: token.tokens ? plainText(token.tokens) : token.text ?? '').join('')
|
|
const text = stripSidenotes(plainText(paragraph.tokens ?? [{ text: paragraph.text ?? '' }]))
|
|
.replace(/\s+/g, ' ').trim()
|
|
if (text.length <= max) return text
|
|
const slice = text.slice(0, max)
|
|
const bound = slice.lastIndexOf(' ')
|
|
return `${(bound > 80 ? slice.slice(0, bound) : slice).trimEnd()}…`
|
|
}
|
|
|
|
const wrapTables = (html) => html.replace(/<table\b[\s\S]*?<\/table>/g, (table) => `<div class="table-scroll">${table}</div>`)
|
|
|
|
export const renderMarkdownPage = (source) => {
|
|
let sidenoteNumber = 0
|
|
let headingIndex = 0
|
|
let headings = []
|
|
const parser = new Marked({
|
|
extensions: [{
|
|
name: 'sidenote',
|
|
level: 'inline',
|
|
start: (text) => text.indexOf('^['),
|
|
tokenizer(text) {
|
|
if (!text.startsWith('^[')) return undefined
|
|
let depth = 1
|
|
for (let index = 2; index < text.length; index += 1) {
|
|
if (text[index] === '\\') index += 1
|
|
else if (text[index] === '[') depth += 1
|
|
else if (text[index] === ']') {
|
|
depth -= 1
|
|
if (depth === 0) return {
|
|
type: 'sidenote', raw: text.slice(0, index + 1),
|
|
tokens: this.lexer.inlineTokens(text.slice(2, index)),
|
|
}
|
|
}
|
|
}
|
|
return undefined
|
|
},
|
|
renderer({ tokens }) {
|
|
sidenoteNumber += 1
|
|
const id = `sidenote-${sidenoteNumber}`
|
|
const content = this.parser.parseInline(tokens)
|
|
return `<input type="checkbox" id="${id}" class="sidenote-toggle visually-hidden"><label for="${id}" class="margin-toggle sidenote-number"><span class="visually-hidden">Toggle sidenote ${sidenoteNumber}</span></label><span class="sidenote">${content}</span>`
|
|
},
|
|
}],
|
|
renderer: {
|
|
code({ text, lang, escaped }) {
|
|
const language = lang?.trim().split(/\s+/, 1)[0].toLowerCase() || 'plain'
|
|
const label = languageLabels[language] ?? language.charAt(0).toUpperCase() + language.slice(1)
|
|
const code = escaped ? text : escapeHtml(text.replace(/\n$/, ''))
|
|
return `<div class="code-block"><div class="code-block-header"><span>${escapeHtml(label)}</span><button type="button" class="copy-code" aria-label="Copy ${escapeHtml(label)} code" aria-live="polite">Copy</button></div><pre><code class="language-${escapeHtml(language)}">${code}\n</code></pre></div>\n`
|
|
},
|
|
heading({ tokens, depth }) {
|
|
const id = headings[headingIndex++]?.id
|
|
return `<h${depth} id="${id}">${this.parser.parseInline(tokens)}</h${depth}>\n`
|
|
},
|
|
},
|
|
})
|
|
const tokens = parser.lexer(withoutTitle(stripHeadingSidenotes(source)))
|
|
const nextId = createSlugger()
|
|
headings = tokens.filter((token) => token.type === 'heading').map((token) => ({
|
|
depth: token.depth, label: headingLabel(token.tokens), id: nextId(token.tokens),
|
|
}))
|
|
return {
|
|
html: wrapTables(parser.parser(tokens)),
|
|
toc: renderToc(headings),
|
|
excerpt: excerptFrom(tokens),
|
|
}
|
|
}
|