Files
www/markdown.js
paul 146d516f24
Deploy website / deploy (push) Successful in 24s
Refactor site generation into a modular content pipeline
2026-09-22 20:51:13 -04:00

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