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 siteUrl = 'https://paulw.xyz' const output = '.generated' const markdownDirs = ['notes', 'posts'] const includeDrafts = process.argv.includes('--drafts') const navLabels = { notes: 'Notes', posts: 'Posts', about: 'About', licenses: 'Licenses', } 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 = '', { draft = false } = {}) => `${render( h('html', { lang: 'en-US' }, [ h('head', {}, [ h('meta', { charset: 'utf-8' }), h('meta', { name: 'viewport', content: 'width=device-width, initial-scale=1', }), draft ? h('meta', { name: 'robots', content: 'noindex, nofollow' }) : undefined, h('title', {}, [title ? `${title} | ${siteName}` : siteName]), h('link', { rel: 'icon', href: '/favicon.ico' }), h('link', { rel: 'alternate', type: 'application/rss+xml', title: `${siteName} feed`, href: '/feed.xml', }), 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])] let href = '' for (const [index, part] of parts.entries()) { href += `/${part}` const isLast = index === parts.length - 1 const label = isLast && currentTitle ? currentTitle : (navLabels[part] ?? part) children.push(' / ', isLast ? label : h('a', { href: `${href}/` }, [label])) } 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) => { let entries try { entries = await readdir(dir, { withFileTypes: true }) } catch (error) { if (error.code === 'ENOENT') return [] throw error } const markdownEntries = entries .filter((entry) => { if (!entry.isFile() || !entry.name.endsWith('.md') || entry.name === '.md') return false return includeDrafts || !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 const draft = entry.name.startsWith('.') const name = (draft ? entry.name.slice(1) : entry.name).slice(0, -3) if (!name) return undefined return { name, title: stripSidenotes(titleLine.slice(2).trim()), file, source, date: await dateFor(file), draft, } }), ) const byName = new Map() for (const page of pages.filter(Boolean)) { const existing = byName.get(page.name) if (!existing || (existing.draft && !page.draft)) byName.set(page.name, page) } return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)) } 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 excerpt = (source, max = 280) => { const paragraph = marked .lexer(withoutTitle(stripHeadingSidenotes(source))) .find((token) => token.type === 'paragraph') if (!paragraph) return '' const text = stripSidenotes(headingLabel(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 toRssDate = (iso) => { if (!iso) return '' const date = new Date(iso) return Number.isNaN(date.valueOf()) ? '' : date.toUTCString() } const toSitemapDate = (iso) => (iso ? iso.slice(0, 10) : '') const renderFeed = (items) => { const lastBuild = toRssDate(items.find((item) => item.date)?.date) || new Date().toUTCString() const itemXml = items .map((item) => { const loc = `${siteUrl}/${item.dir}/${item.name}/` const description = excerpt(item.source) const pubDate = toRssDate(item.date) return ` ${escapeHtml(item.title)} ${escapeHtml(loc)} ${escapeHtml(loc)} ${pubDate ? `${escapeHtml(pubDate)}` : ''} ${escapeHtml(item.dir)} ${description ? `${escapeHtml(description)}` : ''} ` }) .join('\n ') return ` ${escapeHtml(siteName)} ${escapeHtml(`${siteUrl}/`)} ${escapeHtml(`Notes and posts from ${siteName}`)} en-US ${escapeHtml(lastBuild)} ${itemXml} ` } const renderSitemap = (urls) => ` ${urls .map(({ loc, lastmod }) => { const modified = lastmod ? `\n ${escapeHtml(lastmod)}` : '' return ` ${escapeHtml(loc)}${modified} ` }) .join('\n')} ` 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 draftNotice = page.draft ? `

    Draft — rename .${escapeHtml(page.name)}.md to ${escapeHtml(page.name)}.md to publish.

    ` : '' const tableOfContents = toc(page.source) const contents = tableOfContents ? `
    Contents${tableOfContents}

    ` : '' const article = `
    ${renderMarkdown(page.source)}
    ` const body = `${heading}
    ${draftNotice}${updated}${contents}${article}
    ` return pageShell(page.title, body, navigationPath, page.title, { draft: Boolean(page.draft) }) } 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].filter((page) => !page.draft).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.filter((page) => !page.draft), ), ) for (const page of pages) { await writePage(join(dir, page.name), renderArticle(dir, page)) } } const generatedStandalone = [] 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), } generatedStandalone.push(generatedPage) await writePage(page.path, renderArticle(page.path, generatedPage, '/')) } const feedItems = markdownDirs .flatMap((dir) => sections[dir].filter((page) => !page.draft).map((page) => ({ ...page, dir }))) .sort((a, b) => b.date.localeCompare(a.date)) await writeFile(join(output, 'feed.xml'), renderFeed(feedItems)) const sitemapUrls = [ { loc: `${siteUrl}/`, lastmod: toSitemapDate(feedItems[0]?.date), }, ...markdownDirs.map((dir) => ({ loc: `${siteUrl}/${dir}/`, lastmod: toSitemapDate( [...sections[dir]].filter((page) => !page.draft).sort((a, b) => b.date.localeCompare(a.date))[0]?.date, ), })), ...generatedStandalone.map((page) => ({ loc: `${siteUrl}/${page.name}/`, lastmod: toSitemapDate(page.date), })), ...feedItems.map((page) => ({ loc: `${siteUrl}/${page.dir}/${page.name}/`, lastmod: toSitemapDate(page.date), })), { loc: `${siteUrl}/feed.xml`, lastmod: toSitemapDate(feedItems[0]?.date) }, ] await writeFile(join(output, 'sitemap.xml'), renderSitemap(sitemapUrls))