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
PageLast updated
`; }; 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 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)); 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, '/')); }