Refactor site generation into a modular content pipeline
Deploy website / deploy (push) Successful in 24s
Deploy website / deploy (push) Successful in 24s
This commit is contained in:
1 parent
0f69028d4a
commit
146d516f24
20 files changed
+826
-427
No files matched your search
+125
@@ -0,0 +1,125 @@
|
||||
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),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user