diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..33b9477 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -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/" diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..0e7e01f --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,7 @@ +{ + "printWidth": 120, + "semi": false, + "useTabs": false, + "singleQuote": true, + "ignorePatterns": ["**/*.md"] +} diff --git a/check.js b/check.js index 28a1e7f..30b9ea7 100644 --- a/check.js +++ b/check.js @@ -1,67 +1,79 @@ -import { readdir, readFile, stat } from 'node:fs/promises'; -import { join } from 'node:path'; +import { readdir, readFile, stat } from 'node:fs/promises' +import { join } from 'node:path' -const generatedRoot = '.generated'; +const generatedRoot = '.generated' const walk = async (directory) => { - const files = []; + const files = [] for (const entry of await readdir(directory, { withFileTypes: true })) { - const path = join(directory, entry.name); - if (entry.isDirectory()) files.push(...await walk(path)); - else files.push(path); + const path = join(directory, entry.name) + if (entry.isDirectory()) files.push(...(await walk(path))) + else files.push(path) } - return files; -}; + return files +} const exists = async (path) => { try { - await stat(path); - return true; + await stat(path) + return true } catch { - return false; + return false } -}; +} -const errors = []; -const htmlFiles = (await walk(generatedRoot)).filter((file) => file.endsWith('.html')); -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 prismLanguageAliases = { sh: 'bash', shell: 'bash' }; +const errors = [] +const htmlFiles = (await walk(generatedRoot)).filter((file) => file.endsWith('.html')) +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 prismLanguageAliases = { sh: 'bash', shell: 'bash' } for (const file of htmlFiles) { - const html = await readFile(file, 'utf8'); - const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]); - const duplicateIds = ids.filter((id, index) => ids.indexOf(id) !== index); - if (duplicateIds.length > 0) errors.push(`${file}: duplicate IDs: ${[...new Set(duplicateIds)].join(', ')}`); - if (!html.includes(' element`); + const html = await readFile(file, 'utf8') + const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]) + const duplicateIds = ids.filter((id, index) => ids.indexOf(id) !== index) + if (duplicateIds.length > 0) errors.push(`${file}: duplicate IDs: ${[...new Set(duplicateIds)].join(', ')}`) + if (!html.includes(' element`) 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)) { - if (href === '/') continue; - const pathname = href.split(/[?#]/, 1)[0]; - let target; - if (pathname.startsWith('/src/')) target = `.${pathname}`; - else if (pathname.startsWith('/assets/') || pathname === '/favicon.ico') target = `public${pathname}`; - else target = pathname.endsWith('/') - ? join(generatedRoot, pathname, 'index.html') - : join(generatedRoot, pathname); - if (!await exists(target)) errors.push(`${file}: broken internal link ${href}`); + if (href === '/') continue + const pathname = href.split(/[?#]/, 1)[0] + let target + if (pathname.startsWith('/src/')) target = `.${pathname}` + else if (pathname.startsWith('/assets/') || pathname === '/favicon.ico') target = `public${pathname}` + else target = pathname.endsWith('/') ? join(generatedRoot, pathname, 'index.html') : join(generatedRoot, pathname) + if (!(await exists(target))) errors.push(`${file}: broken internal link ${href}`) } for (const [, language] of html.matchAll(/class="language-([\w-]+)"/g)) { - if (builtInPrismLanguages.has(language)) continue; - const component = prismLanguageAliases[language] ?? language; - if (!clientSource.includes(`prism-${component}`)) errors.push(`${file}: Prism grammar is not imported for ${language}`); + if (builtInPrismLanguages.has(language)) continue + const component = prismLanguageAliases[language] ?? language + if (!clientSource.includes(`prism-${component}`)) + errors.push(`${file}: Prism grammar is not imported for ${language}`) } } if (errors.length > 0) { - console.error(errors.join('\n')); - process.exitCode = 1; + console.error(errors.join('\n')) + process.exitCode = 1 } else { - console.log(`Checked ${htmlFiles.length} generated pages.`); + console.log(`Checked ${htmlFiles.length} generated pages.`) } diff --git a/clean.js b/clean.js index 13c071a..b433cfb 100644 --- a/clean.js +++ b/clean.js @@ -1,6 +1,3 @@ -import { rm } from 'node:fs/promises'; +import { rm } from 'node:fs/promises' -await Promise.all([ - rm('.generated', { recursive: true, force: true }), - rm('dist', { recursive: true, force: true }), -]); +await Promise.all([rm('.generated', { recursive: true, force: true }), rm('dist', { recursive: true, force: true })]) diff --git a/external.json b/external.json index 8ce844c..5fd17bd 100644 --- a/external.json +++ b/external.json @@ -1,4 +1,4 @@ { - "Git": "https://git.paulw.xyz/xyz", - "Twitter/X": "https://x.com/paulw_xyz" + "Git": "https://git.paulw.xyz/xyz", + "Twitter/X": "https://x.com/paulw_xyz" } diff --git a/generator.js b/generator.js index 65eca3d..9011239 100644 --- a/generator.js +++ b/generator.js @@ -1,16 +1,18 @@ -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'; +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 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', @@ -18,213 +20,269 @@ const languageLabels = { lua: 'Lua', sh: 'Shell', bash: 'Shell', -}; +} const slug = (value) => { - let result = ''; - let dash = false; + 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; + 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(); + 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(); + 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 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 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]); + 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 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; + 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 ''; } -}; + try { + return (await stat(file)).mtime.toISOString() + } catch { + return '' + } +} const readPages = async (dir) => { - const entries = await readdir(dir, { withFileTypes: true }); + 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); -}; + .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; +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), - }; + 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; + return undefined + }, + renderer({ tokens }) { + sidenoteNumber += 1 + const id = `sidenote-${sidenoteNumber}` + const content = this.parser.parseInline(tokens) + return `${content}` + }, }, - 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`; + 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 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 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 = ''; + 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; } + const end = text.indexOf(']', index + 2) + if (end >= 0) { + index = end + 1 + continue + } } - result += text[index++]; + 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'); + 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; + 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; + 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; -}; + 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(); + 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); + 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 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 writePage = async (path, html) => { - const directory = join(output, path); - await mkdir(directory, { recursive: true }); - await writeFile(join(directory, 'index.html'), 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('', ` +const renderHome = (externalLinks, sectionLinks, recentLinks) => + pageShell( + '', + ` ${titleHeading(siteName)}
${sectionLinks}${externalLinks}
@@ -232,97 +290,131 @@ const renderHome = (externalLinks, sectionLinks, recentLinks) => pageShell('', `

Recent

    ${recentLinks || '
  • Nothing to show yet.
  • '}
-
`); + `, + ) const renderNotesTable = (pages, directory) => { - const rows = pages.map((page) => ` + const rows = pages + .map( + (page) => ` ${escapeHtml(page.title)} ${page.date ? `` : ''} - `).join(''); - return `${rows}
${escapeHtml(directory)} pages
PageLast updated
`; -}; + `, + ) + .join('') + return `${rows}
${escapeHtml(directory)} pages
` +} const renderPostsList = (pages, directory) => { - const items = pages.map((page) => ` + const items = pages + .map( + (page) => `
  • ${escapeHtml(page.title)} ${page.date ? `` : ''} -
  • `).join(''); - return ``; -}; + `, + ) + .join('') + return `` +} const renderEmptySection = () => `
    -

    crickets

    +

    **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); + 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 tableOfContents = toc(page.source); + : '' + 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}
    `; + : '' + const article = `
    ${renderMarkdown(page.source)}
    ` + const body = `${heading}
    ${updated}${contents}${article}
    ` - 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 }); -await mkdir(output, { recursive: true }); -const sections = Object.fromEntries(await Promise.all(markdownDirs.map(async (dir) => [dir, await readPages(dir)]))); +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 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 }))) + .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)); + .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)); + const pages = sections[dir] + await writePage(dir, renderSection(dir, 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) { - const source = await readFile(page.source, 'utf8'); + 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, '/')); + } + await writePage(page.path, renderArticle(page.path, generatedPage, '/')) } diff --git a/html.js b/html.js index 59a4f9e..3ea3a7f 100644 --- a/html.js +++ b/html.js @@ -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 = []) => ({ - 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) - .replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') - .replaceAll('"', '"').replaceAll("'", '''); +export const escapeHtml = (value) => + String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') export const render = (node) => { - if (node == null || node === false) return ''; - if (Array.isArray(node)) return node.map(render).join(''); - if (typeof node === 'string' || typeof node === 'number') return escapeHtml(node); - if (node.type === 'raw') return node.html; + if (node == null || node === false) return '' + if (Array.isArray(node)) return node.map(render).join('') + if (typeof node === 'string' || typeof node === 'number') return escapeHtml(node) + if (node.type === 'raw') return node.html const attributes = Object.entries(node.attributes) .filter(([, value]) => value !== false && value != null) - .map(([name, value]) => value === true ? name : `${name}="${escapeHtml(value)}"`) - .join(' '); - const opening = attributes ? `<${node.tag} ${attributes}>` : `<${node.tag}>`; - if (voidElements.has(node.tag)) return opening; - return `${opening}${node.children.map(render).join('')}`; -}; + .map(([name, value]) => (value === true ? name : `${name}="${escapeHtml(value)}"`)) + .join(' ') + const opening = attributes ? `<${node.tag} ${attributes}>` : `<${node.tag}>` + if (voidElements.has(node.tag)) return opening + return `${opening}${node.children.map(render).join('')}` +} diff --git a/package.json b/package.json index e800a5b..48c60b6 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,6 @@ "name": "paulw-xyz", "private": true, "type": "module", - "packageManager": "bun@1.3.8", "scripts": { "generate": "node generator.js", "check": "node generator.js --clean && node check.js", @@ -20,5 +19,6 @@ }, "devDependencies": { "vite": "^7.0.0" - } + }, + "packageManager": "bun@1.3.8" } diff --git a/src/global.css b/src/global.css index cf1e5fc..eff39a7 100644 --- a/src/global.css +++ b/src/global.css @@ -1,22 +1,23 @@ :root { - --main-background-color: #0d1117; - --main-border-color: #555555; - --link-color: #009dff; - --primary-green: #099945; - --secondary-green: #1a3a15; - --tertiary-green: #0f200c; - --primary-blue: #0a82b1; - --secondary-blue: #05455f; - --tertiary-blue: #05232f; - --table-odd-color: rgba(255, 255, 255, 0.05); - --table-even-color: rgba(255, 255, 255, 0.025); - --serif-font: 'Cardo', Georgia, 'Times New Roman', Times, serif; - --sans-serif-font: 'Inter', 'Helvetica Neue', Helvetica, Arial, sans-serif; - --monospace-font: 'Iosevka', 'SFMono-Regular', Consolas, monospace; + --main-background-color: #0d1117; + --main-border-color: #555555; + --secondary-border-color: #30363d; + --link-color: #009dff; + --primary-green: #099945; + --secondary-green: #1a3a15; + --tertiary-green: #0f200c; + --primary-blue: #0a82b1; + --secondary-blue: #05455f; + --tertiary-blue: #05232f; + --table-odd-color: rgba(255, 255, 255, 0.05); + --table-even-color: rgba(255, 255, 255, 0.025); + --serif-font: 'Cardo', Georgia, 'Times New Roman', Times, serif; + --sans-serif-font: 'Inter', 'Helvetica Neue', Helvetica, Arial, sans-serif; + --monospace-font: 'Iosevka', 'SFMono-Regular', Consolas, monospace; } * { - box-sizing: border-box; + box-sizing: border-box; } article, @@ -29,30 +30,30 @@ hgroup, main, nav, section { - display: block; + display: block; } body { - font-family: var(--sans-serif-font); - margin: 0 0 44px; - font-size: 18px; - font-weight: 400; - line-height: 1.5; - color: #ffffff; - text-align: left; - height: 100%; - background-color: var(--main-background-color); - background-size: cover; + font-family: var(--sans-serif-font); + margin: 0 0 44px; + font-size: 18px; + font-weight: 400; + line-height: 1.5; + color: #ffffff; + text-align: left; + height: 100%; + background-color: var(--main-background-color); + background-size: cover; } -[tabindex="-1"]:focus { - outline: 0 !important; +[tabindex='-1']:focus { + outline: 0 !important; } hr { - box-sizing: content-box; - height: 0; - overflow: visible; + box-sizing: content-box; + height: 0; + overflow: visible; } h1, @@ -61,323 +62,305 @@ h3, h4, h5, h6 { - margin-top: 0; - margin-bottom: 0.5rem; + margin-top: 0; + margin-bottom: 0.5rem; } h1, .h1 { - font-size: 2.5rem; + font-size: 2.5rem; } h2, .h2 { - font-size: 2rem; + font-size: 2rem; } h3, .h3 { - font-size: 1.75rem; + font-size: 1.75rem; } h4, .h4 { - font-size: 1.5rem; + font-size: 1.5rem; } h5, .h5 { - font-size: 1.25rem; + font-size: 1.25rem; } h6, .h6 { - font-size: 1rem; + font-size: 1rem; } p { - margin-top: 0; - margin-bottom: 1rem; + margin-top: 0; + margin-bottom: 1rem; } a { - color: var(--link-color); - text-decoration: underline; - background-color: transparent; - outline: none; + color: var(--link-color); + text-decoration: underline; + background-color: transparent; + outline: none; } a:hover { - text-decoration: underline; + text-decoration: underline; } a:focus { - text-decoration: underline dotted; + text-decoration: underline dotted; } a:focus-visible, summary:focus-visible, .sidenote-number:focus-visible { - outline: 2px solid var(--link-color); - outline-offset: 2px; + outline: 2px solid var(--link-color); + outline-offset: 2px; } .visually-hidden { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; } section { - margin: 0.5rem; + margin: 0.5rem; } pre { - width: 100%; - max-width: 100%; - overflow-x: auto; + width: 100%; + max-width: 100%; + overflow-x: auto; } :not(pre) > code { - overflow-x: auto; - max-width: 100%; - display: inline-block; - background-color: rgba(255, 255, 255, 0.1); - padding: 0.1rem 0.5rem; - vertical-align: bottom; + overflow-x: auto; + max-width: 100%; + display: inline-block; + background-color: rgba(255, 255, 255, 0.1); + padding: 0.1rem 0.5rem; + vertical-align: bottom; } pre, kbd, code { - font-family: var(--monospace-font); - font-size: 0.9rem; + font-family: var(--monospace-font); + font-size: 0.9rem; } table { - margin: 1rem auto; - width: 100%; - border-collapse: collapse; + width: max-content; + margin: 1rem 0; + border-collapse: collapse; +} + +table th, +table td { + border: 0; +} + +table th + th, +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 { - overflow: hidden; - border-radius: 0.5rem; - border: 1px solid var(--main-border-color); - border-spacing: 0; - box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.2); + width: 100%; + border: 1px solid var(--main-border-color); } -.notes-table thead + tbody tr { - border-radius: 0; +.notes-table th + th, +.notes-table td + td, +.posts-table th + th, +.posts-table td + td { + border-left: 0; } .notes-table thead { - 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); + background: var(--secondary-green); } .notes-table tbody tr { - border-left: 3px solid transparent; - 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); + transition: 100ms ease-in-out all; } .notes-table tbody tr td:first-child { - width: 75%; - 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; + width: 75%; + font-size: 1.1rem; } .notes-table tbody tr td:last-child { - color: #bbbbbb; - font-family: var(--monospace-font); - font-size: 0.75rem; - text-align: right; - white-space: nowrap; + color: #bbbbbb; + font-family: var(--monospace-font); + font-size: 0.75rem; + text-align: right; + white-space: nowrap; } .posts-list { - margin: 1rem 0; - padding-left: 1.5rem; - font-family: var(--serif-font); + margin: 1rem 0; + padding-left: 1.5rem; + font-family: var(--serif-font); } .posts-list li { - padding: 0.2rem 0; + padding: 0.2rem 0; } .posts-list time { - margin-left: 1rem; - font-size: 0.85rem; - font-style: italic; + margin-left: 1rem; + font-size: 0.85rem; + font-style: italic; } .recent ul { - position: relative; - margin: 0; - padding-left: 0; - list-style: none; + position: relative; + margin: 0; + padding-left: 0; + list-style: none; } .recent ul::before { - content: ''; - position: absolute; - top: 0; - bottom: 0; - left: 7.75rem; - border-left: 1px solid var(--main-border-color); + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 7.75rem; + border-left: 1px solid var(--secondary-border-color); } .recent li { - display: grid; - grid-template-columns: 7rem 1px max-content minmax(0, 1fr); - gap: 0.75rem; - align-items: baseline; - margin: 0.35rem 0; + display: grid; + grid-template-columns: 7rem 1px max-content minmax(0, 1fr); + gap: 0.75rem; + align-items: baseline; + margin: 0.35rem 0; } .recent-type { - display: inline-block; - padding: 0.05rem 0.35rem; - border: 1px solid var(--main-border-color); - color: #bbbbbb; - font-size: 0.8rem; - font-style: italic; - text-transform: capitalize; + display: inline-block; + padding: 0.05rem 0.35rem; + border: 1px solid var(--main-border-color); + color: #bbbbbb; + font-size: 0.8rem; + font-style: italic; + text-transform: capitalize; } .recent-date { - position: relative; - color: #bbbbbb; - font-size: 0.8rem; - font-style: italic; - text-align: right; + position: relative; + color: #bbbbbb; + font-size: 0.8rem; + font-style: italic; + text-align: right; } .recent-date::after { - content: attr(data-tooltip); - position: absolute; - z-index: 2; - left: 50%; - bottom: calc(100% + 0.4rem); - width: max-content; - max-width: 18rem; - padding: 0.25rem 0.5rem; - color: #ffffff; - background-color: var(--main-background-color); - border: 1px solid var(--main-border-color); - font-size: 0.75rem; - font-style: normal; - opacity: 0; - pointer-events: none; - transform: translateX(-50%); - transition: opacity 75ms ease-in; + content: attr(data-tooltip); + position: absolute; + z-index: 2; + left: 50%; + bottom: calc(100% + 0.4rem); + width: max-content; + max-width: 18rem; + padding: 0.25rem 0.5rem; + color: #ffffff; + background-color: var(--main-background-color); + border: 1px solid var(--main-border-color); + font-size: 0.75rem; + font-style: normal; + opacity: 0; + pointer-events: none; + transform: translateX(-50%); + transition: opacity 75ms ease-in; } .recent-date:hover::after { - opacity: 1; + opacity: 1; } table thead tr th, table tbody tr td { - padding: .25rem 0.75rem; + padding: 0.25rem 0.75rem; } .table-last-updated { - font-style: italic; - margin-right: 0; + font-style: italic; + margin-right: 0; } ul li { - list-style-type: square; + list-style-type: square; } .container { - margin: 0 0.5rem; - position: relative; + margin: 0 0.5rem; + position: relative; } @media screen and (min-width: 818px) { - .container { - max-width: 818px; - margin: 0 auto; - position: relative; - } + .container { + max-width: 818px; + margin: 0 auto; + position: relative; + } } .block { - display: block; - padding: 0.5rem; - max-width: 100%; - margin: 1rem 0.25rem; - border-radius: 1rem; + display: block; + padding: 0.5rem; + max-width: 100%; + margin: 1rem 0.25rem; + border-radius: 1rem; } .toc { - font-size: 0.9rem; - border-radius: 0; + font-size: 0.9rem; + border-radius: 0; } .toc summary { - cursor: pointer; - font-size: 1.1rem; - font-style: normal; - font-weight: bold; + cursor: pointer; + font-size: 1.1rem; + font-style: normal; + font-weight: bold; } .article-page { - max-width: 52rem; - font-size: 1.2rem; - line-height: 1.65; + max-width: 52rem; + font-size: 1.2rem; + line-height: 1.65; } .posts-page { - font-family: var(--serif-font); - font-size: 1.25rem; - line-height: 1.8; + font-family: var(--serif-font); + font-size: 1.25rem; + line-height: 1.8; } .posts-page p { - margin-bottom: 1.25rem; + margin-bottom: 1.25rem; } .article-page.posts-page h2, @@ -385,16 +368,16 @@ ul li { .article-page.posts-page h4, .article-page.posts-page h5, .article-page.posts-page h6 { - font-family: var(--serif-font); - font-variant-caps: small-caps; - font-feature-settings: 'smcp'; - text-transform: capitalize; - letter-spacing: 0.02em; + font-family: var(--serif-font); + font-variant-caps: small-caps; + font-feature-settings: 'smcp'; + text-transform: capitalize; + letter-spacing: 0.02em; } .article-page .block { - margin-top: 1.5rem; - margin-bottom: 1.5rem; + margin-top: 1.5rem; + margin-bottom: 1.5rem; } .article-page h2, @@ -402,9 +385,9 @@ ul li { .article-page h4, .article-page h5, .article-page h6 { - font-family: var(--sans-serif-font); - line-height: 1.25; - margin-top: 2rem; + font-family: var(--sans-serif-font); + line-height: 1.25; + margin-top: 2rem; } .posts-page h2::before, @@ -412,12 +395,12 @@ ul li { .posts-page h4::before, .posts-page h5::before, .posts-page h6::before { - content: ''; - position: absolute; - right: 100%; - color: var(--link-color); - opacity: 0; - transition: 100ms ease-in-out opacity; + content: ''; + position: absolute; + right: 100%; + color: var(--link-color); + opacity: 0; + transition: 100ms ease-in-out opacity; } .posts-page h2:hover::before, @@ -425,413 +408,411 @@ ul li { .posts-page h4:hover::before, .posts-page h5:hover::before, .posts-page h6:hover::before { - content: '§'; - opacity: 1; + content: '§'; + opacity: 1; } .article-page .toc { - font-family: var(--sans-serif-font); - font-size: 0.9rem; - line-height: 1.4; + font-family: var(--sans-serif-font); + font-size: 0.9rem; + line-height: 1.4; } .toc details { - padding: 0.5rem 0.75rem; - border-radius: 0; + padding: 0.5rem 0.75rem; + border-radius: 0; } .toc > details > ol { - margin: 0.75rem 0 0; - padding-left: 1.5rem; - list-style-type: decimal; + margin: 0.75rem 0 0; + padding-left: 1.5rem; + list-style-type: decimal; } .toc ol ol { - margin-top: 0; - padding-left: 0.75rem; - list-style-type: lower-alpha; + margin-top: 0; + padding-left: 0.75rem; + list-style-type: lower-alpha; } .toc ol ol ol { - list-style-type: lower-roman; + list-style-type: lower-roman; } .toc li { - margin: 0.25rem 0; - padding-left: 0.15rem; + margin: 0.25rem 0; + padding-left: 0.15rem; } .toc a { - text-decoration: none; + text-decoration: none; } .toc a:hover { - text-decoration: underline; + text-decoration: underline; } .article-page blockquote { - margin: 1.5rem 0; - padding-left: 1.5rem; - border-left: 3px solid var(--main-border-color); - color: #cccccc; - font-style: italic; + margin: 1.5rem 0; + padding-left: 1.5rem; + border-left: 3px solid var(--main-border-color); + color: #cccccc; + font-style: italic; } .article-page pre { - margin: 0; - padding: 1rem; - border: 1px solid var(--main-border-color); - border-top: 0; + margin: 0; + padding: 1rem; + border: 1px solid var(--main-border-color); + border-top: 0; } .article-page .code-block { - margin: 1.5rem 0; + margin: 1.5rem 0; } .code-block-header { - display: flex; - align-items: center; - justify-content: space-between; - min-height: 2.25rem; - padding-left: 1rem; - border: 1px solid var(--main-border-color); - background-color: var(--secondary-green); - font-family: var(--sans-serif-font); - font-size: 0.80rem; - font-weight: bold; - line-height: 1.5; + display: flex; + align-items: center; + justify-content: space-between; + min-height: 2.25rem; + padding-left: 1rem; + border: 1px solid var(--main-border-color); + background-color: var(--secondary-green); + font-family: var(--sans-serif-font); + font-size: 0.8rem; + font-weight: bold; + line-height: 1.5; } .copy-code { - align-self: stretch; - min-width: 5.5rem; - padding: 0.25rem 0.75rem; - border: 0; - border-left: 1px solid var(--main-border-color); - color: #ffffff; - background: transparent; - font: inherit; - cursor: pointer; + align-self: stretch; + min-width: 5.5rem; + padding: 0.25rem 0.75rem; + border: 0; + border-left: 1px solid var(--secondary-border-color); + color: #ffffff; + background: transparent; + font: inherit; + cursor: pointer; } .copy-code:hover { - background-color: var(--tertiary-green); - color: #cccccc; + background-color: var(--tertiary-green); + color: #cccccc; } .copy-code:focus-visible { - outline: 2px solid var(--link-color); - outline-offset: -3px; + outline: 2px solid var(--link-color); + outline-offset: -3px; } .article-page pre.line-numbers { - padding-left: 3.8em; + padding-left: 3.8em; } .article-page pre > code { - display: block; - overflow: visible; - padding: 0; - background: transparent; + display: block; + overflow: visible; + padding: 0; + background: transparent; } .article-page hr { - margin: 2rem 0; - border: 0; - border-top: 1px solid var(--main-border-color); + margin: 2rem 0; + border: 0; + border-top: 1px solid var(--main-border-color); } .article-page table { - display: table; - width: max-content; - min-width: 100%; - border: 1px solid var(--main-border-color); - border-radius: 0.25rem; - white-space: nowrap; + width: max-content; + border: 1px solid var(--main-border-color); + white-space: nowrap; } .table-scroll { - width: 100%; - overflow-x: auto; + width: 100%; + max-width: 100%; + overflow-x: auto; } .article-page table thead { - background-color: var(--secondary-green); + background-color: var(--secondary-green); } .article-page table th, .article-page table td { - padding: 0.4rem 0.75rem; - 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); + padding: 0.4rem 0.75rem; + text-align: left; } .margin-toggle { - display: none; + display: none; } .sidenote { - float: none; - width: 18rem; - margin-right: 0; - margin-top: 0.25rem; - color: #bbbbbb; - font-size: 0.90rem; - line-height: 1.4; + float: none; + width: 18rem; + margin-right: 0; + margin-top: 0.25rem; + color: #bbbbbb; + font-size: 0.9rem; + line-height: 1.4; } .sidenote::before { - content: counter(sidenote-counter) '. '; - color: var(--link-color); - font-weight: bold; + content: counter(sidenote-counter) '. '; + color: var(--link-color); + font-weight: bold; } .sidenote-number::after { - content: counter(sidenote-counter); - position: relative; - top: -0.35rem; - margin-left: 0.15rem; - color: var(--link-color); - font-size: 0.7em; - font-weight: bold; - counter-increment: sidenote-counter; + content: counter(sidenote-counter); + position: relative; + top: -0.35rem; + margin-left: 0.15rem; + color: var(--link-color); + font-size: 0.7em; + font-weight: bold; + counter-increment: sidenote-counter; } .sidenote-number { - display: inline-block; - min-width: 0.8rem; + display: inline-block; + min-width: 0.8rem; } .article-page { - counter-reset: sidenote-counter; + counter-reset: sidenote-counter; } @media screen and (max-width: 1399px) { - .sidenote { - display: none; - float: none; - width: auto; - margin: 0.75rem 0; - padding: 0.75rem 1rem; - border-left: 3px solid var(--main-border-color); - background-color: rgba(255, 255, 255, 0.04); - } + .sidenote { + display: none; + float: none; + width: auto; + margin: 0.75rem 0; + padding: 0.75rem 1rem; + border-left: 3px solid var(--main-border-color); + background-color: rgba(255, 255, 255, 0.04); + } - .sidenote::before { - content: none; - } + .sidenote::before { + content: none; + } - .sidenote-toggle:checked + .margin-toggle + .sidenote { - display: block !important; - } + .sidenote-toggle:checked + .margin-toggle + .sidenote { + display: block !important; + } - .sidenote-toggle:focus-visible + .sidenote-number { - outline: 2px solid var(--link-color); - outline-offset: 2px; - } + .sidenote-toggle:focus-visible + .sidenote-number { + outline: 2px solid var(--link-color); + outline-offset: 2px; + } - .sidenote-number { - cursor: pointer; - } + .sidenote-number { + cursor: pointer; + } } @media screen and (min-width: 1400px) { - .article-page .toc + hr { - display: none; - } + .article-page .toc + hr { + display: none; + } - .article-page .toc { - position: sticky; - top: 1rem; - height: calc(100vh - 4rem); - overflow-y: auto; - scrollbar-width: none; - float: left; - width: 16rem; - margin-left: -18rem; - margin-top: 4rem; - margin-bottom: 4rem; - padding-right: 1rem; - border-right: 1px solid var(--main-border-color); - } + .article-page .toc { + position: sticky; + top: 1rem; + height: calc(100vh - 4rem); + overflow-y: auto; + scrollbar-width: none; + float: left; + width: 16rem; + margin-left: -18rem; + margin-top: 4rem; + margin-bottom: 4rem; + padding-right: 1rem; + border-right: 1px solid var(--main-border-color); + } - .article-page .toc::-webkit-scrollbar { - display: none; - } + .article-page .toc::-webkit-scrollbar { + display: none; + } - .article-page .toc summary { - cursor: default; - list-style: none; - pointer-events: none; - } + .article-page .toc summary { + cursor: default; + list-style: none; + pointer-events: none; + } - .article-page .toc summary::-webkit-details-marker { - display: none; - } + .article-page .toc summary::-webkit-details-marker { + display: none; + } - .article-page .sidenote { - position: static; - float: right; - clear: right; - width: 16rem; - margin: 0.25rem -18rem 1rem 1.5rem; - } + .article-page .sidenote { + position: static; + float: right; + clear: right; + width: 16rem; + margin: 0.25rem -18rem 1rem 1.5rem; + } } .button { - padding: 0.2rem 1rem; - margin: 0.3rem 0.3rem; - color: #ffffff; - background: var(--primary-green); - display: inline-block; - text-decoration: none; - transition: 50ms ease-in-out all; - border-radius: 0.5rem; - box-shadow: none; + padding: 0.2rem 1rem; + margin: 0.3rem 0.3rem; + color: #ffffff; + background: var(--primary-green); + display: inline-block; + text-decoration: none; + transition: 50ms ease-in-out all; + border-radius: 0.5rem; + box-shadow: none; } .button:hover { - text-decoration: none; - background: var(--secondary-green); - box-shadow: 0 0 0 1px var(--secondary-green); + text-decoration: none; + background: var(--secondary-green); + box-shadow: 0 0 0 1px var(--secondary-green); } .button:active { - text-decoration: none; - box-shadow: 0 0 0 1px var(--tertiary-green); - transform: scale(0.98); - color: #cccccc; - background: var(--tertiary-green); + text-decoration: none; + box-shadow: 0 0 0 1px var(--tertiary-green); + transform: scale(0.98); + color: #cccccc; + background: var(--tertiary-green); } .button:focus { - box-shadow: 0 0 0 2px #ffffff; + box-shadow: 0 0 0 2px #ffffff; } .button.blue { - background: var(--primary-blue); + background: var(--primary-blue); } .button.blue:hover { - background: var(--secondary-blue); - box-shadow: 0 0 0 1px var(--secondary-blue); + background: var(--secondary-blue); + box-shadow: 0 0 0 1px var(--secondary-blue); } .button.blue:active { - background: var(--tertiary-blue); - box-shadow: 0 0 0 1px var(--tertiary-green); + background: var(--tertiary-blue); + box-shadow: 0 0 0 1px var(--tertiary-green); } .button.blue:focus { - box-shadow: 0 0 0 2px #ffffff; + box-shadow: 0 0 0 2px #ffffff; } .text.center { - text-align: center; + text-align: center; } .button.link::after { - content: ''; - display: inline-block; - width: 1em; - height: 1em; - margin-left: 0.3rem; - vertical-align: -0.125em; - 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; - 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; + content: ''; + display: inline-block; + width: 1em; + height: 1em; + margin-left: 0.3rem; + vertical-align: -.175rem; + 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; + 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; } .button.link:hover { - display: inline-block; - transition: 50ms ease-in-out all; + display: inline-block; + transition: 50ms ease-in-out all; } .button.link:hover::after { - 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 { - transform: rotateZ(-45deg) scale(1.5); + transform: rotateZ(0) translateX(0.25rem) scale(1.3); } .button.link.back::before { - content: ''; - display: inline-block; - width: 1em; - height: 1em; - margin-right: 0.3rem; - vertical-align: -0.125em; - 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; - 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); - transition: transform 100ms ease-in-out; + content: ''; + display: inline-block; + width: 1em; + height: 1em; + margin-right: 0.3rem; + vertical-align: -0.175em; + 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; + 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); + transition: transform 100ms ease-in-out; } .button.link:hover::before { - transform: translateX(-0.2rem) rotate(180deg) scale(1.3); + transform: translateX(-0.2rem) rotate(180deg) scale(1.3); } .button.link.back::after { - content: ''; - display: none; + content: ''; + display: none; } .page-heading { - text-align: center; - margin: auto; - background-color: var(--main-background-color); + text-align: center; + margin: auto; + background-color: var(--main-background-color); } .page-heading .title { - border-bottom: 1px solid #FFFFFF; - max-width: 95%; - margin: auto; + border-bottom: 1px solid #ffffff; + max-width: 95%; + margin: auto; } .last-updated { - text-align: right; - display: block; - font-style: italic; - font-size: 1rem; - margin: 0.5rem 0.75rem; + text-align: right; + display: block; + font-style: italic; + font-size: 1rem; + margin: 0.5rem 0.75rem; } nav { - padding: 0.25rem 0.75rem; - font-size: 1.25rem; - box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.4); - position: fixed; - bottom: 0; - z-index: 1000; - margin: 0; - height: 2.5rem; - max-height: 2.5rem; - min-height: 2.5rem; - min-width: 100%; - width: 100%; - background: #09351b no-repeat center center fixed; - overflow-y: hidden; - overflow-x: auto; - white-space: nowrap; + padding: 0.25rem 0.75rem; + font-size: 1.25rem; + box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.4); + position: fixed; + bottom: 0; + z-index: 1000; + margin: 0; + height: 2.5rem; + max-height: 2.5rem; + min-height: 2.5rem; + min-width: 100%; + width: 100%; + background: #09351b no-repeat center center fixed; + overflow-y: hidden; + overflow-x: auto; + white-space: nowrap; } @media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - scroll-behavior: auto !important; - transition-duration: 0.01ms !important; - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - } + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } } diff --git a/src/main.js b/src/main.js index de5d341..3a472b4 100644 --- a/src/main.js +++ b/src/main.js @@ -1,22 +1,24 @@ -import Prism from 'prismjs'; -import './fonts.css'; -import 'prismjs/components/prism-bash'; -import 'prismjs/components/prism-ini'; -import 'prismjs/components/prism-lua'; -import 'prismjs/plugins/line-numbers/prism-line-numbers.js'; -import 'prismjs/themes/prism-tomorrow.css'; -import 'prismjs/plugins/line-numbers/prism-line-numbers.css'; -import './prism.css'; +import Prism from 'prismjs' +import './fonts.css' +import 'prismjs/components/prism-bash' +import 'prismjs/components/prism-ini' +import 'prismjs/components/prism-lua' +import 'prismjs/plugins/line-numbers/prism-line-numbers.js' +import 'prismjs/themes/prism-tomorrow.css' +import 'prismjs/plugins/line-numbers/prism-line-numbers.css' +import './prism.css' const absoluteDateFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: 'long', timeStyle: 'short', -}); +}) const tooltipDateFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: 'long', timeStyle: 'long', -}); -const relativeDateFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }); +}) +const relativeDateFormatter = new Intl.RelativeTimeFormat(undefined, { + numeric: 'auto', +}) const relativeTimeUnits = [ [31536000, 'year'], [2592000, 'month'], @@ -24,66 +26,75 @@ const relativeTimeUnits = [ [86400, 'day'], [3600, 'hour'], [60, 'minute'], -]; +] document.querySelectorAll('pre > code').forEach((code) => { 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'); -}); -Prism.highlightAll(); + code.closest('pre').classList.add('line-numbers') +}) +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) => { if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text); - return; + await navigator.clipboard.writeText(text) + return } - const textarea = document.createElement('textarea'); - textarea.value = text; - textarea.setAttribute('readonly', ''); - textarea.style.position = 'fixed'; - textarea.style.opacity = '0'; - document.body.append(textarea); - textarea.select(); - const copied = document.execCommand('copy'); - textarea.remove(); - if (!copied) throw new Error('Copy command failed'); -}; + const textarea = document.createElement('textarea') + textarea.value = text + textarea.setAttribute('readonly', '') + textarea.style.position = 'fixed' + textarea.style.opacity = '0' + document.body.append(textarea) + textarea.select() + const copied = document.execCommand('copy') + textarea.remove() + if (!copied) throw new Error('Copy command failed') +} document.querySelectorAll('.copy-code').forEach((button) => { button.addEventListener('click', async () => { - const code = button.closest('.code-block')?.querySelector('code'); - if (!code) return; + const code = button.closest('.code-block')?.querySelector('code') + if (!code) return try { - await copyText(code.textContent); - button.textContent = 'Copied'; + await copyText(code.textContent) + button.textContent = 'Copied' } catch { - button.textContent = 'Copy failed'; + button.textContent = 'Copy failed' } window.setTimeout(() => { - button.textContent = 'Copy'; - }, 1500); - }); -}); + button.textContent = 'Copy' + }, 1500) + }) +}) document.querySelectorAll('[data-value]').forEach((element) => { - const date = new Date(element.dataset.value); - if (Number.isNaN(date.valueOf())) return; + const date = new Date(element.dataset.value) + if (Number.isNaN(date.valueOf())) return if (element.classList.contains('recent-date')) { - element.dataset.tooltip = tooltipDateFormatter.format(date); - const seconds = Math.round((date.valueOf() - Date.now()) / 1000); - const unit = relativeTimeUnits.find(([size]) => Math.abs(seconds) >= size) ?? [1, 'second']; - element.textContent = relativeDateFormatter.format(Math.round(seconds / unit[0]), unit[1]); - return; + element.dataset.tooltip = tooltipDateFormatter.format(date) + const seconds = Math.round((date.valueOf() - Date.now()) / 1000) + const unit = relativeTimeUnits.find(([size]) => Math.abs(seconds) >= size) ?? [1, 'second'] + element.textContent = relativeDateFormatter.format(Math.round(seconds / unit[0]), unit[1]) + return } - const formatted = absoluteDateFormatter.format(date); - element.textContent = element.classList.contains('last-updated') - ? `Last updated: ${formatted}` - : formatted; -}); + const formatted = absoluteDateFormatter.format(date) + element.textContent = element.classList.contains('last-updated') ? `Last updated: ${formatted}` : formatted +}) diff --git a/vite.config.js b/vite.config.js index 992ee00..fc3feaa 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,48 +1,48 @@ -import fs from 'node:fs'; -import { spawn } from 'node:child_process'; -import path from 'node:path'; -import { defineConfig } from 'vite'; +import fs from 'node:fs' +import { spawn } from 'node:child_process' +import path from 'node:path' +import { defineConfig } from 'vite' function generatedPages(directory) { - const inputs = {}; + const inputs = {} for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { - const filePath = path.join(directory, entry.name); - if (entry.isDirectory()) Object.assign(inputs, generatedPages(filePath)); + const filePath = path.join(directory, entry.name) + if (entry.isDirectory()) Object.assign(inputs, generatedPages(filePath)) else if (entry.name === 'index.html') { - const relativePath = path.relative('.generated', filePath); - const key = relativePath.endsWith('/index.html') - ? relativePath.slice(0, -'/index.html'.length) - : relativePath; - inputs[key] = path.resolve(filePath); + const relativePath = path.relative('.generated', filePath) + const key = relativePath.endsWith('/index.html') ? relativePath.slice(0, -'/index.html'.length) : relativePath + inputs[key] = path.resolve(filePath) + } else if (entry.name === '404.html' && directory === '.generated') { + inputs['404'] = path.resolve(filePath) } } - return inputs; + return inputs } function contentGenerator() { - let timer; - let generator; - let rerun = false; + let timer + let generator + let rerun = false const generate = (server) => { if (generator) { - rerun = true; - return; + rerun = true + return } - generator = spawn(process.execPath, ['generator.js'], { stdio: 'inherit' }); + generator = spawn(process.execPath, ['generator.js'], { stdio: 'inherit' }) generator.on('error', (error) => { - console.error(`Content generation failed: ${error.message}`); - }); + console.error(`Content generation failed: ${error.message}`) + }) generator.on('close', (code) => { - generator = undefined; - if (code === 0) server.ws.send({ type: 'full-reload' }); + generator = undefined + if (code === 0) server.ws.send({ type: 'full-reload' }) if (rerun) { - rerun = false; - generate(server); + rerun = false + generate(server) } - }); - }; + }) + } return { name: 'content-generator', @@ -55,22 +55,25 @@ function contentGenerator() { path.resolve('external.json'), path.resolve('generator.js'), path.resolve('html.js'), - ]; - server.watcher.add(sources); + ] + server.watcher.add(sources) server.watcher.on('all', (event, file) => { - if (!['add', 'change', 'unlink'].includes(event)) return; - const isMarkdown = file.endsWith('.md') && (file.startsWith(`${path.resolve('notes')}/`) || file.startsWith(`${path.resolve('posts')}/`)); - const isExternal = file === path.resolve('external.json'); - const isStandaloneMarkdown = file === 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); + if (!['add', 'change', 'unlink'].includes(event)) return + const isMarkdown = + file.endsWith('.md') && + (file.startsWith(`${path.resolve('notes')}/`) || file.startsWith(`${path.resolve('posts')}/`)) + const isExternal = file === path.resolve('external.json') + const isStandaloneMarkdown = + 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(() => { - generate(server); - }, 100); - }); + generate(server) + }, 100) + }) }, - }; + } } export default defineConfig({ @@ -94,4 +97,4 @@ export default defineConfig({ input: generatedPages('.generated'), }, }, -}); +})