263 lines
9.5 KiB
JavaScript
263 lines
9.5 KiB
JavaScript
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
import { join } from 'node:path'
|
|
import { escapeHtml, h, raw, render } from './html.js'
|
|
import { dateFor, readPages } from './content.js'
|
|
import { renderFeed, renderSitemap, toSitemapDate } from './feed.js'
|
|
import { createOutput, discardOutput, publishOutput } from './output.js'
|
|
import { renderMarkdownPage } from './markdown.js'
|
|
import {
|
|
contentDirectories,
|
|
generatedRoot,
|
|
navLabels,
|
|
siteName,
|
|
siteUrl,
|
|
standalonePages,
|
|
} from './site.config.js'
|
|
|
|
const includeDrafts = process.argv.includes('--drafts')
|
|
const output = await createOutput(generatedRoot)
|
|
|
|
const pageShell = (title, body, path = '/', navTitle = '', { draft = false } = {}) =>
|
|
`<!DOCTYPE html>${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 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 = '') => `
|
|
<header class="page-heading${className ? ` ${className}` : ''}">
|
|
<h1 class="title">${escapeHtml(title)}</h1>
|
|
</header>`
|
|
|
|
const renderHome = (externalLinks, sectionLinks, recentLinks) =>
|
|
pageShell(
|
|
'',
|
|
`
|
|
${titleHeading(siteName)}
|
|
<main class="container">
|
|
<section class="block">${sectionLinks}${externalLinks}</section>
|
|
<section class="block recent">
|
|
<h2>Recent</h2>
|
|
<ul>${recentLinks || '<li>Nothing to show yet.</li>'}</ul>
|
|
</section>
|
|
</main>`,
|
|
)
|
|
|
|
const renderNotesTable = (pages, directory) => {
|
|
const rows = pages
|
|
.map(
|
|
(page) => `
|
|
<tr>
|
|
<td><a href="/${directory}/${page.name}/">${escapeHtml(page.title)}</a></td>
|
|
<td>${page.date ? `<time class="table-last-updated" datetime="${escapeHtml(page.date)}" data-value="${escapeHtml(page.date)}">${escapeHtml(page.date)}</time>` : ''}</td>
|
|
</tr>`,
|
|
)
|
|
.join('')
|
|
return `<table class="${directory}-table"><caption class="visually-hidden">${escapeHtml(directory)} pages</caption><thead><tr><th scope="col">Page</th><th scope="col">Last updated</th></tr></thead><tbody>${rows}</tbody></table>`
|
|
}
|
|
|
|
const renderPostsList = (pages, directory) => {
|
|
const items = pages
|
|
.map(
|
|
(page) => `
|
|
<li>
|
|
<a href="/${directory}/${page.name}/">${escapeHtml(page.title)}</a>
|
|
${page.date ? `<time datetime="${escapeHtml(page.date)}" data-value="${escapeHtml(page.date)}">${escapeHtml(page.date)}</time>` : ''}
|
|
</li>`,
|
|
)
|
|
.join('')
|
|
return `<ul class="posts-list">${items}</ul>`
|
|
}
|
|
|
|
const renderEmptySection = () => `
|
|
<div class="text center">
|
|
<p><strong>crickets</strong></p>
|
|
<p>Nothing to list...</p>
|
|
<a class="link button back" href="/">Go Home</a>
|
|
</div>`
|
|
|
|
const renderNotFound = () =>
|
|
pageShell(
|
|
'Page not found',
|
|
`${titleHeading('404: Page Not Found')}<main class="container"><section class="block text center"><p><strong>Uh oh! The page you are looking for does not exist...</strong></p><a class="link button back" href="/">Go Home</a><a class='button blue link extern' href='https://en.wikipedia.org/wiki/HTTP_404'>More on 404</a></section></main>`,
|
|
'/',
|
|
' ... ??? / 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)}<main class="container"><section class="block">${listing}</section></main>`,
|
|
`/${directory}`,
|
|
title,
|
|
)
|
|
}
|
|
|
|
const renderArticle = (directory, page, navigationPath = `/${directory}/${page.name}`) => {
|
|
const updated = page.date
|
|
? `<time class="last-updated" datetime="${escapeHtml(page.date)}" data-value="${escapeHtml(page.date)}">Last updated: ${escapeHtml(page.date)}</time>`
|
|
: ''
|
|
const heading = titleHeading(page.title, `${directory}-heading`)
|
|
const draftNotice = page.draft
|
|
? `<p class="draft-banner" role="status">Draft — rename <code>.${escapeHtml(page.name)}.md</code> to <code>${escapeHtml(page.name)}.md</code> to publish.</p>`
|
|
: ''
|
|
const tableOfContents = page.markdown.toc
|
|
const contents = tableOfContents
|
|
? `<section class="block toc"><details open><summary>Contents</summary>${tableOfContents}</details></section><hr>`
|
|
: ''
|
|
const article = `<section class="block">${page.markdown.html}</section>`
|
|
const body = `${heading}<main class="article-page ${directory}-page container">${draftNotice}${updated}${contents}${article}</main>`
|
|
|
|
return pageShell(page.title, body, navigationPath, page.title, { draft: Boolean(page.draft) })
|
|
}
|
|
|
|
try {
|
|
const sections = Object.fromEntries(await Promise.all(contentDirectories.map(async (dir) => [dir, await readPages(dir, { includeDrafts })])))
|
|
for (const pages of Object.values(sections)) {
|
|
for (const page of pages) page.markdown = renderMarkdownPage(page.source)
|
|
}
|
|
|
|
const external = JSON.parse(await readFile('external.json', 'utf8'))
|
|
|
|
const externalLinks = Object.entries(external)
|
|
.map(([text, href]) => `<a class="button blue link extern" href="${escapeHtml(href)}">${escapeHtml(text)}</a>`)
|
|
.join('')
|
|
|
|
const sectionLinks = contentDirectories
|
|
.map((dir) => `<a class="button link" href="/${dir}/">${dir[0].toUpperCase() + dir.slice(1)}</a>`)
|
|
.join('')
|
|
|
|
const standaloneLinks = standalonePages
|
|
.filter((page) => page.path !== 'licenses')
|
|
.map((page) => `<a class="button link" href="/${page.path}/">${page.title}</a>`)
|
|
.join('')
|
|
|
|
const recentPages = contentDirectories
|
|
.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) =>
|
|
`<li>${page.date ? `<time class="recent-date" datetime="${escapeHtml(page.date)}" data-value="${escapeHtml(page.date)}" data-tooltip="${escapeHtml(page.date)}">${escapeHtml(page.date)}</time>` : ''}<span class="recent-gap" aria-hidden="true"></span><span class="recent-type">${page.dir}</span><a href="/${page.dir}/${page.name}/">${escapeHtml(page.title)}</a></li>`,
|
|
)
|
|
.join('')
|
|
await writeFile(join(output, 'index.html'), renderHome(externalLinks, sectionLinks + standaloneLinks, recentLinks))
|
|
await writeFile(join(output, '404.html'), renderNotFound())
|
|
|
|
for (const dir of contentDirectories) {
|
|
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,
|
|
markdown: renderMarkdownPage(source),
|
|
date: await dateFor(page.source),
|
|
}
|
|
generatedStandalone.push(generatedPage)
|
|
await writePage(page.path, renderArticle(page.path, generatedPage, '/'))
|
|
}
|
|
|
|
const feedItems = contentDirectories
|
|
.flatMap((dir) => sections[dir].filter((page) => !page.draft).map((page) => ({ ...page, dir })))
|
|
.sort((a, b) => b.date.localeCompare(a.date))
|
|
|
|
for (const item of feedItems) item.description = item.markdown.excerpt
|
|
|
|
await writeFile(join(output, 'feed.xml'), renderFeed(feedItems))
|
|
|
|
const sitemapUrls = [
|
|
{
|
|
loc: `${siteUrl}/`,
|
|
lastmod: toSitemapDate(feedItems[0]?.date),
|
|
},
|
|
...contentDirectories.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))
|
|
await publishOutput(output, generatedRoot)
|
|
} catch (error) {
|
|
await discardOutput(output)
|
|
throw error
|
|
}
|