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 `
\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 `- ${escapeHtml(headingLabel(heading.tokens))}${nested}
`
})
.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 = '') => `
`
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${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 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, '/'))
}