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 } = {}) =>
`${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 = '') => `
`
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 `${escapeHtml(directory)} pages| Page | Last updated |
${rows}
`
}
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)}`,
`/${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 = page.markdown.toc
const contents = tableOfContents
? `Contents
${tableOfContents}
`
: ''
const article = ``
const body = `${heading}${draftNotice}${updated}${contents}${article}`
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]) => `${escapeHtml(text)}`)
.join('')
const sectionLinks = contentDirectories
.map((dir) => `${dir[0].toUpperCase() + dir.slice(1)}`)
.join('')
const standaloneLinks = standalonePages
.filter((page) => page.path !== 'licenses')
.map((page) => `${page.title}`)
.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) =>
`${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 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
}