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) => `
    ${items.map(({ id, label, children }) => `
  1. ${escapeHtml(label)}${children.length ? renderItems(children) : ''}
  2. `).join('')}
` 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(//g, (table) => `
${table}
`) 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 `${content}` }, }], 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 `
${escapeHtml(label)}
${code}\n
\n` }, heading({ tokens, depth }) { const id = headings[headingIndex++]?.id return `${this.parser.parseInline(tokens)}\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), } }