import { execFileSync } from 'node:child_process' import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { escapeHtml, h, raw, render } from './html.js' import { marked } from 'marked' const siteName = 'PaulW.XYZ' const output = '.generated' const markdownDirs = ['notes', 'posts'] const standalonePages = [ { source: 'README.md', path: 'about', title: 'About' }, { source: 'THIRD_PARTY_LICENSES.md', path: 'licenses', title: 'Licenses' }, ] const languageLabels = { ini: 'INI', js: 'JavaScript', javascript: 'JavaScript', lua: 'Lua', sh: 'Shell', bash: 'Shell', } const slug = (value) => { let result = '' let dash = false for (const character of String(value).toLowerCase()) { const valid = (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') if (valid) { if (dash && result) result += '-' result += character dash = false } else if (result) dash = true } return result } 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 createSlugger = () => { const counts = new Map() return (tokens) => { const base = slug(headingLabel(tokens)) || 'section' const count = counts.get(base) ?? 0 counts.set(base, count + 1) return count === 0 ? base : `${base}-${count + 1}` } } const pageShell = (title, body, path = '/', navTitle = '') => `${render( h('html', { lang: 'en-US' }, [ h('head', {}, [ h('meta', { charset: 'utf-8' }), h('meta', { name: 'viewport', content: 'width=device-width, initial-scale=1', }), h('title', {}, [title ? `${title} | ${siteName}` : siteName]), h('link', { rel: 'icon', href: '/favicon.ico' }), h('link', { rel: 'stylesheet', href: '/src/global.css' }), h('script', { type: 'module', src: '/src/main.js' }), ]), h('body', {}, [raw(body), h('nav', { 'aria-label': 'Breadcrumb' }, [raw(nav(path, navTitle))])]), ]), )}` const nav = (path, currentTitle = '') => { if (path === '/') { if (!currentTitle) return render(h('span', {}, [siteName])) return render([h('a', { href: '/' }, [siteName]), ' / ', currentTitle]) } const parts = path.split('/').filter(Boolean) const children = [h('a', { href: '/' }, [siteName])] const sectionTitle = { notes: 'Notes', posts: 'Posts', about: 'About', licenses: 'Licenses' }[parts[0]] ?? parts[0] if (parts.length >= 1) children.push(' / ', parts.length > 1 ? h('a', { href: `/${parts[0]}/` }, [sectionTitle]) : sectionTitle) if (parts.length > 1) children.push(' / ', currentTitle) return render(children) } const dateFor = async (file) => { try { const gitDate = execFileSync( 'git', ['log', '-1', '--date=format-local:%Y-%m-%dT%H:%M:%SZ', '--format=%ad', '--', file], { env: { ...process.env, TZ: 'UTC' } }, ) .toString() .trim() if (gitDate) return gitDate } catch {} try { return (await stat(file)).mtime.toISOString() } catch { return '' } } const readPages = async (dir) => { const entries = await readdir(dir, { withFileTypes: true }) const markdownEntries = entries .filter((entry) => entry.isFile() && entry.name.endsWith('.md') && !entry.name.startsWith('.')) .sort((a, b) => a.name.localeCompare(b.name)) const pages = await Promise.all( markdownEntries.map(async (entry) => { const file = join(dir, entry.name) const source = await readFile(file, 'utf8') const titleLine = source.split(/\r?\n/).find((line) => line.startsWith('# ')) if (!titleLine) return undefined return { name: entry.name.slice(0, -3), title: stripSidenotes(titleLine.slice(2).trim()), file, source, date: await dateFor(file), } }), ) return pages.filter(Boolean) } let nextHeadingId = createSlugger() let sidenoteNumber = 0 marked.use({ extensions: [ { name: 'sidenote', level: 'inline', start: (source) => source.indexOf('^['), tokenizer(source) { if (!source.startsWith('^[')) return undefined let depth = 1 for (let index = 2; index < source.length; index += 1) { if (source[index] === '\\') { index += 1 } else if (source[index] === '[') { depth += 1 } else if (source[index] === ']') { depth -= 1 if (depth === 0) { const text = source.slice(2, index) return { type: 'sidenote', raw: source.slice(0, index + 1), tokens: this.lexer.inlineTokens(text), } } } } 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 source = escaped ? text : escapeHtml(text.replace(/\n$/, '')) return `
${escapeHtml(label)}
${source}\n
\n` }, heading({ tokens, depth }) { const text = this.parser.parseInline(tokens) const id = nextHeadingId(tokens) return `${text}\n` }, }, }) 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 stripSidenotes = (text) => { let result = '' for (let index = 0; index < text.length;) { if (text[index] === '^' && text[index + 1] === '[') { const end = text.indexOf(']', index + 2) if (end >= 0) { index = end + 1 continue } } result += text[index++] } return result } const stripHeadingSidenotes = (source) => source .split('\n') .map((line) => { const content = line.trimStart() return content.startsWith('# ') || content.startsWith('## ') || content.startsWith('### ') || content.startsWith('#### ') || content.startsWith('##### ') || content.startsWith('###### ') ? line.slice(0, line.length - content.length) + stripSidenotes(content) : line }) .join('\n') const renderMarkdown = (source) => { nextHeadingId = createSlugger() sidenoteNumber = 0 const html = marked.parse(withoutTitle(stripHeadingSidenotes(source))) let result = '' let cursor = 0 while (cursor < html.length) { const start = html.indexOf('', start) if (end < 0) { result += html.slice(cursor) break } result += html.slice(cursor, start) result += `
${html.slice(start, end + 8)}
` cursor = end + 8 } return result } const toc = (source) => { const headings = marked.lexer(withoutTitle(stripHeadingSidenotes(source))).filter((token) => token.type === 'heading') if (headings.length === 0) return '' const root = { depth: 0, children: [] } const stack = [root] const headingId = createSlugger() for (const heading of headings) { while (stack.length > 1 && stack.at(-1).depth >= heading.depth) stack.pop() const node = { depth: heading.depth, heading, children: [] } stack.at(-1).children.push(node) stack.push(node) } const renderItems = (items) => `
    ${items .map(({ heading, children }) => { const id = headingId(heading.tokens) const nested = children.length > 0 ? renderItems(children) : '' return `
  1. ${escapeHtml(headingLabel(heading.tokens))}${nested}
  2. ` }) .join('')}
` return renderItems(root.children) } const writePage = async (path, html) => { const directory = join(output, path) await mkdir(directory, { recursive: true }) await writeFile(join(directory, 'index.html'), html) } const titleHeading = (title, className = '') => `

${escapeHtml(title)}

` const renderHome = (externalLinks, sectionLinks, recentLinks) => pageShell( '', ` ${titleHeading(siteName)}
${sectionLinks}${externalLinks}

Recent

    ${recentLinks || '
  • Nothing to show yet.
  • '}
`, ) const renderNotesTable = (pages, directory) => { const rows = pages .map( (page) => ` ${escapeHtml(page.title)} ${page.date ? `` : ''} `, ) .join('') return `${rows}
${escapeHtml(directory)} pages
` } const renderPostsList = (pages, directory) => { const items = pages .map( (page) => `
  • ${escapeHtml(page.title)} ${page.date ? `` : ''}
  • `, ) .join('') return `` } const renderEmptySection = () => `

    **crickets**

    Nothing to list...

    Go Home
    ` const renderNotFound = () => pageShell( 'Page not found', `${titleHeading('404: Page Not Found')}

    Uh oh! The page you are looking for does not exist...

    Go HomeMore on 404
    `, '/', ' ... ??? / 404: Page Not Found', ) const renderSection = (directory, pages) => { const title = directory[0].toUpperCase() + directory.slice(1) const listing = pages.length === 0 ? renderEmptySection() : directory === 'posts' ? renderPostsList(pages, directory) : renderNotesTable(pages, directory) return pageShell( title, `${titleHeading(title)}
    ${listing}
    `, `/${directory}`, title, ) } const renderArticle = (directory, page, navigationPath = `/${directory}/${page.name}`) => { const updated = page.date ? `` : '' const heading = titleHeading(page.title, `${directory}-heading`) const tableOfContents = toc(page.source) const contents = tableOfContents ? `
    Contents${tableOfContents}

    ` : '' const article = `
    ${renderMarkdown(page.source)}
    ` const body = `${heading}
    ${updated}${contents}${article}
    ` return pageShell(page.title, body, navigationPath, page.title) } if (process.argv.includes('--clean')) await rm(output, { recursive: true, force: true }) await mkdir(output, { recursive: true }) const sections = Object.fromEntries(await Promise.all(markdownDirs.map(async (dir) => [dir, await readPages(dir)]))) const external = JSON.parse(await readFile('external.json', 'utf8')) const externalLinks = Object.entries(external) .map(([text, href]) => `${escapeHtml(text)}`) .join('') const sectionLinks = markdownDirs .map((dir) => `${dir[0].toUpperCase() + dir.slice(1)}`) .join('') const standaloneLinks = standalonePages .filter((page) => page.path !== 'licenses') .map((page) => `${page.title}`) .join('') const recentPages = markdownDirs .flatMap((dir) => sections[dir].map((page) => ({ ...page, dir }))) .sort((a, b) => b.date.localeCompare(a.date)) .slice(0, 5) const recentLinks = recentPages .map( (page) => `
  • ${page.date ? `` : ''}${page.dir}${escapeHtml(page.title)}
  • `, ) .join('') await writeFile(join(output, 'index.html'), renderHome(externalLinks, sectionLinks + standaloneLinks, recentLinks)) await writeFile(join(output, '404.html'), renderNotFound()) for (const dir of markdownDirs) { const pages = sections[dir] await writePage(dir, renderSection(dir, pages)) for (const page of pages) { await writePage(join(dir, page.name), renderArticle(dir, page)) } } for (const page of standalonePages) { const source = await readFile(page.source, 'utf8') const generatedPage = { name: page.path, title: page.title, source, date: await dateFor(page.source), } await writePage(page.path, renderArticle(page.path, generatedPage, '/')) }