fmt and ci
Deploy website / deploy (push) Failing after 28s

This commit is contained in:
2026-08-25 17:59:45 -04:00
parent 7e9e744f0d
commit aacfd33c62
11 changed files with 1005 additions and 835 deletions
+46
View File
@@ -0,0 +1,46 @@
name: Deploy website
on:
push:
branches:
- master
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Check out source
uses: actions/checkout@v4
- name: Install Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --no-progress
- name: Build site
run: bun run build
- name: Configure SSH
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: |
install -d -m 700 "$HOME/.ssh"
printf '%s\n' "$DEPLOY_SSH_KEY" > "$HOME/.ssh/deploy_key"
chmod 600 "$HOME/.ssh/deploy_key"
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
- name: Deploy dist/
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
run: |
rsync -az --delete \
-e "ssh -i $HOME/.ssh/deploy_key -o IdentitiesOnly=yes" \
dist/ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/"
+7
View File
@@ -0,0 +1,7 @@
{
"printWidth": 120,
"semi": false,
"useTabs": false,
"singleQuote": true,
"ignorePatterns": ["**/*.md"]
}
+52 -40
View File
@@ -1,67 +1,79 @@
import { readdir, readFile, stat } from 'node:fs/promises'; import { readdir, readFile, stat } from 'node:fs/promises'
import { join } from 'node:path'; import { join } from 'node:path'
const generatedRoot = '.generated'; const generatedRoot = '.generated'
const walk = async (directory) => { const walk = async (directory) => {
const files = []; const files = []
for (const entry of await readdir(directory, { withFileTypes: true })) { for (const entry of await readdir(directory, { withFileTypes: true })) {
const path = join(directory, entry.name); const path = join(directory, entry.name)
if (entry.isDirectory()) files.push(...await walk(path)); if (entry.isDirectory()) files.push(...(await walk(path)))
else files.push(path); else files.push(path)
}
return files
} }
return files;
};
const exists = async (path) => { const exists = async (path) => {
try { try {
await stat(path); await stat(path)
return true; return true
} catch { } catch {
return false; return false
}
} }
};
const errors = []; const errors = []
const htmlFiles = (await walk(generatedRoot)).filter((file) => file.endsWith('.html')); const htmlFiles = (await walk(generatedRoot)).filter((file) => file.endsWith('.html'))
const clientSource = await readFile('src/main.js', 'utf8'); const clientSource = await readFile('src/main.js', 'utf8')
const builtInPrismLanguages = new Set(['markup', 'html', 'xml', 'svg', 'css', 'clike', 'javascript', 'js', 'plain', 'plaintext', 'text', 'txt']); const builtInPrismLanguages = new Set([
const prismLanguageAliases = { sh: 'bash', shell: 'bash' }; 'markup',
'html',
'xml',
'svg',
'css',
'clike',
'javascript',
'js',
'plain',
'plaintext',
'text',
'txt',
])
const prismLanguageAliases = { sh: 'bash', shell: 'bash' }
for (const file of htmlFiles) { for (const file of htmlFiles) {
const html = await readFile(file, 'utf8'); const html = await readFile(file, 'utf8')
const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]); const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1])
const duplicateIds = ids.filter((id, index) => ids.indexOf(id) !== index); const duplicateIds = ids.filter((id, index) => ids.indexOf(id) !== index)
if (duplicateIds.length > 0) errors.push(`${file}: duplicate IDs: ${[...new Set(duplicateIds)].join(', ')}`); if (duplicateIds.length > 0) errors.push(`${file}: duplicate IDs: ${[...new Set(duplicateIds)].join(', ')}`)
if (!html.includes('<meta name="viewport"')) errors.push(`${file}: missing viewport metadata`); if (!html.includes('<meta name="viewport"')) errors.push(`${file}: missing viewport metadata`)
if (html.includes('<heading')) errors.push(`${file}: contains a nonstandard <heading> element`); if (html.includes('<heading')) errors.push(`${file}: contains a nonstandard <heading> element`)
for (const [, fragment] of html.matchAll(/href="#([^"]+)"/g)) { for (const [, fragment] of html.matchAll(/href="#([^"]+)"/g)) {
if (!ids.includes(fragment)) errors.push(`${file}: missing fragment target #${fragment}`); if (!ids.includes(fragment)) errors.push(`${file}: missing fragment target #${fragment}`)
} }
for (const [, href] of html.matchAll(/href="(\/[^"]*)"/g)) { for (const [, href] of html.matchAll(/href="(\/[^"]*)"/g)) {
if (href === '/') continue; if (href === '/') continue
const pathname = href.split(/[?#]/, 1)[0]; const pathname = href.split(/[?#]/, 1)[0]
let target; let target
if (pathname.startsWith('/src/')) target = `.${pathname}`; if (pathname.startsWith('/src/')) target = `.${pathname}`
else if (pathname.startsWith('/assets/') || pathname === '/favicon.ico') target = `public${pathname}`; else if (pathname.startsWith('/assets/') || pathname === '/favicon.ico') target = `public${pathname}`
else target = pathname.endsWith('/') else target = pathname.endsWith('/') ? join(generatedRoot, pathname, 'index.html') : join(generatedRoot, pathname)
? join(generatedRoot, pathname, 'index.html') if (!(await exists(target))) errors.push(`${file}: broken internal link ${href}`)
: join(generatedRoot, pathname);
if (!await exists(target)) errors.push(`${file}: broken internal link ${href}`);
} }
for (const [, language] of html.matchAll(/class="language-([\w-]+)"/g)) { for (const [, language] of html.matchAll(/class="language-([\w-]+)"/g)) {
if (builtInPrismLanguages.has(language)) continue; if (builtInPrismLanguages.has(language)) continue
const component = prismLanguageAliases[language] ?? language; const component = prismLanguageAliases[language] ?? language
if (!clientSource.includes(`prism-${component}`)) errors.push(`${file}: Prism grammar is not imported for ${language}`); if (!clientSource.includes(`prism-${component}`))
errors.push(`${file}: Prism grammar is not imported for ${language}`)
} }
} }
if (errors.length > 0) { if (errors.length > 0) {
console.error(errors.join('\n')); console.error(errors.join('\n'))
process.exitCode = 1; process.exitCode = 1
} else { } else {
console.log(`Checked ${htmlFiles.length} generated pages.`); console.log(`Checked ${htmlFiles.length} generated pages.`)
} }
+2 -5
View File
@@ -1,6 +1,3 @@
import { rm } from 'node:fs/promises'; import { rm } from 'node:fs/promises'
await Promise.all([ await Promise.all([rm('.generated', { recursive: true, force: true }), rm('dist', { recursive: true, force: true })])
rm('.generated', { recursive: true, force: true }),
rm('dist', { recursive: true, force: true }),
]);
+267 -175
View File
@@ -1,16 +1,18 @@
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process'
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { join } from 'node:path'; import { join } from 'node:path'
import { escapeHtml, h, raw, render } from './html.js'; import { escapeHtml, h, raw, render } from './html.js'
import { marked } from 'marked'; import { marked } from 'marked'
const siteName = 'PaulW.XYZ'
const output = '.generated'
const markdownDirs = ['notes', 'posts']
const siteName = 'PaulW.XYZ';
const output = '.generated';
const markdownDirs = ['notes', 'posts'];
const standalonePages = [ const standalonePages = [
{ source: 'README.md', path: 'about', title: 'About' }, { source: 'README.md', path: 'about', title: 'About' },
{ source: 'THIRD_PARTY_LICENSES.md', path: 'licenses', title: 'Licenses' }, { source: 'THIRD_PARTY_LICENSES.md', path: 'licenses', title: 'Licenses' },
]; ]
const languageLabels = { const languageLabels = {
ini: 'INI', ini: 'INI',
js: 'JavaScript', js: 'JavaScript',
@@ -18,213 +20,269 @@ const languageLabels = {
lua: 'Lua', lua: 'Lua',
sh: 'Shell', sh: 'Shell',
bash: 'Shell', bash: 'Shell',
}; }
const slug = (value) => { const slug = (value) => {
let result = ''; let result = ''
let dash = false; let dash = false
for (const character of String(value).toLowerCase()) { for (const character of String(value).toLowerCase()) {
const valid = (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9'); const valid = (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9')
if (valid) { if (dash && result) result += '-'; result += character; dash = false; } if (valid) {
else if (result) dash = true; if (dash && result) result += '-'
result += character
dash = false
} else if (result) dash = true
} }
return result; return result
}; }
const headingLabel = (tokens) => tokens.map((token) => {
if (token.type === 'image') return token.text ?? ''; const headingLabel = (tokens) =>
if (token.tokens) return headingLabel(token.tokens); tokens
return token.text ?? token.raw ?? ''; .map((token) => {
}).join('').trim(); if (token.type === 'image') return token.text ?? ''
if (token.tokens) return headingLabel(token.tokens)
return token.text ?? token.raw ?? ''
})
.join('')
.trim()
const createSlugger = () => { const createSlugger = () => {
const counts = new Map(); const counts = new Map()
return (tokens) => { return (tokens) => {
const base = slug(headingLabel(tokens)) || 'section'; const base = slug(headingLabel(tokens)) || 'section'
const count = counts.get(base) ?? 0; const count = counts.get(base) ?? 0
counts.set(base, count + 1); counts.set(base, count + 1)
return count === 0 ? base : `${base}-${count + 1}`; return count === 0 ? base : `${base}-${count + 1}`
}; }
}; }
const pageShell = (title, body, path = '/', navTitle = '') => `<!DOCTYPE html>${render(h('html', { lang: 'en-US' }, [ const pageShell = (title, body, path = '/', navTitle = '') =>
`<!DOCTYPE html>${render(
h('html', { lang: 'en-US' }, [
h('head', {}, [ h('head', {}, [
h('meta', { charset: 'utf-8' }), h('meta', { charset: 'utf-8' }),
h('meta', { name: 'viewport', content: 'width=device-width, initial-scale=1' }), h('meta', {
name: 'viewport',
content: 'width=device-width, initial-scale=1',
}),
h('title', {}, [title ? `${title} | ${siteName}` : siteName]), h('title', {}, [title ? `${title} | ${siteName}` : siteName]),
h('link', { rel: 'icon', href: '/favicon.ico' }), h('link', { rel: 'icon', href: '/favicon.ico' }),
h('link', { rel: 'stylesheet', href: '/src/global.css' }), h('link', { rel: 'stylesheet', href: '/src/global.css' }),
h('script', { type: 'module', src: '/src/main.js' }), h('script', { type: 'module', src: '/src/main.js' }),
]), ]),
h('body', {}, [raw(body), h('nav', { 'aria-label': 'Breadcrumb' }, [raw(nav(path, navTitle))])]), h('body', {}, [raw(body), h('nav', { 'aria-label': 'Breadcrumb' }, [raw(nav(path, navTitle))])]),
]))}`; ]),
)}`
const nav = (path, currentTitle = '') => { const nav = (path, currentTitle = '') => {
if (path === '/') { if (path === '/') {
if (!currentTitle) return render(h('span', {}, [siteName])); if (!currentTitle) return render(h('span', {}, [siteName]))
return render([h('a', { href: '/' }, [siteName]), ' / ', currentTitle]); 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 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) => { const dateFor = async (file) => {
try { 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(); const gitDate = execFileSync(
if (gitDate) return gitDate; '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 {} } catch {}
try { return (await stat(file)).mtime.toISOString(); } try {
catch { return ''; } return (await stat(file)).mtime.toISOString()
}; } catch {
return ''
}
}
const readPages = async (dir) => { const readPages = async (dir) => {
const entries = await readdir(dir, { withFileTypes: true }); const entries = await readdir(dir, { withFileTypes: true })
const markdownEntries = entries const markdownEntries = entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.md') && !entry.name.startsWith('.')) .filter((entry) => entry.isFile() && entry.name.endsWith('.md') && !entry.name.startsWith('.'))
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name))
const pages = await Promise.all(markdownEntries.map(async (entry) => { const pages = await Promise.all(
const file = join(dir, entry.name); markdownEntries.map(async (entry) => {
const source = await readFile(file, 'utf8'); const file = join(dir, entry.name)
const titleLine = source.split(/\r?\n/).find((line) => line.startsWith('# ')); const source = await readFile(file, 'utf8')
if (!titleLine) return undefined; const titleLine = source.split(/\r?\n/).find((line) => line.startsWith('# '))
if (!titleLine) return undefined
return { return {
name: entry.name.slice(0, -3), name: entry.name.slice(0, -3),
title: stripSidenotes(titleLine.slice(2).trim()), title: stripSidenotes(titleLine.slice(2).trim()),
file, file,
source, source,
date: await dateFor(file), date: await dateFor(file),
}; }
})); }),
return pages.filter(Boolean); )
}; return pages.filter(Boolean)
}
let nextHeadingId = createSlugger(); let nextHeadingId = createSlugger()
let sidenoteNumber = 0; let sidenoteNumber = 0
marked.use({ marked.use({
extensions: [{ extensions: [
{
name: 'sidenote', name: 'sidenote',
level: 'inline', level: 'inline',
start: (source) => source.indexOf('^['), start: (source) => source.indexOf('^['),
tokenizer(source) { tokenizer(source) {
if (!source.startsWith('^[')) return undefined; if (!source.startsWith('^[')) return undefined
let depth = 1; let depth = 1
for (let index = 2; index < source.length; index += 1) { for (let index = 2; index < source.length; index += 1) {
if (source[index] === '\\') { if (source[index] === '\\') {
index += 1; index += 1
} else if (source[index] === '[') { } else if (source[index] === '[') {
depth += 1; depth += 1
} else if (source[index] === ']') { } else if (source[index] === ']') {
depth -= 1; depth -= 1
if (depth === 0) { if (depth === 0) {
const text = source.slice(2, index); const text = source.slice(2, index)
return { return {
type: 'sidenote', type: 'sidenote',
raw: source.slice(0, index + 1), raw: source.slice(0, index + 1),
tokens: this.lexer.inlineTokens(text), tokens: this.lexer.inlineTokens(text),
};
} }
} }
} }
return undefined; }
return undefined
}, },
renderer({ tokens }) { renderer({ tokens }) {
sidenoteNumber += 1; sidenoteNumber += 1
const id = `sidenote-${sidenoteNumber}`; const id = `sidenote-${sidenoteNumber}`
const content = this.parser.parseInline(tokens); const content = this.parser.parseInline(tokens)
return `<input type="checkbox" id="${id}" class="sidenote-toggle visually-hidden"><label for="${id}" class="margin-toggle sidenote-number"><span class="visually-hidden">Toggle sidenote ${sidenoteNumber}</span></label><span class="sidenote">${content}</span>`; return `<input type="checkbox" id="${id}" class="sidenote-toggle visually-hidden"><label for="${id}" class="margin-toggle sidenote-number"><span class="visually-hidden">Toggle sidenote ${sidenoteNumber}</span></label><span class="sidenote">${content}</span>`
}, },
}], },
],
renderer: { renderer: {
code({ text, lang, escaped }) { code({ text, lang, escaped }) {
const language = lang?.trim().split(/\s+/, 1)[0].toLowerCase() || 'plain'; const language = lang?.trim().split(/\s+/, 1)[0].toLowerCase() || 'plain'
const label = languageLabels[language] const label = languageLabels[language] ?? language.charAt(0).toUpperCase() + language.slice(1)
?? language.charAt(0).toUpperCase() + language.slice(1); const source = escaped ? text : escapeHtml(text.replace(/\n$/, ''))
const source = escaped ? text : escapeHtml(text.replace(/\n$/, '')); return `<div class="code-block"><div class="code-block-header"><span>${escapeHtml(label)}</span><button type="button" class="copy-code" aria-label="Copy ${escapeHtml(label)} code" aria-live="polite">Copy</button></div><pre><code class="language-${escapeHtml(language)}">${source}\n</code></pre></div>\n`
return `<div class="code-block"><div class="code-block-header"><span>${escapeHtml(label)}</span><button type="button" class="copy-code" aria-label="Copy ${escapeHtml(label)} code" aria-live="polite">Copy</button></div><pre><code class="language-${escapeHtml(language)}">${source}\n</code></pre></div>\n`;
}, },
heading({ tokens, depth }) { heading({ tokens, depth }) {
const text = this.parser.parseInline(tokens); const text = this.parser.parseInline(tokens)
const id = nextHeadingId(tokens); const id = nextHeadingId(tokens)
return `<h${depth} id="${id}">${text}</h${depth}>\n`; return `<h${depth} id="${id}">${text}</h${depth}>\n`
}, },
}, },
}); })
const withoutTitle = (source) => { const withoutTitle = (source) => {
const lines = source.split('\n'); const lines = source.split('\n')
const index = lines.findIndex((line) => line.startsWith('# ')); const index = lines.findIndex((line) => line.startsWith('# '))
if (index >= 0) lines.splice(index, 1); if (index >= 0) lines.splice(index, 1)
return lines.join('\n'); return lines.join('\n')
}; }
const stripSidenotes = (text) => { const stripSidenotes = (text) => {
let result = ''; let result = ''
for (let index = 0; index < text.length;) { for (let index = 0; index < text.length;) {
if (text[index] === '^' && text[index + 1] === '[') { if (text[index] === '^' && text[index + 1] === '[') {
const end = text.indexOf(']', index + 2); const end = text.indexOf(']', index + 2)
if (end >= 0) { index = end + 1; continue; } if (end >= 0) {
index = end + 1
continue
} }
result += text[index++];
} }
return result; result += text[index++]
}; }
const stripHeadingSidenotes = (source) => source.split('\n').map((line) => { return result
const content = line.trimStart(); }
return content.startsWith('# ') || content.startsWith('## ') || content.startsWith('### ') || content.startsWith('#### ') || content.startsWith('##### ') || content.startsWith('###### ')
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.slice(0, line.length - content.length) + stripSidenotes(content)
: line; : line
}).join('\n'); })
.join('\n')
const renderMarkdown = (source) => { const renderMarkdown = (source) => {
nextHeadingId = createSlugger(); nextHeadingId = createSlugger()
sidenoteNumber = 0; sidenoteNumber = 0
const html = marked.parse(withoutTitle(stripHeadingSidenotes(source))); const html = marked.parse(withoutTitle(stripHeadingSidenotes(source)))
let result = ''; let result = ''
let cursor = 0; let cursor = 0
while (cursor < html.length) { while (cursor < html.length) {
const start = html.indexOf('<table', cursor); const start = html.indexOf('<table', cursor)
if (start < 0) { result += html.slice(cursor); break; } if (start < 0) {
const end = html.indexOf('</table>', start); result += html.slice(cursor)
if (end < 0) { result += html.slice(cursor); break; } break
result += html.slice(cursor, start);
result += `<div class="table-scroll">${html.slice(start, end + 8)}</div>`;
cursor = end + 8;
} }
return result; const end = html.indexOf('</table>', start)
}; if (end < 0) {
result += html.slice(cursor)
break
}
result += html.slice(cursor, start)
result += `<div class="table-scroll">${html.slice(start, end + 8)}</div>`
cursor = end + 8
}
return result
}
const toc = (source) => { const toc = (source) => {
const headings = marked.lexer(withoutTitle(stripHeadingSidenotes(source))).filter((token) => token.type === 'heading'); const headings = marked.lexer(withoutTitle(stripHeadingSidenotes(source))).filter((token) => token.type === 'heading')
if (headings.length === 0) return ''; if (headings.length === 0) return ''
const root = { depth: 0, children: [] }; const root = { depth: 0, children: [] }
const stack = [root]; const stack = [root]
const headingId = createSlugger(); const headingId = createSlugger()
for (const heading of headings) { for (const heading of headings) {
while (stack.length > 1 && stack.at(-1).depth >= heading.depth) stack.pop(); while (stack.length > 1 && stack.at(-1).depth >= heading.depth) stack.pop()
const node = { depth: heading.depth, heading, children: [] }; const node = { depth: heading.depth, heading, children: [] }
stack.at(-1).children.push(node); stack.at(-1).children.push(node)
stack.push(node); stack.push(node)
}
const renderItems = (items) =>
`<ol>${items
.map(({ heading, children }) => {
const id = headingId(heading.tokens)
const nested = children.length > 0 ? renderItems(children) : ''
return `<li><a href="#${id}">${escapeHtml(headingLabel(heading.tokens))}</a>${nested}</li>`
})
.join('')}</ol>`
return renderItems(root.children)
} }
const renderItems = (items) => `<ol>${items.map(({ heading, children }) => {
const id = headingId(heading.tokens);
const nested = children.length > 0 ? renderItems(children) : '';
return `<li><a href="#${id}">${escapeHtml(headingLabel(heading.tokens))}</a>${nested}</li>`;
}).join('')}</ol>`;
return renderItems(root.children);
};
const writePage = async (path, html) => { const writePage = async (path, html) => {
const directory = join(output, path); const directory = join(output, path)
await mkdir(directory, { recursive: true }); await mkdir(directory, { recursive: true })
await writeFile(join(directory, 'index.html'), html); await writeFile(join(directory, 'index.html'), html)
}; }
const titleHeading = (title, className = '') => ` const titleHeading = (title, className = '') => `
<header class="page-heading${className ? ` ${className}` : ''}"> <header class="page-heading${className ? ` ${className}` : ''}">
<h1 class="title">${escapeHtml(title)}</h1> <h1 class="title">${escapeHtml(title)}</h1>
</header>`; </header>`
const renderHome = (externalLinks, sectionLinks, recentLinks) => pageShell('', ` const renderHome = (externalLinks, sectionLinks, recentLinks) =>
pageShell(
'',
`
${titleHeading(siteName)} ${titleHeading(siteName)}
<main class="container"> <main class="container">
<section class="block">${sectionLinks}${externalLinks}</section> <section class="block">${sectionLinks}${externalLinks}</section>
@@ -232,97 +290,131 @@ const renderHome = (externalLinks, sectionLinks, recentLinks) => pageShell('', `
<h2>Recent</h2> <h2>Recent</h2>
<ul>${recentLinks || '<li>Nothing to show yet.</li>'}</ul> <ul>${recentLinks || '<li>Nothing to show yet.</li>'}</ul>
</section> </section>
</main>`); </main>`,
)
const renderNotesTable = (pages, directory) => { const renderNotesTable = (pages, directory) => {
const rows = pages.map((page) => ` const rows = pages
.map(
(page) => `
<tr> <tr>
<td><a href="/${directory}/${page.name}/">${escapeHtml(page.title)}</a></td> <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> <td>${page.date ? `<time class="table-last-updated" datetime="${escapeHtml(page.date)}" data-value="${escapeHtml(page.date)}">${escapeHtml(page.date)}</time>` : ''}</td>
</tr>`).join(''); </tr>`,
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>`; )
}; .join('')
return `<table class="${directory}-table"><caption class="visually-hidden">${escapeHtml(directory)} pages</caption><tbody>${rows}</tbody></table>`
}
const renderPostsList = (pages, directory) => { const renderPostsList = (pages, directory) => {
const items = pages.map((page) => ` const items = pages
.map(
(page) => `
<li> <li>
<a href="/${directory}/${page.name}/">${escapeHtml(page.title)}</a> <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>` : ''} ${page.date ? `<time datetime="${escapeHtml(page.date)}" data-value="${escapeHtml(page.date)}">${escapeHtml(page.date)}</time>` : ''}
</li>`).join(''); </li>`,
return `<ul class="posts-list">${items}</ul>`; )
}; .join('')
return `<ul class="posts-list">${items}</ul>`
}
const renderEmptySection = () => ` const renderEmptySection = () => `
<div class="text center"> <div class="text center">
<p><strong>crickets</strong></p> <p>**crickets**</p>
<p>Nothing to list...</p> <p>Nothing to list...</p>
<a class="link button back" href="/">Go Home</a> <a class="link button back" href="/">Go Home</a>
</div>`; </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 renderSection = (directory, pages) => {
const title = directory[0].toUpperCase() + directory.slice(1); const title = directory[0].toUpperCase() + directory.slice(1)
const listing = pages.length === 0 const listing =
pages.length === 0
? renderEmptySection() ? renderEmptySection()
: directory === 'posts' : directory === 'posts'
? renderPostsList(pages, directory) ? renderPostsList(pages, directory)
: renderNotesTable(pages, directory); : renderNotesTable(pages, directory)
return pageShell( return pageShell(
title, title,
`${titleHeading(title)}<main class="container"><section class="block">${listing}</section></main>`, `${titleHeading(title)}<main class="container"><section class="block">${listing}</section></main>`,
`/${directory}`, `/${directory}`,
title, title,
); )
}; }
const renderArticle = (directory, page, navigationPath = `/${directory}/${page.name}`) => { const renderArticle = (directory, page, navigationPath = `/${directory}/${page.name}`) => {
const updated = page.date const updated = page.date
? `<time class="last-updated" datetime="${escapeHtml(page.date)}" data-value="${escapeHtml(page.date)}">Last updated: ${escapeHtml(page.date)}</time>` ? `<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 heading = titleHeading(page.title, `${directory}-heading`)
const tableOfContents = toc(page.source); const tableOfContents = toc(page.source)
const contents = tableOfContents const contents = tableOfContents
? `<section class="block toc"><details open><summary>Contents</summary>${tableOfContents}</details></section><hr>` ? `<section class="block toc"><details open><summary>Contents</summary>${tableOfContents}</details></section><hr>`
: ''; : ''
const article = `<section class="block">${renderMarkdown(page.source)}</section>`; const article = `<section class="block">${renderMarkdown(page.source)}</section>`
const body = `${heading}<main class="article-page ${directory}-page container">${updated}${contents}${article}</main>`; const body = `${heading}<main class="article-page ${directory}-page container">${updated}${contents}${article}</main>`
return pageShell(page.title, body, navigationPath, page.title); return pageShell(page.title, body, navigationPath, page.title)
}; }
if (process.argv.includes('--clean')) await rm(output, { recursive: true, force: true }); if (process.argv.includes('--clean')) await rm(output, { recursive: true, force: true })
await mkdir(output, { recursive: true }); await mkdir(output, { recursive: true })
const sections = Object.fromEntries(await Promise.all(markdownDirs.map(async (dir) => [dir, await readPages(dir)]))); 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]) => `<a class="button blue link extern" href="${escapeHtml(href)}">${escapeHtml(text)}</a>`)
.join('')
const sectionLinks = markdownDirs
.map((dir) => `<a class="button link" href="/${dir}/">${dir[0].toUpperCase() + dir.slice(1)}</a>`)
.join('')
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 = markdownDirs.map((dir) => `<a class="button link" href="/${dir}/">${dir[0].toUpperCase() + dir.slice(1)}</a>`).join('');
const standaloneLinks = standalonePages const standaloneLinks = standalonePages
.filter((page) => page.path !== 'licenses') .filter((page) => page.path !== 'licenses')
.map((page) => `<a class="button link" href="/${page.path}/">${page.title}</a>`) .map((page) => `<a class="button link" href="/${page.path}/">${page.title}</a>`)
.join(''); .join('')
const recentPages = markdownDirs.flatMap((dir) => sections[dir].map((page) => ({ ...page, dir })))
const recentPages = markdownDirs
.flatMap((dir) => sections[dir].map((page) => ({ ...page, dir })))
.sort((a, b) => b.date.localeCompare(a.date)) .sort((a, b) => b.date.localeCompare(a.date))
.slice(0, 5); .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)); 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 markdownDirs) { for (const dir of markdownDirs) {
const pages = sections[dir]; const pages = sections[dir]
await writePage(dir, renderSection(dir, pages)); await writePage(dir, renderSection(dir, pages))
for (const page of pages) { for (const page of pages) {
await writePage(join(dir, page.name), renderArticle(dir, page)); await writePage(join(dir, page.name), renderArticle(dir, page))
} }
} }
for (const page of standalonePages) { for (const page of standalonePages) {
const source = await readFile(page.source, 'utf8'); const source = await readFile(page.source, 'utf8')
const generatedPage = { const generatedPage = {
name: page.path, name: page.path,
title: page.title, title: page.title,
source, source,
date: await dateFor(page.source), date: await dateFor(page.source),
}; }
await writePage(page.path, renderArticle(page.path, generatedPage, '/')); await writePage(page.path, renderArticle(page.path, generatedPage, '/'))
} }
+38 -17
View File
@@ -1,24 +1,45 @@
const voidElements = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr']); const voidElements = new Set([
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'link',
'meta',
'source',
'track',
'wbr',
])
export const raw = (html) => ({ type: 'raw', html }); export const raw = (html) => ({ type: 'raw', html })
export const h = (tag, attributes = {}, children = []) => ({ export const h = (tag, attributes = {}, children = []) => ({
type: 'element', tag, attributes, children: Array.isArray(children) ? children : [children], type: 'element',
}); tag,
attributes,
children: Array.isArray(children) ? children : [children],
})
export const escapeHtml = (value) => String(value) export const escapeHtml = (value) =>
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;') String(value)
.replaceAll('"', '&quot;').replaceAll("'", '&#39;'); .replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
export const render = (node) => { export const render = (node) => {
if (node == null || node === false) return ''; if (node == null || node === false) return ''
if (Array.isArray(node)) return node.map(render).join(''); if (Array.isArray(node)) return node.map(render).join('')
if (typeof node === 'string' || typeof node === 'number') return escapeHtml(node); if (typeof node === 'string' || typeof node === 'number') return escapeHtml(node)
if (node.type === 'raw') return node.html; if (node.type === 'raw') return node.html
const attributes = Object.entries(node.attributes) const attributes = Object.entries(node.attributes)
.filter(([, value]) => value !== false && value != null) .filter(([, value]) => value !== false && value != null)
.map(([name, value]) => value === true ? name : `${name}="${escapeHtml(value)}"`) .map(([name, value]) => (value === true ? name : `${name}="${escapeHtml(value)}"`))
.join(' '); .join(' ')
const opening = attributes ? `<${node.tag} ${attributes}>` : `<${node.tag}>`; const opening = attributes ? `<${node.tag} ${attributes}>` : `<${node.tag}>`
if (voidElements.has(node.tag)) return opening; if (voidElements.has(node.tag)) return opening
return `${opening}${node.children.map(render).join('')}</${node.tag}>`; return `${opening}${node.children.map(render).join('')}</${node.tag}>`
}; }
+2 -2
View File
@@ -2,7 +2,6 @@
"name": "paulw-xyz", "name": "paulw-xyz",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "bun@1.3.8",
"scripts": { "scripts": {
"generate": "node generator.js", "generate": "node generator.js",
"check": "node generator.js --clean && node check.js", "check": "node generator.js --clean && node check.js",
@@ -20,5 +19,6 @@
}, },
"devDependencies": { "devDependencies": {
"vite": "^7.0.0" "vite": "^7.0.0"
} },
"packageManager": "bun@1.3.8"
} }
+52 -71
View File
@@ -1,6 +1,7 @@
:root { :root {
--main-background-color: #0d1117; --main-background-color: #0d1117;
--main-border-color: #555555; --main-border-color: #555555;
--secondary-border-color: #30363d;
--link-color: #009dff; --link-color: #009dff;
--primary-green: #099945; --primary-green: #099945;
--secondary-green: #1a3a15; --secondary-green: #1a3a15;
@@ -45,7 +46,7 @@ body {
background-size: cover; background-size: cover;
} }
[tabindex="-1"]:focus { [tabindex='-1']:focus {
outline: 0 !important; outline: 0 !important;
} }
@@ -161,72 +162,54 @@ code {
} }
table { table {
margin: 1rem auto; width: max-content;
width: 100%; margin: 1rem 0;
border-collapse: collapse; border-collapse: collapse;
} }
.notes-table { table th,
overflow: hidden; table td {
border-radius: 0.5rem; border: 0;
border: 1px solid var(--main-border-color);
border-spacing: 0;
box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.2);
} }
.notes-table thead + tbody tr { table th + th,
border-radius: 0; table td + td {
border-left: 1px solid var(--secondary-border-color);
}
table tbody tr:nth-child(even) {
background-color: var(--table-even-color);
}
table tbody tr:nth-child(odd) {
background-color: var(--table-odd-color);
}
.notes-table {
width: 100%;
border: 1px solid var(--main-border-color);
}
.notes-table th + th,
.notes-table td + td,
.posts-table th + th,
.posts-table td + td {
border-left: 0;
} }
.notes-table thead { .notes-table thead {
background: var(--secondary-green); background: var(--secondary-green);
z-index: -1;
}
.notes-table tbody,
.notes-table tr:last-of-type {
border-bottom-left-radius: 0.5rem;
border-bottom-right-radius: 0.5rem;
}
.notes-table tbody tr:nth-of-type(2n) {
background-color: var(--table-even-color);
}
.notes-table tbody tr:nth-of-type(2n+1) {
background-color: var(--table-odd-color);
} }
.notes-table tbody tr { .notes-table tbody tr {
border-left: 3px solid transparent;
transition: 100ms ease-in-out all; transition: 100ms ease-in-out all;
} }
.notes-table tbody tr:hover {
background: var(--tertiary-blue);
border-left-color: var(--link-color);
transform: translateX(0.15rem);
}
.notes-table tbody tr td:first-child { .notes-table tbody tr td:first-child {
width: 75%; width: 75%;
font-size: 1.1rem; font-size: 1.1rem;
} }
.notes-table tbody tr td:first-child a {
text-decoration: none;
}
.notes-table tbody tr td:first-child a::after {
content: ' →';
opacity: 0;
transition: 100ms ease-in-out all;
}
.notes-table tbody tr:hover td:first-child a::after {
opacity: 1;
}
.notes-table tbody tr td:last-child { .notes-table tbody tr td:last-child {
color: #bbbbbb; color: #bbbbbb;
font-family: var(--monospace-font); font-family: var(--monospace-font);
@@ -264,7 +247,7 @@ table {
top: 0; top: 0;
bottom: 0; bottom: 0;
left: 7.75rem; left: 7.75rem;
border-left: 1px solid var(--main-border-color); border-left: 1px solid var(--secondary-border-color);
} }
.recent li { .recent li {
@@ -319,7 +302,7 @@ table {
table thead tr th, table thead tr th,
table tbody tr td { table tbody tr td {
padding: .25rem 0.75rem; padding: 0.25rem 0.75rem;
} }
.table-last-updated { .table-last-updated {
@@ -497,7 +480,7 @@ ul li {
border: 1px solid var(--main-border-color); border: 1px solid var(--main-border-color);
background-color: var(--secondary-green); background-color: var(--secondary-green);
font-family: var(--sans-serif-font); font-family: var(--sans-serif-font);
font-size: 0.80rem; font-size: 0.8rem;
font-weight: bold; font-weight: bold;
line-height: 1.5; line-height: 1.5;
} }
@@ -507,7 +490,7 @@ ul li {
min-width: 5.5rem; min-width: 5.5rem;
padding: 0.25rem 0.75rem; padding: 0.25rem 0.75rem;
border: 0; border: 0;
border-left: 1px solid var(--main-border-color); border-left: 1px solid var(--secondary-border-color);
color: #ffffff; color: #ffffff;
background: transparent; background: transparent;
font: inherit; font: inherit;
@@ -542,16 +525,14 @@ ul li {
} }
.article-page table { .article-page table {
display: table;
width: max-content; width: max-content;
min-width: 100%;
border: 1px solid var(--main-border-color); border: 1px solid var(--main-border-color);
border-radius: 0.25rem;
white-space: nowrap; white-space: nowrap;
} }
.table-scroll { .table-scroll {
width: 100%; width: 100%;
max-width: 100%;
overflow-x: auto; overflow-x: auto;
} }
@@ -565,14 +546,6 @@ ul li {
text-align: left; text-align: left;
} }
.article-page table tbody tr:nth-child(even) {
background-color: var(--table-even-color);
}
.article-page table tbody tr:nth-child(odd) {
background-color: var(--table-odd-color);
}
.margin-toggle { .margin-toggle {
display: none; display: none;
} }
@@ -583,7 +556,7 @@ ul li {
margin-right: 0; margin-right: 0;
margin-top: 0.25rem; margin-top: 0.25rem;
color: #bbbbbb; color: #bbbbbb;
font-size: 0.90rem; font-size: 0.9rem;
line-height: 1.4; line-height: 1.4;
} }
@@ -743,10 +716,12 @@ ul li {
width: 1em; width: 1em;
height: 1em; height: 1em;
margin-left: 0.3rem; margin-left: 0.3rem;
vertical-align: -0.125em; vertical-align: -.175rem;
background-color: currentColor; background-color: currentColor;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5 12h14m-6-6 6 6-6 6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / contain no-repeat; -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5 12h14m-6-6 6 6-6 6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5 12h14m-6-6 6 6-6 6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / contain no-repeat; center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5 12h14m-6-6 6 6-6 6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")
center / contain no-repeat;
transition: transform 50ms ease-in-out; transition: transform 50ms ease-in-out;
} }
@@ -759,8 +734,12 @@ ul li {
transform: translateX(0.25rem) scale(1.3); transform: translateX(0.25rem) scale(1.3);
} }
.button.link.extern::after {
transform: rotateZ(-45deg);
}
.button.link.extern:hover::after { .button.link.extern:hover::after {
transform: rotateZ(-45deg) scale(1.5); transform: rotateZ(0) translateX(0.25rem) scale(1.3);
} }
.button.link.back::before { .button.link.back::before {
@@ -769,10 +748,12 @@ ul li {
width: 1em; width: 1em;
height: 1em; height: 1em;
margin-right: 0.3rem; margin-right: 0.3rem;
vertical-align: -0.125em; vertical-align: -0.175em;
background-color: currentColor; background-color: currentColor;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5 12h14m-6-6 6 6-6 6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / contain no-repeat; -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5 12h14m-6-6 6 6-6 6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5 12h14m-6-6 6 6-6 6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / contain no-repeat; center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5 12h14m-6-6 6 6-6 6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")
center / contain no-repeat;
transform: rotate(180deg); transform: rotate(180deg);
transition: transform 100ms ease-in-out; transition: transform 100ms ease-in-out;
} }
@@ -793,7 +774,7 @@ ul li {
} }
.page-heading .title { .page-heading .title {
border-bottom: 1px solid #FFFFFF; border-bottom: 1px solid #ffffff;
max-width: 95%; max-width: 95%;
margin: auto; margin: auto;
} }
+62 -51
View File
@@ -1,22 +1,24 @@
import Prism from 'prismjs'; import Prism from 'prismjs'
import './fonts.css'; import './fonts.css'
import 'prismjs/components/prism-bash'; import 'prismjs/components/prism-bash'
import 'prismjs/components/prism-ini'; import 'prismjs/components/prism-ini'
import 'prismjs/components/prism-lua'; import 'prismjs/components/prism-lua'
import 'prismjs/plugins/line-numbers/prism-line-numbers.js'; import 'prismjs/plugins/line-numbers/prism-line-numbers.js'
import 'prismjs/themes/prism-tomorrow.css'; import 'prismjs/themes/prism-tomorrow.css'
import 'prismjs/plugins/line-numbers/prism-line-numbers.css'; import 'prismjs/plugins/line-numbers/prism-line-numbers.css'
import './prism.css'; import './prism.css'
const absoluteDateFormatter = new Intl.DateTimeFormat(undefined, { const absoluteDateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: 'long', dateStyle: 'long',
timeStyle: 'short', timeStyle: 'short',
}); })
const tooltipDateFormatter = new Intl.DateTimeFormat(undefined, { const tooltipDateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: 'long', dateStyle: 'long',
timeStyle: 'long', timeStyle: 'long',
}); })
const relativeDateFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }); const relativeDateFormatter = new Intl.RelativeTimeFormat(undefined, {
numeric: 'auto',
})
const relativeTimeUnits = [ const relativeTimeUnits = [
[31536000, 'year'], [31536000, 'year'],
[2592000, 'month'], [2592000, 'month'],
@@ -24,66 +26,75 @@ const relativeTimeUnits = [
[86400, 'day'], [86400, 'day'],
[3600, 'hour'], [3600, 'hour'],
[60, 'minute'], [60, 'minute'],
]; ]
document.querySelectorAll('pre > code').forEach((code) => { document.querySelectorAll('pre > code').forEach((code) => {
if (![...code.classList].some((name) => name.startsWith('language-'))) { if (![...code.classList].some((name) => name.startsWith('language-'))) {
code.classList.add('language-plain'); code.classList.add('language-plain')
} }
code.closest('pre').classList.add('line-numbers'); code.closest('pre').classList.add('line-numbers')
}); })
Prism.highlightAll(); Prism.highlightAll()
const sidebarTocMedia = window.matchMedia('(min-width: 1400px)')
const syncSidebarToc = () => {
if (!sidebarTocMedia.matches) return
document.querySelectorAll('.article-page .toc details').forEach((details) => {
details.open = true
})
}
syncSidebarToc()
sidebarTocMedia.addEventListener('change', syncSidebarToc)
const copyText = async (text) => { const copyText = async (text) => {
if (navigator.clipboard?.writeText) { if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text)
return; return
} }
const textarea = document.createElement('textarea'); const textarea = document.createElement('textarea')
textarea.value = text; textarea.value = text
textarea.setAttribute('readonly', ''); textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'; textarea.style.position = 'fixed'
textarea.style.opacity = '0'; textarea.style.opacity = '0'
document.body.append(textarea); document.body.append(textarea)
textarea.select(); textarea.select()
const copied = document.execCommand('copy'); const copied = document.execCommand('copy')
textarea.remove(); textarea.remove()
if (!copied) throw new Error('Copy command failed'); if (!copied) throw new Error('Copy command failed')
}; }
document.querySelectorAll('.copy-code').forEach((button) => { document.querySelectorAll('.copy-code').forEach((button) => {
button.addEventListener('click', async () => { button.addEventListener('click', async () => {
const code = button.closest('.code-block')?.querySelector('code'); const code = button.closest('.code-block')?.querySelector('code')
if (!code) return; if (!code) return
try { try {
await copyText(code.textContent); await copyText(code.textContent)
button.textContent = 'Copied'; button.textContent = 'Copied'
} catch { } catch {
button.textContent = 'Copy failed'; button.textContent = 'Copy failed'
} }
window.setTimeout(() => { window.setTimeout(() => {
button.textContent = 'Copy'; button.textContent = 'Copy'
}, 1500); }, 1500)
}); })
}); })
document.querySelectorAll('[data-value]').forEach((element) => { document.querySelectorAll('[data-value]').forEach((element) => {
const date = new Date(element.dataset.value); const date = new Date(element.dataset.value)
if (Number.isNaN(date.valueOf())) return; if (Number.isNaN(date.valueOf())) return
if (element.classList.contains('recent-date')) { if (element.classList.contains('recent-date')) {
element.dataset.tooltip = tooltipDateFormatter.format(date); element.dataset.tooltip = tooltipDateFormatter.format(date)
const seconds = Math.round((date.valueOf() - Date.now()) / 1000); const seconds = Math.round((date.valueOf() - Date.now()) / 1000)
const unit = relativeTimeUnits.find(([size]) => Math.abs(seconds) >= size) ?? [1, 'second']; const unit = relativeTimeUnits.find(([size]) => Math.abs(seconds) >= size) ?? [1, 'second']
element.textContent = relativeDateFormatter.format(Math.round(seconds / unit[0]), unit[1]); element.textContent = relativeDateFormatter.format(Math.round(seconds / unit[0]), unit[1])
return; return
} }
const formatted = absoluteDateFormatter.format(date); const formatted = absoluteDateFormatter.format(date)
element.textContent = element.classList.contains('last-updated') element.textContent = element.classList.contains('last-updated') ? `Last updated: ${formatted}` : formatted
? `Last updated: ${formatted}` })
: formatted;
});
+44 -41
View File
@@ -1,48 +1,48 @@
import fs from 'node:fs'; import fs from 'node:fs'
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process'
import path from 'node:path'; import path from 'node:path'
import { defineConfig } from 'vite'; import { defineConfig } from 'vite'
function generatedPages(directory) { function generatedPages(directory) {
const inputs = {}; const inputs = {}
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const filePath = path.join(directory, entry.name); const filePath = path.join(directory, entry.name)
if (entry.isDirectory()) Object.assign(inputs, generatedPages(filePath)); if (entry.isDirectory()) Object.assign(inputs, generatedPages(filePath))
else if (entry.name === 'index.html') { else if (entry.name === 'index.html') {
const relativePath = path.relative('.generated', filePath); const relativePath = path.relative('.generated', filePath)
const key = relativePath.endsWith('/index.html') const key = relativePath.endsWith('/index.html') ? relativePath.slice(0, -'/index.html'.length) : relativePath
? relativePath.slice(0, -'/index.html'.length) inputs[key] = path.resolve(filePath)
: relativePath; } else if (entry.name === '404.html' && directory === '.generated') {
inputs[key] = path.resolve(filePath); inputs['404'] = path.resolve(filePath)
} }
} }
return inputs; return inputs
} }
function contentGenerator() { function contentGenerator() {
let timer; let timer
let generator; let generator
let rerun = false; let rerun = false
const generate = (server) => { const generate = (server) => {
if (generator) { if (generator) {
rerun = true; rerun = true
return; return
} }
generator = spawn(process.execPath, ['generator.js'], { stdio: 'inherit' }); generator = spawn(process.execPath, ['generator.js'], { stdio: 'inherit' })
generator.on('error', (error) => { generator.on('error', (error) => {
console.error(`Content generation failed: ${error.message}`); console.error(`Content generation failed: ${error.message}`)
}); })
generator.on('close', (code) => { generator.on('close', (code) => {
generator = undefined; generator = undefined
if (code === 0) server.ws.send({ type: 'full-reload' }); if (code === 0) server.ws.send({ type: 'full-reload' })
if (rerun) { if (rerun) {
rerun = false; rerun = false
generate(server); generate(server)
}
})
} }
});
};
return { return {
name: 'content-generator', name: 'content-generator',
@@ -55,22 +55,25 @@ function contentGenerator() {
path.resolve('external.json'), path.resolve('external.json'),
path.resolve('generator.js'), path.resolve('generator.js'),
path.resolve('html.js'), path.resolve('html.js'),
]; ]
server.watcher.add(sources); server.watcher.add(sources)
server.watcher.on('all', (event, file) => { server.watcher.on('all', (event, file) => {
if (!['add', 'change', 'unlink'].includes(event)) return; if (!['add', 'change', 'unlink'].includes(event)) return
const isMarkdown = file.endsWith('.md') && (file.startsWith(`${path.resolve('notes')}/`) || file.startsWith(`${path.resolve('posts')}/`)); const isMarkdown =
const isExternal = file === path.resolve('external.json'); file.endsWith('.md') &&
const isStandaloneMarkdown = file === resolve('README.md') || file === path.resolve('THIRD_PARTY_LICENSES.md'); (file.startsWith(`${path.resolve('notes')}/`) || file.startsWith(`${path.resolve('posts')}/`))
const isGenerator = file === path.resolve('generator.js') || file === path.resolve('html.js'); const isExternal = file === path.resolve('external.json')
if (!isMarkdown && !isStandaloneMarkdown && !isExternal && !isGenerator) return; const isStandaloneMarkdown =
clearTimeout(timer); file === path.resolve('README.md') || file === path.resolve('THIRD_PARTY_LICENSES.md')
const isGenerator = file === path.resolve('generator.js') || file === path.resolve('html.js')
if (!isMarkdown && !isStandaloneMarkdown && !isExternal && !isGenerator) return
clearTimeout(timer)
timer = setTimeout(() => { timer = setTimeout(() => {
generate(server); generate(server)
}, 100); }, 100)
}); })
}, },
}; }
} }
export default defineConfig({ export default defineConfig({
@@ -94,4 +97,4 @@ export default defineConfig({
input: generatedPages('.generated'), input: generatedPages('.generated'),
}, },
}, },
}); })