Compare commits
9
Commits
c4e7c710d1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f69028d4a | ||
|
|
88d401198a | ||
|
|
a62bed5504 | ||
|
|
49cb5d5cbf | ||
|
|
aacfd33c62 | ||
|
|
7e9e744f0d | ||
|
|
a433a534a7 | ||
|
|
b5f3ed49d3 | ||
|
|
7817cd1dac
|
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "next/core-web-vitals"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
name: Deploy website
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out source
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- 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: |
|
||||||
|
set -o pipefail
|
||||||
|
tar -C dist -czf - . | \
|
||||||
|
ssh -T -i "$HOME/.ssh/deploy_key" -o IdentitiesOnly=yes \
|
||||||
|
"$DEPLOY_USER@$DEPLOY_HOST" \
|
||||||
|
"find '$DEPLOY_PATH' -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + && tar -xzf - -C '$DEPLOY_PATH'"
|
||||||
+6
-8
@@ -1,11 +1,9 @@
|
|||||||
node_modules/
|
|
||||||
dist/
|
|
||||||
.next/
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.cache/
|
|
||||||
*.bun
|
|
||||||
**/.*.md
|
**/.*.md
|
||||||
.env
|
.env
|
||||||
public/posts.json
|
.generated/
|
||||||
public/notes.json
|
.bun-tmp/
|
||||||
public/sitemap.json
|
dist/
|
||||||
|
node_modules/
|
||||||
|
*.pdb
|
||||||
|
*.exe
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 120,
|
||||||
|
"semi": false,
|
||||||
|
"useTabs": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"ignorePatterns": ["**/*.md"]
|
||||||
|
}
|
||||||
@@ -1,20 +1,25 @@
|
|||||||
# PaulW.XYZ
|
# PaulW.XYZ
|
||||||
|
|
||||||
A [Next.js](https://nextjs.com) website that mainly involves generating content out of a bunch of markdown files contained in `notes/` and `posts/` which contain rough, unorganized yet useful information and thought-out articles respectively.
|
Paul's personal website.
|
||||||
|
|
||||||
## Third-Party Licenses
|
## Third-Party Licenses
|
||||||
Any trademarks listed on this site are the property of their respective owners. This site neither endorses nor is affiliated with any of the owners. Any source code available on any of the pages is without warranty of any kind and the use of such is at your own risk.
|
|
||||||
|
|
||||||
### Fonts
|
Any trademarks listed on this site are the property of their respective owners.
|
||||||
|
This site neither endorses nor is affiliated with any of the owners.
|
||||||
|
Any source code available on any of the pages is without warranty of any kind.
|
||||||
|
Use it at your own risk.
|
||||||
|
|
||||||
[Hack](https://github.com/source-foundry/Hack)
|
Direct dependency notices are documented in
|
||||||
- © 2018 Source Foundry Authors
|
[the license page](/licenses/).
|
||||||
- MIT License
|
|
||||||
|
|
||||||
[Cantarell](https://github.com/davelab6/cantarell)
|
## Development
|
||||||
- © 2009-2010, [Understanding Limited](mailto:dave@understandinglimited.com)
|
|
||||||
- Open Font License, Version 1.1
|
|
||||||
|
|
||||||
[EB Garamond](https://github.com/georgd/EB-Garamond)
|
The source for this site is hosted at <https://git.paulw.xyz/paul/www>.
|
||||||
- © 2010-2013 [Georg Duffner](http://www.georgduffner.at)
|
|
||||||
- Open Font License, Version 1.1
|
```sh
|
||||||
|
bun install
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `bun run check` to validate generated pages and `bun run build` to create
|
||||||
|
the production site in `dist/`.
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Third-Party Licenses
|
||||||
|
|
||||||
|
This project uses the following direct dependencies:
|
||||||
|
|
||||||
|
## Cardo
|
||||||
|
|
||||||
|
- Package: `@fontsource/cardo`
|
||||||
|
- License: SIL Open Font License, Version 1.1
|
||||||
|
- <https://scholarsfonts.net/cardofnt.html>
|
||||||
|
- <https://github.com/fontsource/font-files/tree/main/fonts/google/cardo>
|
||||||
|
|
||||||
|
## Inter
|
||||||
|
|
||||||
|
- Package: `@fontsource/inter`
|
||||||
|
- License: SIL Open Font License, Version 1.1
|
||||||
|
- Font copyright: Rasmus Andersson
|
||||||
|
- <https://github.com/rsms/inter>
|
||||||
|
- <https://github.com/fontsource/font-files/tree/main/fonts/other/inter>
|
||||||
|
|
||||||
|
## Iosevka
|
||||||
|
|
||||||
|
- Package: `@fontsource/iosevka`
|
||||||
|
- License: SIL Open Font License, Version 1.1
|
||||||
|
- Sources:
|
||||||
|
- <https://github.com/be5invis/iosevka>
|
||||||
|
- <https://github.com/fontsource/font-files/tree/main/fonts/other/iosevka>
|
||||||
|
|
||||||
|
## Marked
|
||||||
|
|
||||||
|
- Package: `marked`
|
||||||
|
- License: MIT
|
||||||
|
- Copyright: MarkedJS; Christopher Jeffrey
|
||||||
|
- <https://github.com/markedjs/marked>
|
||||||
|
|
||||||
|
## PrismJS
|
||||||
|
|
||||||
|
- Package: `prismjs`
|
||||||
|
- License: MIT
|
||||||
|
- <https://github.com/PrismJS/prism>
|
||||||
|
|
||||||
|
## Vite
|
||||||
|
|
||||||
|
- Package: `vite`
|
||||||
|
- License: MIT
|
||||||
|
- Copyright: VoidZero Inc. and Vite contributors
|
||||||
|
- <https://github.com/vitejs/vite>
|
||||||
|
|
||||||
|
The package-manager lockfile records transitive dependencies. Their license
|
||||||
|
notices remain part of the installed dependency trees.
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { execFileSync } from 'node:child_process'
|
||||||
|
import { readdir, readFile, stat } from 'node:fs/promises'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
const generatedRoot = '.generated'
|
||||||
|
|
||||||
|
const walk = async (directory) => {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = async (path) => {
|
||||||
|
try {
|
||||||
|
await stat(path)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = []
|
||||||
|
const htmlFiles = (await walk(generatedRoot)).filter((file) => file.endsWith('.html'))
|
||||||
|
const noindexFiles = new Set()
|
||||||
|
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('<meta name="viewport"')) errors.push(`${file}: missing viewport metadata`)
|
||||||
|
if (html.includes('<heading')) errors.push(`${file}: contains a nonstandard <heading> element`)
|
||||||
|
if (html.includes('name="robots"') && html.includes('noindex')) {
|
||||||
|
noindexFiles.add(file)
|
||||||
|
errors.push(`${file}: draft page must not be in production output`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [, fragment] of html.matchAll(/href="#([^"]+)"/g)) {
|
||||||
|
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]
|
||||||
|
const relative = pathname.replace(/^\//, '')
|
||||||
|
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, relative, 'index.html') : join(generatedRoot, relative)
|
||||||
|
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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const siteUrl = 'https://paulw.xyz'
|
||||||
|
const generatedFile = (pathname) => {
|
||||||
|
const relative = pathname.replace(/^\//, '')
|
||||||
|
return pathname.endsWith('/') ? join(generatedRoot, relative, 'index.html') : join(generatedRoot, relative)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of ['feed.xml', 'sitemap.xml']) {
|
||||||
|
if (!(await exists(join(generatedRoot, name)))) errors.push(`missing ${name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await exists(join(generatedRoot, 'feed.xml'))) {
|
||||||
|
const feed = await readFile(join(generatedRoot, 'feed.xml'), 'utf8')
|
||||||
|
if (!feed.includes('<rss version="2.0"')) errors.push('feed.xml: missing RSS 2.0 channel')
|
||||||
|
if (feed.includes('noindex')) errors.push('feed.xml: unexpected noindex marker')
|
||||||
|
for (const [, loc] of feed.matchAll(/<guid isPermaLink="true">([^<]+)<\/guid>/g)) {
|
||||||
|
const { pathname } = new URL(loc)
|
||||||
|
if (!(await exists(generatedFile(pathname)))) errors.push(`feed.xml: missing page for ${loc}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await exists(join(generatedRoot, 'sitemap.xml'))) {
|
||||||
|
const sitemap = await readFile(join(generatedRoot, 'sitemap.xml'), 'utf8')
|
||||||
|
if (!sitemap.includes('http://www.sitemaps.org/schemas/sitemap/0.9'))
|
||||||
|
errors.push('sitemap.xml: missing urlset namespace')
|
||||||
|
|
||||||
|
const locs = [...sitemap.matchAll(/<loc>([^<]+)<\/loc>/g)].map((match) => match[1])
|
||||||
|
for (const loc of locs) {
|
||||||
|
const { origin, pathname } = new URL(loc)
|
||||||
|
if (origin !== siteUrl) errors.push(`sitemap.xml: unexpected origin ${loc}`)
|
||||||
|
if (!(await exists(generatedFile(pathname))) && pathname !== '/sitemap.xml')
|
||||||
|
errors.push(`sitemap.xml: missing file for ${loc}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const locSet = new Set(locs)
|
||||||
|
for (const file of htmlFiles) {
|
||||||
|
if (file.endsWith('404.html') || noindexFiles.has(file)) continue
|
||||||
|
const relative = file.slice(generatedRoot.length + 1).replaceAll('\\', '/')
|
||||||
|
const pathname = relative === 'index.html' ? '/' : `/${relative.replace(/\/index\.html$/, '/')}`
|
||||||
|
const loc = `${siteUrl}${pathname}`
|
||||||
|
if (!locSet.has(loc)) errors.push(`sitemap.xml: missing ${loc}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await exists('public/robots.txt'))) errors.push('missing public/robots.txt')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tracked = execFileSync('git', ['ls-files', '-z'], { encoding: 'utf8' })
|
||||||
|
for (const file of tracked.split('\0').filter(Boolean)) {
|
||||||
|
if (/(?:^|\/)\.[^/]*\.md$/.test(file)) errors.push(`${file}: hidden markdown must not be tracked`)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
console.error(errors.join('\n'))
|
||||||
|
process.exitCode = 1
|
||||||
|
} else {
|
||||||
|
console.log(`Checked ${htmlFiles.length} generated pages.`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { rm } from 'node:fs/promises'
|
||||||
|
|
||||||
|
await Promise.all([rm('.generated', { recursive: true, force: true }), rm('dist', { recursive: true, force: true })])
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"Git": "https://git.paulw.xyz/xyz",
|
||||||
|
"Twitter/X": "https://x.com/paulw_xyz"
|
||||||
|
}
|
||||||
+565
@@ -0,0 +1,565 @@
|
|||||||
|
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 siteUrl = 'https://paulw.xyz'
|
||||||
|
const output = '.generated'
|
||||||
|
const markdownDirs = ['notes', 'posts']
|
||||||
|
const includeDrafts = process.argv.includes('--drafts')
|
||||||
|
const navLabels = {
|
||||||
|
notes: 'Notes',
|
||||||
|
posts: 'Posts',
|
||||||
|
about: 'About',
|
||||||
|
licenses: 'Licenses',
|
||||||
|
}
|
||||||
|
|
||||||
|
const standalonePages = [
|
||||||
|
{ source: 'README.md', path: 'about', title: 'About' },
|
||||||
|
{ source: 'THIRD_PARTY_LICENSES.md', path: 'licenses', title: 'Licenses' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const languageLabels = {
|
||||||
|
ini: 'INI',
|
||||||
|
js: 'JavaScript',
|
||||||
|
javascript: 'JavaScript',
|
||||||
|
lua: 'Lua',
|
||||||
|
sh: 'Shell',
|
||||||
|
bash: 'Shell',
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = (value) => {
|
||||||
|
let result = ''
|
||||||
|
let dash = false
|
||||||
|
for (const character of String(value).toLowerCase()) {
|
||||||
|
const valid = (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9')
|
||||||
|
if (valid) {
|
||||||
|
if (dash && result) result += '-'
|
||||||
|
result += character
|
||||||
|
dash = false
|
||||||
|
} else if (result) dash = true
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const headingLabel = (tokens) =>
|
||||||
|
tokens
|
||||||
|
.map((token) => {
|
||||||
|
if (token.type === 'image') return token.text ?? ''
|
||||||
|
if (token.tokens) return headingLabel(token.tokens)
|
||||||
|
return token.text ?? token.raw ?? ''
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
const createSlugger = () => {
|
||||||
|
const counts = new Map()
|
||||||
|
return (tokens) => {
|
||||||
|
const base = slug(headingLabel(tokens)) || 'section'
|
||||||
|
const count = counts.get(base) ?? 0
|
||||||
|
counts.set(base, count + 1)
|
||||||
|
return count === 0 ? base : `${base}-${count + 1}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageShell = (title, body, path = '/', navTitle = '', { draft = false } = {}) =>
|
||||||
|
`<!DOCTYPE html>${render(
|
||||||
|
h('html', { lang: 'en-US' }, [
|
||||||
|
h('head', {}, [
|
||||||
|
h('meta', { charset: 'utf-8' }),
|
||||||
|
h('meta', {
|
||||||
|
name: 'viewport',
|
||||||
|
content: 'width=device-width, initial-scale=1',
|
||||||
|
}),
|
||||||
|
draft ? h('meta', { name: 'robots', content: 'noindex, nofollow' }) : undefined,
|
||||||
|
h('title', {}, [title ? `${title} | ${siteName}` : siteName]),
|
||||||
|
h('link', { rel: 'icon', href: '/favicon.ico' }),
|
||||||
|
h('link', {
|
||||||
|
rel: 'alternate',
|
||||||
|
type: 'application/rss+xml',
|
||||||
|
title: `${siteName} feed`,
|
||||||
|
href: '/feed.xml',
|
||||||
|
}),
|
||||||
|
h('link', { rel: 'stylesheet', href: '/src/global.css' }),
|
||||||
|
h('script', { type: 'module', src: '/src/main.js' }),
|
||||||
|
]),
|
||||||
|
h('body', {}, [raw(body), h('nav', { 'aria-label': 'Breadcrumb' }, [raw(nav(path, navTitle))])]),
|
||||||
|
]),
|
||||||
|
)}`
|
||||||
|
|
||||||
|
const nav = (path, currentTitle = '') => {
|
||||||
|
if (path === '/') {
|
||||||
|
if (!currentTitle) return render(h('span', {}, [siteName]))
|
||||||
|
return render([h('a', { href: '/' }, [siteName]), ' / ', currentTitle])
|
||||||
|
}
|
||||||
|
const parts = path.split('/').filter(Boolean)
|
||||||
|
const children = [h('a', { href: '/' }, [siteName])]
|
||||||
|
let href = ''
|
||||||
|
for (const [index, part] of parts.entries()) {
|
||||||
|
href += `/${part}`
|
||||||
|
const isLast = index === parts.length - 1
|
||||||
|
const label = isLast && currentTitle ? currentTitle : (navLabels[part] ?? part)
|
||||||
|
children.push(' / ', isLast ? label : h('a', { href: `${href}/` }, [label]))
|
||||||
|
}
|
||||||
|
return render(children)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateFor = async (file) => {
|
||||||
|
try {
|
||||||
|
const gitDate = execFileSync(
|
||||||
|
'git',
|
||||||
|
['log', '-1', '--date=format-local:%Y-%m-%dT%H:%M:%SZ', '--format=%ad', '--', file],
|
||||||
|
{ env: { ...process.env, TZ: 'UTC' } },
|
||||||
|
)
|
||||||
|
.toString()
|
||||||
|
.trim()
|
||||||
|
if (gitDate) return gitDate
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
return (await stat(file)).mtime.toISOString()
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const readPages = async (dir) => {
|
||||||
|
let entries
|
||||||
|
try {
|
||||||
|
entries = await readdir(dir, { withFileTypes: true })
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') return []
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const markdownEntries = entries
|
||||||
|
.filter((entry) => {
|
||||||
|
if (!entry.isFile() || !entry.name.endsWith('.md') || entry.name === '.md') return false
|
||||||
|
return includeDrafts || !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
|
||||||
|
const draft = entry.name.startsWith('.')
|
||||||
|
const name = (draft ? entry.name.slice(1) : entry.name).slice(0, -3)
|
||||||
|
if (!name) return undefined
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
title: stripSidenotes(titleLine.slice(2).trim()),
|
||||||
|
file,
|
||||||
|
source,
|
||||||
|
date: await dateFor(file),
|
||||||
|
draft,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const byName = new Map()
|
||||||
|
for (const page of pages.filter(Boolean)) {
|
||||||
|
const existing = byName.get(page.name)
|
||||||
|
if (!existing || (existing.draft && !page.draft)) byName.set(page.name, page)
|
||||||
|
}
|
||||||
|
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextHeadingId = createSlugger()
|
||||||
|
let sidenoteNumber = 0
|
||||||
|
|
||||||
|
marked.use({
|
||||||
|
extensions: [
|
||||||
|
{
|
||||||
|
name: 'sidenote',
|
||||||
|
level: 'inline',
|
||||||
|
start: (source) => source.indexOf('^['),
|
||||||
|
tokenizer(source) {
|
||||||
|
if (!source.startsWith('^[')) return undefined
|
||||||
|
let depth = 1
|
||||||
|
for (let index = 2; index < source.length; index += 1) {
|
||||||
|
if (source[index] === '\\') {
|
||||||
|
index += 1
|
||||||
|
} else if (source[index] === '[') {
|
||||||
|
depth += 1
|
||||||
|
} else if (source[index] === ']') {
|
||||||
|
depth -= 1
|
||||||
|
if (depth === 0) {
|
||||||
|
const text = source.slice(2, index)
|
||||||
|
return {
|
||||||
|
type: 'sidenote',
|
||||||
|
raw: source.slice(0, index + 1),
|
||||||
|
tokens: this.lexer.inlineTokens(text),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
renderer({ tokens }) {
|
||||||
|
sidenoteNumber += 1
|
||||||
|
const id = `sidenote-${sidenoteNumber}`
|
||||||
|
const content = this.parser.parseInline(tokens)
|
||||||
|
return `<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: {
|
||||||
|
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 `<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 }) {
|
||||||
|
const text = this.parser.parseInline(tokens)
|
||||||
|
const id = nextHeadingId(tokens)
|
||||||
|
return `<h${depth} id="${id}">${text}</h${depth}>\n`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const withoutTitle = (source) => {
|
||||||
|
const lines = source.split('\n')
|
||||||
|
const index = lines.findIndex((line) => line.startsWith('# '))
|
||||||
|
if (index >= 0) lines.splice(index, 1)
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripSidenotes = (text) => {
|
||||||
|
let result = ''
|
||||||
|
for (let index = 0; index < text.length;) {
|
||||||
|
if (text[index] === '^' && text[index + 1] === '[') {
|
||||||
|
const end = text.indexOf(']', index + 2)
|
||||||
|
if (end >= 0) {
|
||||||
|
index = end + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result += text[index++]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripHeadingSidenotes = (source) =>
|
||||||
|
source
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => {
|
||||||
|
const content = line.trimStart()
|
||||||
|
return content.startsWith('# ') ||
|
||||||
|
content.startsWith('## ') ||
|
||||||
|
content.startsWith('### ') ||
|
||||||
|
content.startsWith('#### ') ||
|
||||||
|
content.startsWith('##### ') ||
|
||||||
|
content.startsWith('###### ')
|
||||||
|
? line.slice(0, line.length - content.length) + stripSidenotes(content)
|
||||||
|
: line
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
const renderMarkdown = (source) => {
|
||||||
|
nextHeadingId = createSlugger()
|
||||||
|
sidenoteNumber = 0
|
||||||
|
const html = marked.parse(withoutTitle(stripHeadingSidenotes(source)))
|
||||||
|
let result = ''
|
||||||
|
let cursor = 0
|
||||||
|
while (cursor < html.length) {
|
||||||
|
const start = html.indexOf('<table', cursor)
|
||||||
|
if (start < 0) {
|
||||||
|
result += html.slice(cursor)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
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 headings = marked.lexer(withoutTitle(stripHeadingSidenotes(source))).filter((token) => token.type === 'heading')
|
||||||
|
if (headings.length === 0) return ''
|
||||||
|
const root = { depth: 0, children: [] }
|
||||||
|
const stack = [root]
|
||||||
|
const headingId = createSlugger()
|
||||||
|
for (const heading of headings) {
|
||||||
|
while (stack.length > 1 && stack.at(-1).depth >= heading.depth) stack.pop()
|
||||||
|
const node = { depth: heading.depth, heading, children: [] }
|
||||||
|
stack.at(-1).children.push(node)
|
||||||
|
stack.push(node)
|
||||||
|
}
|
||||||
|
const renderItems = (items) =>
|
||||||
|
`<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 excerpt = (source, max = 280) => {
|
||||||
|
const paragraph = marked
|
||||||
|
.lexer(withoutTitle(stripHeadingSidenotes(source)))
|
||||||
|
.find((token) => token.type === 'paragraph')
|
||||||
|
if (!paragraph) return ''
|
||||||
|
const text = stripSidenotes(headingLabel(paragraph.tokens ?? [{ text: paragraph.text ?? '' }]))
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
if (text.length <= max) return text
|
||||||
|
const slice = text.slice(0, max)
|
||||||
|
const bound = slice.lastIndexOf(' ')
|
||||||
|
return `${(bound > 80 ? slice.slice(0, bound) : slice).trimEnd()}…`
|
||||||
|
}
|
||||||
|
|
||||||
|
const toRssDate = (iso) => {
|
||||||
|
if (!iso) return ''
|
||||||
|
const date = new Date(iso)
|
||||||
|
return Number.isNaN(date.valueOf()) ? '' : date.toUTCString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toSitemapDate = (iso) => (iso ? iso.slice(0, 10) : '')
|
||||||
|
|
||||||
|
const renderFeed = (items) => {
|
||||||
|
const lastBuild = toRssDate(items.find((item) => item.date)?.date) || new Date().toUTCString()
|
||||||
|
const itemXml = items
|
||||||
|
.map((item) => {
|
||||||
|
const loc = `${siteUrl}/${item.dir}/${item.name}/`
|
||||||
|
const description = excerpt(item.source)
|
||||||
|
const pubDate = toRssDate(item.date)
|
||||||
|
return `<item>
|
||||||
|
<title>${escapeHtml(item.title)}</title>
|
||||||
|
<link>${escapeHtml(loc)}</link>
|
||||||
|
<guid isPermaLink="true">${escapeHtml(loc)}</guid>
|
||||||
|
${pubDate ? `<pubDate>${escapeHtml(pubDate)}</pubDate>` : ''}
|
||||||
|
<category>${escapeHtml(item.dir)}</category>
|
||||||
|
${description ? `<description>${escapeHtml(description)}</description>` : ''}
|
||||||
|
</item>`
|
||||||
|
})
|
||||||
|
.join('\n ')
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||||
|
<channel>
|
||||||
|
<title>${escapeHtml(siteName)}</title>
|
||||||
|
<link>${escapeHtml(`${siteUrl}/`)}</link>
|
||||||
|
<description>${escapeHtml(`Notes and posts from ${siteName}`)}</description>
|
||||||
|
<language>en-US</language>
|
||||||
|
<atom:link href="${escapeHtml(`${siteUrl}/feed.xml`)}" rel="self" type="application/rss+xml"/>
|
||||||
|
<lastBuildDate>${escapeHtml(lastBuild)}</lastBuildDate>
|
||||||
|
${itemXml}
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderSitemap = (urls) => `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
${urls
|
||||||
|
.map(({ loc, lastmod }) => {
|
||||||
|
const modified = lastmod ? `\n <lastmod>${escapeHtml(lastmod)}</lastmod>` : ''
|
||||||
|
return ` <url>
|
||||||
|
<loc>${escapeHtml(loc)}</loc>${modified}
|
||||||
|
</url>`
|
||||||
|
})
|
||||||
|
.join('\n')}
|
||||||
|
</urlset>
|
||||||
|
`
|
||||||
|
|
||||||
|
const writePage = async (path, html) => {
|
||||||
|
const directory = join(output, path)
|
||||||
|
await mkdir(directory, { recursive: true })
|
||||||
|
await writeFile(join(directory, 'index.html'), html)
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleHeading = (title, className = '') => `
|
||||||
|
<header class="page-heading${className ? ` ${className}` : ''}">
|
||||||
|
<h1 class="title">${escapeHtml(title)}</h1>
|
||||||
|
</header>`
|
||||||
|
|
||||||
|
const renderHome = (externalLinks, sectionLinks, recentLinks) =>
|
||||||
|
pageShell(
|
||||||
|
'',
|
||||||
|
`
|
||||||
|
${titleHeading(siteName)}
|
||||||
|
<main class="container">
|
||||||
|
<section class="block">${sectionLinks}${externalLinks}</section>
|
||||||
|
<section class="block recent">
|
||||||
|
<h2>Recent</h2>
|
||||||
|
<ul>${recentLinks || '<li>Nothing to show yet.</li>'}</ul>
|
||||||
|
</section>
|
||||||
|
</main>`,
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderNotesTable = (pages, directory) => {
|
||||||
|
const rows = pages
|
||||||
|
.map(
|
||||||
|
(page) => `
|
||||||
|
<tr>
|
||||||
|
<td><a href="/${directory}/${page.name}/">${escapeHtml(page.title)}</a></td>
|
||||||
|
<td>${page.date ? `<time class="table-last-updated" datetime="${escapeHtml(page.date)}" data-value="${escapeHtml(page.date)}">${escapeHtml(page.date)}</time>` : ''}</td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
return `<table class="${directory}-table"><caption class="visually-hidden">${escapeHtml(directory)} pages</caption><tbody>${rows}</tbody></table>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderPostsList = (pages, directory) => {
|
||||||
|
const items = pages
|
||||||
|
.map(
|
||||||
|
(page) => `
|
||||||
|
<li>
|
||||||
|
<a href="/${directory}/${page.name}/">${escapeHtml(page.title)}</a>
|
||||||
|
${page.date ? `<time datetime="${escapeHtml(page.date)}" data-value="${escapeHtml(page.date)}">${escapeHtml(page.date)}</time>` : ''}
|
||||||
|
</li>`,
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
return `<ul class="posts-list">${items}</ul>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderEmptySection = () => `
|
||||||
|
<div class="text center">
|
||||||
|
<p>**crickets**</p>
|
||||||
|
<p>Nothing to list...</p>
|
||||||
|
<a class="link button back" href="/">Go Home</a>
|
||||||
|
</div>`
|
||||||
|
|
||||||
|
const renderNotFound = () =>
|
||||||
|
pageShell(
|
||||||
|
'Page not found',
|
||||||
|
`${titleHeading('404: Page Not Found')}<main class="container"><section class="block text center"><p><strong>Uh oh! The page you are looking for does not exist...</strong></p><a class="link button back" href="/">Go Home</a><a class='button blue link extern' href='https://en.wikipedia.org/wiki/HTTP_404'>More on 404</a></section></main>`,
|
||||||
|
'/',
|
||||||
|
' ... ??? / 404: Page Not Found',
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderSection = (directory, pages) => {
|
||||||
|
const title = directory[0].toUpperCase() + directory.slice(1)
|
||||||
|
const listing =
|
||||||
|
pages.length === 0
|
||||||
|
? renderEmptySection()
|
||||||
|
: directory === 'posts'
|
||||||
|
? renderPostsList(pages, directory)
|
||||||
|
: renderNotesTable(pages, directory)
|
||||||
|
|
||||||
|
return pageShell(
|
||||||
|
title,
|
||||||
|
`${titleHeading(title)}<main class="container"><section class="block">${listing}</section></main>`,
|
||||||
|
`/${directory}`,
|
||||||
|
title,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderArticle = (directory, page, navigationPath = `/${directory}/${page.name}`) => {
|
||||||
|
const updated = page.date
|
||||||
|
? `<time class="last-updated" datetime="${escapeHtml(page.date)}" data-value="${escapeHtml(page.date)}">Last updated: ${escapeHtml(page.date)}</time>`
|
||||||
|
: ''
|
||||||
|
const heading = titleHeading(page.title, `${directory}-heading`)
|
||||||
|
const draftNotice = page.draft
|
||||||
|
? `<p class="draft-banner" role="status">Draft — rename <code>.${escapeHtml(page.name)}.md</code> to <code>${escapeHtml(page.name)}.md</code> to publish.</p>`
|
||||||
|
: ''
|
||||||
|
const tableOfContents = toc(page.source)
|
||||||
|
const contents = tableOfContents
|
||||||
|
? `<section class="block toc"><details open><summary>Contents</summary>${tableOfContents}</details></section><hr>`
|
||||||
|
: ''
|
||||||
|
const article = `<section class="block">${renderMarkdown(page.source)}</section>`
|
||||||
|
const body = `${heading}<main class="article-page ${directory}-page container">${draftNotice}${updated}${contents}${article}</main>`
|
||||||
|
|
||||||
|
return pageShell(page.title, body, navigationPath, page.title, { draft: Boolean(page.draft) })
|
||||||
|
}
|
||||||
|
|
||||||
|
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]) => `<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
|
||||||
|
.filter((page) => page.path !== 'licenses')
|
||||||
|
.map((page) => `<a class="button link" href="/${page.path}/">${page.title}</a>`)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const recentPages = markdownDirs
|
||||||
|
.flatMap((dir) => sections[dir].filter((page) => !page.draft).map((page) => ({ ...page, dir })))
|
||||||
|
.sort((a, b) => b.date.localeCompare(a.date))
|
||||||
|
.slice(0, 5)
|
||||||
|
|
||||||
|
const recentLinks = recentPages
|
||||||
|
.map(
|
||||||
|
(page) =>
|
||||||
|
`<li>${page.date ? `<time class="recent-date" datetime="${escapeHtml(page.date)}" data-value="${escapeHtml(page.date)}" data-tooltip="${escapeHtml(page.date)}">${escapeHtml(page.date)}</time>` : ''}<span class="recent-gap" aria-hidden="true"></span><span class="recent-type">${page.dir}</span><a href="/${page.dir}/${page.name}/">${escapeHtml(page.title)}</a></li>`,
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
await writeFile(join(output, 'index.html'), renderHome(externalLinks, sectionLinks + standaloneLinks, recentLinks))
|
||||||
|
await writeFile(join(output, '404.html'), renderNotFound())
|
||||||
|
|
||||||
|
for (const dir of markdownDirs) {
|
||||||
|
const pages = sections[dir]
|
||||||
|
await writePage(
|
||||||
|
dir,
|
||||||
|
renderSection(
|
||||||
|
dir,
|
||||||
|
pages.filter((page) => !page.draft),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const page of pages) {
|
||||||
|
await writePage(join(dir, page.name), renderArticle(dir, page))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const generatedStandalone = []
|
||||||
|
for (const page of standalonePages) {
|
||||||
|
const source = await readFile(page.source, 'utf8')
|
||||||
|
const generatedPage = {
|
||||||
|
name: page.path,
|
||||||
|
title: page.title,
|
||||||
|
source,
|
||||||
|
date: await dateFor(page.source),
|
||||||
|
}
|
||||||
|
generatedStandalone.push(generatedPage)
|
||||||
|
await writePage(page.path, renderArticle(page.path, generatedPage, '/'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const feedItems = markdownDirs
|
||||||
|
.flatMap((dir) => sections[dir].filter((page) => !page.draft).map((page) => ({ ...page, dir })))
|
||||||
|
.sort((a, b) => b.date.localeCompare(a.date))
|
||||||
|
|
||||||
|
await writeFile(join(output, 'feed.xml'), renderFeed(feedItems))
|
||||||
|
|
||||||
|
const sitemapUrls = [
|
||||||
|
{
|
||||||
|
loc: `${siteUrl}/`,
|
||||||
|
lastmod: toSitemapDate(feedItems[0]?.date),
|
||||||
|
},
|
||||||
|
...markdownDirs.map((dir) => ({
|
||||||
|
loc: `${siteUrl}/${dir}/`,
|
||||||
|
lastmod: toSitemapDate(
|
||||||
|
[...sections[dir]].filter((page) => !page.draft).sort((a, b) => b.date.localeCompare(a.date))[0]?.date,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
...generatedStandalone.map((page) => ({
|
||||||
|
loc: `${siteUrl}/${page.name}/`,
|
||||||
|
lastmod: toSitemapDate(page.date),
|
||||||
|
})),
|
||||||
|
...feedItems.map((page) => ({
|
||||||
|
loc: `${siteUrl}/${page.dir}/${page.name}/`,
|
||||||
|
lastmod: toSitemapDate(page.date),
|
||||||
|
})),
|
||||||
|
{ loc: `${siteUrl}/feed.xml`, lastmod: toSitemapDate(feedItems[0]?.date) },
|
||||||
|
]
|
||||||
|
|
||||||
|
await writeFile(join(output, 'sitemap.xml'), renderSitemap(sitemapUrls))
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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 h = (tag, attributes = {}, children = []) => ({
|
||||||
|
type: 'element',
|
||||||
|
tag,
|
||||||
|
attributes,
|
||||||
|
children: Array.isArray(children) ? children : [children],
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
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('')}</${node.tag}>`
|
||||||
|
}
|
||||||
Vendored
-5
@@ -1,5 +0,0 @@
|
|||||||
/// <reference types="next" />
|
|
||||||
/// <reference types="next/image-types/global" />
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import type {NextConfig } from 'next';
|
|
||||||
import NextBundleAnalyzer from '@next/bundle-analyzer';
|
|
||||||
|
|
||||||
let config: NextConfig = {
|
|
||||||
reactStrictMode: true,
|
|
||||||
turbopack: {
|
|
||||||
rules: {
|
|
||||||
'*.txt': {
|
|
||||||
as: '*.js',
|
|
||||||
loaders: ['raw-loader'],
|
|
||||||
},
|
|
||||||
'*.md': {
|
|
||||||
as: '*.js',
|
|
||||||
loaders: ['raw-loader'],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
resolveExtensions: ['.txt', '.md', '.tsx', '.ts', '.js']
|
|
||||||
},
|
|
||||||
webpack: (config, _options) => {
|
|
||||||
config.module.rules.push(
|
|
||||||
{
|
|
||||||
test: /\.svg$/,
|
|
||||||
use: [{ loader: '@svgr/webpack' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.md$/,
|
|
||||||
type: 'asset/source',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.otf$/,
|
|
||||||
type: 'asset/resource',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.txt$/,
|
|
||||||
type: 'asset/source',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (process.env.ANALYZE) {
|
|
||||||
config = NextBundleAnalyzer({
|
|
||||||
enabled: true
|
|
||||||
})(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
# Web Browsers
|
# Web Browsers
|
||||||
|
|
||||||
Extensions/Plugins I Use on All Supported Browsers:
|
Extensions/Plugins I Use on All Supported Browsers:
|
||||||
- uBlock Origin
|
- uBlock Origin
|
||||||
- Decentraleyes
|
- Decentraleyes
|
||||||
@@ -8,11 +9,13 @@ Extensions/Plugins I Use on All Supported Browsers:
|
|||||||
- Greasemonkey/Tampermonkey
|
- Greasemonkey/Tampermonkey
|
||||||
- Indie Wiki Buddy
|
- Indie Wiki Buddy
|
||||||
- SingleFile
|
- SingleFile
|
||||||
|
- Yet Another Flags
|
||||||
|
|
||||||
Product/Service-specific Extensions:
|
Product/Service-specific Extensions:
|
||||||
- SteamDB
|
- SteamDB
|
||||||
- Return YouTube Dislike
|
- Return YouTube Dislike
|
||||||
- SponsorBlock
|
- SponsorBlock
|
||||||
|
- Enhancer for YouTube™
|
||||||
|
|
||||||
## Chromium
|
## Chromium
|
||||||
- [Chromium Source Docs](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/README.md)
|
- [Chromium Source Docs](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/README.md)
|
||||||
|
|||||||
+3
-6
@@ -1,12 +1,9 @@
|
|||||||
# Lua Programming Language
|
# Lua Programming Language
|
||||||
<!-- TODO ## Lua 5.4 C API-->
|
|
||||||
|
<!-- TODO ## Lua 5.5 C API-->
|
||||||
|
|
||||||
## Lua 5.4 Bytecode
|
## Lua 5.4 Bytecode
|
||||||
|
^[These are **unstable** and may differ in different versions of the language. They are not part of the language specification but an implementation detail, which in this case is the reference implementation.] ^[The reference implementation used to have a stack based but now uses a register based VM similar to how modern real computer architectures.]
|
||||||
> These are **unstable** and may differ in different versions of the language.
|
|
||||||
> They are not part of the language specification but an implementation detail, which in this case is the reference implementation.
|
|
||||||
|
|
||||||
> The reference implementation used to have a stack based but now uses a register based VM similar to how modern real computer architectures.
|
|
||||||
|
|
||||||
The instructions are 32 bits wide; every instruction has an opcode that takes up 7 bits, which leaves out 25 bits for the addresses and values.
|
The instructions are 32 bits wide; every instruction has an opcode that takes up 7 bits, which leaves out 25 bits for the addresses and values.
|
||||||
|
|
||||||
|
|||||||
+6
-3
@@ -1,7 +1,10 @@
|
|||||||
# References
|
# References
|
||||||
|
|
||||||
## [Intel® 64 and IA-32 Architectures Software Developer’s Manual](https://software.intel.com/en-us/download/intel-64-and-ia-32-architectures-sdm-combined-volumes-1-2a-2b-2c-2d-3a-3b-3c-3d-and-4)
|
## Intel® 64 and IA-32 Architectures Software Developer’s Manual
|
||||||
|
- <https://software.intel.com/en-us/download/intel-64-and-ia-32-architectures-sdm-combined-volumes-1-2a-2b-2c-2d-3a-3b-3c-3d-and-4>
|
||||||
|
|
||||||
## [CUDA C++ Programming Guide](https://docs.nvidia.com/cuda/pdf/CUDA_C_Programming_Guide.pdf)
|
## Windows Internals Book
|
||||||
|
- <https://learn.microsoft.com/en-us/sysinternals/resources/windows-internals>
|
||||||
|
|
||||||
## [Windows Internals Book](https://learn.microsoft.com/en-us/sysinternals/resources/windows-internals)
|
## CUDA C++ Programming Guide
|
||||||
|
- <https://docs.nvidia.com/cuda/pdf/CUDA_C_Programming_Guide.pdf>
|
||||||
|
|||||||
+18
-18
@@ -18,33 +18,33 @@ refer to someone or within something.
|
|||||||
|
|
||||||
### Memory Allocation Strategies
|
### Memory Allocation Strategies
|
||||||
|
|
||||||
- https://www.gingerbill.org/series/memory-allocation-strategies/
|
- <https://www.gingerbill.org/series/memory-allocation-strategies/>
|
||||||
|
|
||||||
### Immediate-Mode Graphical User Interfaces (2005)
|
### Immediate-Mode Graphical User Interfaces (2005)
|
||||||
|
|
||||||
- https://caseymuratori.com/blog_0001
|
- <https://caseymuratori.com/blog_0001>
|
||||||
|
|
||||||
- https://www.youtube.com/watch?v=Z1qyvQsjK5Y
|
- <https://www.youtube.com/watch?v=Z1qyvQsjK5Y>
|
||||||
|
|
||||||
### What Color is Your Function?
|
### What Color is Your Function?
|
||||||
|
|
||||||
- https://journal.stuffwithstuff.com/2015/02/01/what-color-is-your-function/
|
- <https://journal.stuffwithstuff.com/2015/02/01/what-color-is-your-function/>
|
||||||
|
|
||||||
### Real-time audio programming 101: time waits for nothing
|
### Real-time audio programming 101: time waits for nothing
|
||||||
|
|
||||||
- http://www.rossbencina.com/code/real-time-audio-programming-101-time-waits-for-nothing
|
- <http://www.rossbencina.com/code/real-time-audio-programming-101-time-waits-for-nothing>
|
||||||
|
|
||||||
### Triangulation
|
### Triangulation
|
||||||
|
|
||||||
- https://www.humus.name/index.php?ID=228
|
- <https://www.humus.name/index.php?ID=228>
|
||||||
|
|
||||||
### Quantifying the Performance of Garbage Collection vs. Explicit Memory Management
|
### Quantifying the Performance of Garbage Collection vs. Explicit Memory Management
|
||||||
|
|
||||||
- https://people.cs.umass.edu/~emery/pubs/gcvsmalloc.pdf
|
- <https://people.cs.umass.edu/~emery/pubs/gcvsmalloc.pdf>
|
||||||
|
|
||||||
### Typing is Hard
|
### Typing is Hard
|
||||||
|
|
||||||
- https://3fx.ch/typing-is-hard.html
|
- <https://3fx.ch/typing-is-hard.html>
|
||||||
|
|
||||||
### Easy Scalable Text Rendering on the GPU
|
### Easy Scalable Text Rendering on the GPU
|
||||||
|
|
||||||
@@ -56,40 +56,40 @@ refer to someone or within something.
|
|||||||
|
|
||||||
### The Aggregate Magic Algorithms
|
### The Aggregate Magic Algorithms
|
||||||
|
|
||||||
- http://aggregate.org/MAGIC/
|
- <http://aggregate.org/MAGIC/>
|
||||||
|
|
||||||
### You Could Have Invented Monads! (And Maybe You Already Have.)
|
### You Could Have Invented Monads! (And Maybe You Already Have.)
|
||||||
|
|
||||||
- http://blog.sigfpe.com/2006/08/you-could-have-invented-monads-and.html
|
- <http://blog.sigfpe.com/2006/08/you-could-have-invented-monads-and.html>
|
||||||
|
|
||||||
### Fix Your Timestep!
|
### Fix Your Timestep!
|
||||||
|
|
||||||
- https://gafferongames.com/post/fix_your_timestep/
|
- <https://gafferongames.com/post/fix_your_timestep/>
|
||||||
|
|
||||||
### UTF-8 Everywhere
|
### UTF-8 Everywhere
|
||||||
|
|
||||||
- http://utf8everywhere.org
|
- <http://utf8everywhere.org>
|
||||||
|
|
||||||
### Parsing Gigabytes of JSON per Second
|
### Parsing Gigabytes of JSON per Second
|
||||||
|
|
||||||
- https://arxiv.org/abs/1902.08318 [[PDF](https://arxiv.org/pdf/1902.08318)]
|
- <https://arxiv.org/abs/1902.08318> [[PDF](https://arxiv.org/pdf/1902.08318)]
|
||||||
|
|
||||||
### What are OKLCH colors?
|
### What are OKLCH colors?
|
||||||
|
|
||||||
- https://jakub.kr/components/oklch-colors
|
- <https://jakub.kr/components/oklch-colors>
|
||||||
|
|
||||||
### Software Foundations series
|
### Software Foundations series
|
||||||
|
|
||||||
- https://softwarefoundations.cis.upenn.edu/
|
- <https://softwarefoundations.cis.upenn.edu/>
|
||||||
|
|
||||||
### Software Rendering Alpha-Blending Tricks
|
### Software Rendering Alpha-Blending Tricks
|
||||||
|
|
||||||
- https://gist.github.com/mattiasgustavsson/c11e824e3d603d0c86e5e0dde4ecf839
|
- <https://gist.github.com/mattiasgustavsson/c11e824e3d603d0c86e5e0dde4ecf839>
|
||||||
|
|
||||||
### A Relational Model of Data for Large Shared Data Banks
|
### A Relational Model of Data for Large Shared Data Banks
|
||||||
|
|
||||||
- https://fermatslibrary.com/s/a-relational-model-of-data-for-large-shared-data-banks
|
- <https://fermatslibrary.com/s/a-relational-model-of-data-for-large-shared-data-banks>
|
||||||
|
|
||||||
### Powersort
|
### Powersort
|
||||||
|
|
||||||
- https://www.wild-inter.net/publications/munro-wild-2018.pdf
|
- <https://www.wild-inter.net/publications/munro-wild-2018.pdf>
|
||||||
|
|||||||
+8
-10
@@ -5,29 +5,27 @@ The use of the term retro is debatable here as the term is used quite inconsiste
|
|||||||
## Recompilations
|
## Recompilations
|
||||||
|
|
||||||
### Zelda64Recomp
|
### Zelda64Recomp
|
||||||
- https://github.com/Zelda64Recomp/Zelda64Recomp
|
- <https://github.com/Zelda64Recomp/Zelda64Recomp>
|
||||||
|
|
||||||
## Open-source Recreations
|
## Open-source Recreations
|
||||||
|
|
||||||
### OpenMW
|
### OpenMW
|
||||||
- https://github.com/OpenMW/openmw
|
- <https://github.com/OpenMW/openmw>
|
||||||
|
|
||||||
### OpenTTD
|
### OpenTTD
|
||||||
- https://github.com/OpenTTD/OpenTTD
|
- <https://github.com/OpenTTD/OpenTTD>
|
||||||
|
|
||||||
### NFSIISE
|
### NFSIISE
|
||||||
- https://github.com/zaps166/NFSIISE
|
- <https://github.com/zaps166/NFSIISE>
|
||||||
|
|
||||||
### RE3
|
### RE3
|
||||||
- https://github.com/github/dmca/blob/master/2021/02/2021-02-19-take-two.md
|
- <https://github.com/github/dmca/blob/master/2021/02/2021-02-19-take-two.md>
|
||||||
- :(
|
- :(
|
||||||
|
|
||||||
## WidescreenFixesPack
|
## WidescreenFixesPack
|
||||||
- https://thirteenag.github.io/wfp
|
- <https://thirteenag.github.io/wfp>
|
||||||
- https://github.com/ThirteenAG/WidescreenFixesPack
|
- <https://github.com/ThirteenAG/WidescreenFixesPack>
|
||||||
|
|
||||||
## Linux Arm Handhelds
|
## Linux Arm Handhelds
|
||||||
- https://portmaster.games/
|
- <https://portmaster.games/>
|
||||||
- native ports of the reimplementations of many old-skool games
|
- native ports of the reimplementations of many old-skool games
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -31,6 +31,7 @@ WinCDEmu is a lightweight, open-source disc emulator that supports mounting CUE,
|
|||||||
- [Portable Version](https://wincdemu.sysprogs.org/portable/)
|
- [Portable Version](https://wincdemu.sysprogs.org/portable/)
|
||||||
|
|
||||||
### Master Control Panel / God Mode
|
### Master Control Panel / God Mode
|
||||||
|
|
||||||
(Misnomer; you probably won't use this either)
|
(Misnomer; you probably won't use this either)
|
||||||
|
|
||||||
Shows a list of all the available settings on Windows in a single view.
|
Shows a list of all the available settings on Windows in a single view.
|
||||||
@@ -40,8 +41,9 @@ Open it by exceuting the following command or saving it as a shortcut: `explorer
|
|||||||
## MacOS
|
## MacOS
|
||||||
|
|
||||||
### Clipboard Management
|
### Clipboard Management
|
||||||
|
|
||||||
- [maccy](https://maccy.app/)
|
- [maccy](https://maccy.app/)
|
||||||
- not sure why macOs doesn't have a native clipboard manager like Windows and KDE
|
- not sure why macOS doesn't have a native clipboard manager like Windows and KDE
|
||||||
|
|
||||||
### Terminal Emulator
|
### Terminal Emulator
|
||||||
|
|
||||||
@@ -51,7 +53,7 @@ Open it by exceuting the following command or saving it as a shortcut: `explorer
|
|||||||
### Package Manager
|
### Package Manager
|
||||||
|
|
||||||
- [HomeBrew](https://brew.sh)
|
- [HomeBrew](https://brew.sh)
|
||||||
- package manager everyone uses but it is noticeably slow
|
- package manager everyone uses
|
||||||
|
|
||||||
### Video Players
|
### Video Players
|
||||||
|
|
||||||
|
|||||||
+16
-33
@@ -1,41 +1,24 @@
|
|||||||
{
|
{
|
||||||
|
"name": "paulw-xyz",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prebuild": "node ./scripts/generate-metadata.js",
|
"generate": "node generator.js",
|
||||||
"dev": "next dev",
|
"check": "node generator.js --clean && node check.js",
|
||||||
"build": "next build",
|
"clean": "node clean.js",
|
||||||
"start": "next start",
|
"dev": "node generator.js --drafts && vite",
|
||||||
"lint": "next lint"
|
"build": "node generator.js --clean && node check.js && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dotenv": "^16.3.1",
|
"@fontsource/cardo": "^5.3.0",
|
||||||
"highlight.js": "^11.10.0",
|
"@fontsource/inter": "^5.3.0",
|
||||||
"next": "^15.3.1",
|
"@fontsource/iosevka": "^5.3.0",
|
||||||
"normalize.css": "^8.0.1",
|
"marked": "^16.0.0",
|
||||||
"raw-loader": "^4.0.2",
|
"prismjs": "^1.30.0"
|
||||||
"react": "^19.0.0",
|
|
||||||
"react-dom": "^19.0.0",
|
|
||||||
"react-markdown": "^9.0.1",
|
|
||||||
"rehype-autolink-headings": "^7.1.0",
|
|
||||||
"rehype-highlight": "^7.0.0",
|
|
||||||
"rehype-highlight-code-lines": "^1.0.4",
|
|
||||||
"rehype-katex": "^7.0.1",
|
|
||||||
"rehype-raw": "^7.0.0",
|
|
||||||
"rehype-slug": "^6.0.0",
|
|
||||||
"remark-gfm": "^4.0.0",
|
|
||||||
"remark-loader": "^6.0.0",
|
|
||||||
"remark-math": "^6.0.0",
|
|
||||||
"uri-js": "^4.4.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@next/bundle-analyzer": "^15.0.4",
|
"vite": "^7.0.0"
|
||||||
"@svgr/webpack": "^8.1.0",
|
},
|
||||||
"@types/node": "^22.7.4",
|
"packageManager": "bun@1.3.8"
|
||||||
"@types/react": "^19.0.1",
|
|
||||||
"@types/react-dom": "^19.0.1",
|
|
||||||
"@types/react-syntax-highlighter": "^15.5.7",
|
|
||||||
"eslint": "^9.11.1",
|
|
||||||
"eslint-config-next": "^15.0.4",
|
|
||||||
"typescript": "^5.6.2"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,93 +0,0 @@
|
|||||||
Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12)
|
|
||||||
|
|
||||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
||||||
This license is copied below, and is also available with a FAQ at:
|
|
||||||
http://scripts.sil.org/OFL
|
|
||||||
|
|
||||||
|
|
||||||
-----------------------------------------------------------
|
|
||||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
||||||
-----------------------------------------------------------
|
|
||||||
|
|
||||||
PREAMBLE
|
|
||||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
||||||
development of collaborative font projects, to support the font creation
|
|
||||||
efforts of academic and linguistic communities, and to provide a free and
|
|
||||||
open framework in which fonts may be shared and improved in partnership
|
|
||||||
with others.
|
|
||||||
|
|
||||||
The OFL allows the licensed fonts to be used, studied, modified and
|
|
||||||
redistributed freely as long as they are not sold by themselves. The
|
|
||||||
fonts, including any derivative works, can be bundled, embedded,
|
|
||||||
redistributed and/or sold with any software provided that any reserved
|
|
||||||
names are not used by derivative works. The fonts and derivatives,
|
|
||||||
however, cannot be released under any other type of license. The
|
|
||||||
requirement for fonts to remain under this license does not apply
|
|
||||||
to any document created using the fonts or their derivatives.
|
|
||||||
|
|
||||||
DEFINITIONS
|
|
||||||
"Font Software" refers to the set of files released by the Copyright
|
|
||||||
Holder(s) under this license and clearly marked as such. This may
|
|
||||||
include source files, build scripts and documentation.
|
|
||||||
|
|
||||||
"Reserved Font Name" refers to any names specified as such after the
|
|
||||||
copyright statement(s).
|
|
||||||
|
|
||||||
"Original Version" refers to the collection of Font Software components as
|
|
||||||
distributed by the Copyright Holder(s).
|
|
||||||
|
|
||||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
||||||
or substituting -- in part or in whole -- any of the components of the
|
|
||||||
Original Version, by changing formats or by porting the Font Software to a
|
|
||||||
new environment.
|
|
||||||
|
|
||||||
"Author" refers to any designer, engineer, programmer, technical
|
|
||||||
writer or other person who contributed to the Font Software.
|
|
||||||
|
|
||||||
PERMISSION & CONDITIONS
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
||||||
redistribute, and sell modified and unmodified copies of the Font
|
|
||||||
Software, subject to the following conditions:
|
|
||||||
|
|
||||||
1) Neither the Font Software nor any of its individual components,
|
|
||||||
in Original or Modified Versions, may be sold by itself.
|
|
||||||
|
|
||||||
2) Original or Modified Versions of the Font Software may be bundled,
|
|
||||||
redistributed and/or sold with any software, provided that each copy
|
|
||||||
contains the above copyright notice and this license. These can be
|
|
||||||
included either as stand-alone text files, human-readable headers or
|
|
||||||
in the appropriate machine-readable metadata fields within text or
|
|
||||||
binary files as long as those fields can be easily viewed by the user.
|
|
||||||
|
|
||||||
3) No Modified Version of the Font Software may use the Reserved Font
|
|
||||||
Name(s) unless explicit written permission is granted by the corresponding
|
|
||||||
Copyright Holder. This restriction only applies to the primary font name as
|
|
||||||
presented to the users.
|
|
||||||
|
|
||||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
||||||
Software shall not be used to promote, endorse or advertise any
|
|
||||||
Modified Version, except to acknowledge the contribution(s) of the
|
|
||||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
||||||
permission.
|
|
||||||
|
|
||||||
5) The Font Software, modified or unmodified, in part or in whole,
|
|
||||||
must be distributed entirely under this license, and must not be
|
|
||||||
distributed under any other license. The requirement for fonts to
|
|
||||||
remain under this license does not apply to any document created
|
|
||||||
using the Font Software.
|
|
||||||
|
|
||||||
TERMINATION
|
|
||||||
This license becomes null and void if any of the above conditions are
|
|
||||||
not met.
|
|
||||||
|
|
||||||
DISCLAIMER
|
|
||||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
||||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
||||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
||||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
||||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
||||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
||||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
EB Garamond Variable Font
|
|
||||||
=========================
|
|
||||||
|
|
||||||
This download contains EB Garamond as both variable fonts and static fonts.
|
|
||||||
|
|
||||||
EB Garamond is a variable font with this axis:
|
|
||||||
wght
|
|
||||||
|
|
||||||
This means all the styles are contained in these files:
|
|
||||||
EBGaramond-VariableFont_wght.ttf
|
|
||||||
EBGaramond-Italic-VariableFont_wght.ttf
|
|
||||||
|
|
||||||
If your app fully supports variable fonts, you can now pick intermediate styles
|
|
||||||
that aren’t available as static fonts. Not all apps support variable fonts, and
|
|
||||||
in those cases you can use the static font files for EB Garamond:
|
|
||||||
static/EBGaramond-Regular.ttf
|
|
||||||
static/EBGaramond-Medium.ttf
|
|
||||||
static/EBGaramond-SemiBold.ttf
|
|
||||||
static/EBGaramond-Bold.ttf
|
|
||||||
static/EBGaramond-ExtraBold.ttf
|
|
||||||
static/EBGaramond-Italic.ttf
|
|
||||||
static/EBGaramond-MediumItalic.ttf
|
|
||||||
static/EBGaramond-SemiBoldItalic.ttf
|
|
||||||
static/EBGaramond-BoldItalic.ttf
|
|
||||||
static/EBGaramond-ExtraBoldItalic.ttf
|
|
||||||
|
|
||||||
Get started
|
|
||||||
-----------
|
|
||||||
|
|
||||||
1. Install the font files you want to use
|
|
||||||
|
|
||||||
2. Use your app's font picker to view the font family and all the
|
|
||||||
available styles
|
|
||||||
|
|
||||||
Learn more about variable fonts
|
|
||||||
-------------------------------
|
|
||||||
|
|
||||||
https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts
|
|
||||||
https://variablefonts.typenetwork.com
|
|
||||||
https://medium.com/variable-fonts
|
|
||||||
|
|
||||||
In desktop apps
|
|
||||||
|
|
||||||
https://theblog.adobe.com/can-variable-fonts-illustrator-cc
|
|
||||||
https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts
|
|
||||||
|
|
||||||
Online
|
|
||||||
|
|
||||||
https://developers.google.com/fonts/docs/getting_started
|
|
||||||
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide
|
|
||||||
https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts
|
|
||||||
|
|
||||||
Installing fonts
|
|
||||||
|
|
||||||
MacOS: https://support.apple.com/en-us/HT201749
|
|
||||||
Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux
|
|
||||||
Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows
|
|
||||||
|
|
||||||
Android Apps
|
|
||||||
|
|
||||||
https://developers.google.com/fonts/docs/android
|
|
||||||
https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts
|
|
||||||
|
|
||||||
License
|
|
||||||
-------
|
|
||||||
Please read the full license text (OFL.txt) to understand the permissions,
|
|
||||||
restrictions and requirements for usage, redistribution, and modification.
|
|
||||||
|
|
||||||
You can use them freely in your products & projects - print or digital,
|
|
||||||
commercial or otherwise.
|
|
||||||
|
|
||||||
This isn't legal advice, please consider consulting a lawyer and see the full
|
|
||||||
license for all details.
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,45 +0,0 @@
|
|||||||
The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License
|
|
||||||
|
|
||||||
The work in the DejaVu project was committed to the public domain.
|
|
||||||
|
|
||||||
Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera"
|
|
||||||
|
|
||||||
### MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2018 Source Foundry Authors
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
|
|
||||||
### BITSTREAM VERA LICENSE
|
|
||||||
|
|
||||||
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc.
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces.
|
|
||||||
|
|
||||||
The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera".
|
|
||||||
|
|
||||||
This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names.
|
|
||||||
|
|
||||||
The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself.
|
|
||||||
|
|
||||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
||||||
|
|
||||||
Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org.
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"Git": "https://git.paulw.xyz/xyz",
|
|
||||||
"Twitter/X": "https://x.com/paulw_xyz"
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"posts": {
|
|
||||||
"title": "Posts"
|
|
||||||
},
|
|
||||||
"notes": {
|
|
||||||
"title": "Notes"
|
|
||||||
},
|
|
||||||
"about": {
|
|
||||||
"title": "About"
|
|
||||||
},
|
|
||||||
"sitemap": {
|
|
||||||
"title": "Site Map"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://paulw.xyz/sitemap.xml
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
const path = require('path')
|
|
||||||
const fs = require('fs/promises')
|
|
||||||
|
|
||||||
const gitRef = process.env.WWW_GIT_REF ?? 'master'
|
|
||||||
const giteaApiRepo = `https://git.paulw.xyz/api/v1/repos/xyz/www/`
|
|
||||||
|
|
||||||
async function readFirstLines(filePath, lineCount = 1) {
|
|
||||||
const gitFileFetch = await fetch(`${giteaApiRepo}raw/${filePath}?ref=${gitRef}`)
|
|
||||||
if (!gitFileFetch.ok) return null
|
|
||||||
const file = await gitFileFetch.text()
|
|
||||||
const lines = file.split('\n')
|
|
||||||
const out = []
|
|
||||||
for (let i = 0; i < lineCount && i < lines.length; i++) {
|
|
||||||
out.push(lines[i])
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getTitle(filePath) {
|
|
||||||
const firstLines = await readFirstLines(filePath)
|
|
||||||
if (firstLines === null || firstLines === undefined || firstLines.length === 0) return null
|
|
||||||
let title = firstLines[0]
|
|
||||||
|
|
||||||
if (title.substring(0, 2) !== '# ') return null
|
|
||||||
title = title
|
|
||||||
.substring(1, firstLines[0].length)
|
|
||||||
.trim()
|
|
||||||
if (title.length < 3)
|
|
||||||
return null
|
|
||||||
return title
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getMarkdownMetadata(dir) {
|
|
||||||
const dirGitInfoFetch = await fetch(`${giteaApiRepo}contents/${dir}/?ref=${gitRef}`)
|
|
||||||
if (!dirGitInfoFetch.ok) return {}
|
|
||||||
|
|
||||||
const commits = {}
|
|
||||||
const out = {}
|
|
||||||
|
|
||||||
const dirGitInfo = await dirGitInfoFetch.json()
|
|
||||||
for (const file of dirGitInfo) {
|
|
||||||
if (file.name.startsWith('.') || !file.name.endsWith('.md')) continue
|
|
||||||
const title = await getTitle(file.path)
|
|
||||||
if (title === null) continue
|
|
||||||
|
|
||||||
const slug = file.name.replace(/\.md$/, '')
|
|
||||||
let mtime = new Date(); // better to have an incorrect recent date than the more incorrect unix time 0 (assuming the host doesn't have messed up clock)
|
|
||||||
|
|
||||||
|
|
||||||
if (!(file.last_commit_sha in commits)) {
|
|
||||||
const lastCommitSha = await fetch(`${giteaApiRepo}/git/commits/${file.last_commit_sha}`)
|
|
||||||
if (lastCommitSha.ok) {
|
|
||||||
const commitJson = await lastCommitSha.json()
|
|
||||||
commits[commitJson.sha] = (new Date(commitJson.created))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mtime = commits[file.last_commit_sha]
|
|
||||||
|
|
||||||
out[slug] = {
|
|
||||||
title: title,
|
|
||||||
mtime: mtime.toISOString(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readFilesMetadata(dir) {
|
|
||||||
const filePath = jsonFilePath(dir)
|
|
||||||
try {
|
|
||||||
const fileContent = await fs.readFile(filePath, 'utf-8')
|
|
||||||
const metadata = JSON.parse(fileContent)
|
|
||||||
return metadata
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeFilesMetadata(filePath, metadata) {
|
|
||||||
try {
|
|
||||||
await fs.writeFile(filePath, JSON.stringify(metadata, null, 4), 'utf-8')
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function jsonFilePath(dir) {
|
|
||||||
return path.join(process.cwd(), 'public', `${dir}.json`); // ehh
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateNotesMetadata() {
|
|
||||||
const dir = 'notes'
|
|
||||||
await writeFilesMetadata(jsonFilePath(dir), await getMarkdownMetadata(dir))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generatePostsMetadata() {
|
|
||||||
const dir = 'posts'
|
|
||||||
const currMetadata = await readFilesMetadata(dir)
|
|
||||||
const generatedMetadata = await getMarkdownMetadata(dir)
|
|
||||||
const newMetadata = {}
|
|
||||||
|
|
||||||
for (const [name, data] of Object.entries(generatedMetadata)) {
|
|
||||||
let otime = new Date()
|
|
||||||
if (currMetadata[name]?.otime !== undefined && currMetadata[name]?.otime !== null)
|
|
||||||
otime = currMetadata[name].otime ?? otime
|
|
||||||
else
|
|
||||||
otime = data.mtime ?? otime
|
|
||||||
|
|
||||||
newMetadata[name] = { ...data, otime }
|
|
||||||
}
|
|
||||||
await writeFilesMetadata(jsonFilePath(dir), newMetadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateSiteMap() {
|
|
||||||
await generateNotesMetadata()
|
|
||||||
await generatePostsMetadata()
|
|
||||||
|
|
||||||
const sitemap = {
|
|
||||||
title: 'PaulW.XYZ',
|
|
||||||
pages: await readFilesMetadata('home')
|
|
||||||
}
|
|
||||||
|
|
||||||
const pages = ['posts', 'notes']
|
|
||||||
for (const page of pages) {
|
|
||||||
sitemap.pages[page].pages = await readFilesMetadata(page)
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFilesMetadata(jsonFilePath('sitemap'), sitemap)
|
|
||||||
}
|
|
||||||
|
|
||||||
generateSiteMap()
|
|
||||||
Vendored
-9
@@ -1,9 +0,0 @@
|
|||||||
declare module '*.md' {
|
|
||||||
const rawmd: string;
|
|
||||||
export default rawmd;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module '*.txt' {
|
|
||||||
const content: string;
|
|
||||||
export default content;
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import ReactMarkdown from 'react-markdown';
|
|
||||||
|
|
||||||
import ReadmeMd from '../../../README.md';
|
|
||||||
import License from '../../../LICENSE.txt';
|
|
||||||
|
|
||||||
function AboutPage() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<section className='block'>
|
|
||||||
<p>Paul's Personal Website.</p>
|
|
||||||
<p> You can find me on the following platforms:</p>
|
|
||||||
<ul>
|
|
||||||
<li>X/Twitter: <a href='https://x.com/paulw_xyz'>paulw_xyz</a></li>
|
|
||||||
<li>GitHub: <a href='https://github.com/paulwxyz'>paulwxyz</a></li>
|
|
||||||
{/* <li>BlueSky (unused): <a href='https://bsky.app/profile/@paulw.xyz'>@paulw.xyz</a></li> */}
|
|
||||||
<li><a href='https://git.paulw.xyz/xyz'>git.paulw.xyz</a></li>
|
|
||||||
</ul>
|
|
||||||
<p>
|
|
||||||
The original motivation was to just play with Next.js as it pretty much did the things I wanted web pages to do. But it came at the cost of needless complexity. As I use the JavaScript/ECMAScript/Whatever-you-want-to-call-it-script more and more, I am convinced that it is not a platform worth pursuing because the more complex it gets, the less control I have over what it does and this platform and its users seems to be okay with that sort of loss. I have been instead pivoting toward things that impressed and got me interested in working with computers.</p>
|
|
||||||
<p>Most services/products are keen on going against the <a href='https://stephango.com/file-over-app'>file over app</a> philosophy which entails prioritizing data over software and anticipate and embrace the eventual death of software. People instead want subscription services that barely support open formats and sometimes do not support exporting data to commonly used formats. The goal here is to avoid storing artifacts under locations that are easily not accessible, not under my control, and does not lock me out of using it with other software. The only reason I have not completely abandoned this is thanks to my decision to rely on Markdown files alone. Had it been reliant on any cloud software, I would have started over.</p>
|
|
||||||
|
|
||||||
<p>Got any questions, concerns, or issues? Contact me via email: <code>contact [at] paulw [dot] xyz</code>.</p>
|
|
||||||
</section>
|
|
||||||
<hr />
|
|
||||||
<section className='block'>
|
|
||||||
<p>Source for this site is available on GitHub: <a href='https://github.com/paulwxyz/www'>paulwxyz/www</a> and <a href='https://git.paulw.xyz/xyz/www'>git.paulw.xyz/xyz/www</a></p>
|
|
||||||
<p>Relevant information regarding the source is available on the repo and is also provided below.</p>
|
|
||||||
</section>
|
|
||||||
<section className='block'>
|
|
||||||
<h2>README</h2>
|
|
||||||
<ReactMarkdown>
|
|
||||||
{ReadmeMd.replace(/^#{6}\s+(.*)\s+$/gm, (s: string, a) => `**${a}**\n`).replace(/^#{1,5} /gm, (s: string) => { return `##${s}` })}
|
|
||||||
</ReactMarkdown>
|
|
||||||
</section>
|
|
||||||
<section className='block'>
|
|
||||||
<h2>LICENSE</h2>
|
|
||||||
<pre className='license'>{License}</pre>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AboutPage;
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export default function Container(props: { children?: React.ReactNode, ignore?: boolean }) {
|
|
||||||
if (props.ignore)
|
|
||||||
return <>{props.children}</>;
|
|
||||||
return (
|
|
||||||
<div className='container'>
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
'use client'
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { toRelativeDate } from '../lib/date';
|
|
||||||
export function NoteEntry({ note }: { note: { title: string, mtime: string, slug: string } }) {
|
|
||||||
return (
|
|
||||||
<tr>
|
|
||||||
<td style={{ flex: '1 0 50%' }}>
|
|
||||||
<Link href={`/notes/${note.slug}`}>
|
|
||||||
{note.title}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
<td style={{ fontStyle: 'italic' }}>
|
|
||||||
{note.mtime && toRelativeDate(note.mtime)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
import Pages from '../../../public/external.json';
|
|
||||||
|
|
||||||
function QuickLinks() {
|
|
||||||
return (
|
|
||||||
<div className='block'>
|
|
||||||
{
|
|
||||||
Object.entries(Pages).map(([title, link]) => {
|
|
||||||
const extern = link.match(/^http/) && `blue extern` || '';
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={link}
|
|
||||||
href={link}
|
|
||||||
className={`${extern} link button`}>
|
|
||||||
{title}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default QuickLinks;
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import NotesInfo from '../../../public/notes.json';
|
|
||||||
|
|
||||||
function RecentNotes() {
|
|
||||||
const notes = Object.entries(NotesInfo)
|
|
||||||
.map(([slug, note]) => {
|
|
||||||
return {
|
|
||||||
slug,
|
|
||||||
title: note.title,
|
|
||||||
mtime: new Date(note.mtime)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.sort(
|
|
||||||
(a, b) => {
|
|
||||||
return b.mtime.getTime() - a.mtime.getTime();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div className='block'>
|
|
||||||
<h2>Recent Notes</h2>
|
|
||||||
<ul>
|
|
||||||
{notes?.slice(0, 5)
|
|
||||||
.map(({slug, title, mtime}) => {
|
|
||||||
return (
|
|
||||||
<li key={slug} >
|
|
||||||
<Link href={`/notes/${slug}`}>{title}</Link>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
{
|
|
||||||
notes.length > 5 &&
|
|
||||||
<Link href='/notes'>More...</Link>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default RecentNotes;
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
.container {
|
|
||||||
border-bottom-left-radius: 0.5rem;
|
|
||||||
border-bottom: 1px dashed var(--main-border-color);
|
|
||||||
border-left: 1px dashed var(--main-border-color);
|
|
||||||
padding-top: 1.25rem;
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block {
|
|
||||||
margin: 0;
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block+.block {
|
|
||||||
border-top: 1px dashed var(--main-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.block:first-of-type {
|
|
||||||
border-top-right-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block:nth-of-type(2n) {
|
|
||||||
background-color: var(--table-even-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.block:nth-of-type(2n+1) {
|
|
||||||
background-color: var(--table-odd-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.postTitle {
|
|
||||||
flex: 1 1 60%;
|
|
||||||
padding: .25rem 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.postDate {
|
|
||||||
flex: 1 1;
|
|
||||||
display: inline-block;
|
|
||||||
text-align: right;
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 1rem;
|
|
||||||
padding: .25rem 0.50rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.more {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.more a {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { toRelativeDate } from "../lib/date";
|
|
||||||
import style from './recent-posts.module.css';
|
|
||||||
import PostsInfo from '../../../public/posts.json';
|
|
||||||
|
|
||||||
function PostBlock({ slug, otime, title }: { slug: string, otime: string, title: string }) {
|
|
||||||
return (
|
|
||||||
<div className={style.block}>
|
|
||||||
<span className={style.postDate}>
|
|
||||||
{toRelativeDate(new Date(otime))}
|
|
||||||
</span>
|
|
||||||
<div className={style.postTitle}>
|
|
||||||
<Link href={`/posts/${slug}`}>
|
|
||||||
{title}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RecentPosts() {
|
|
||||||
const posts = Object.entries(PostsInfo).reverse();
|
|
||||||
if (!posts.length)
|
|
||||||
return <></>;
|
|
||||||
return (
|
|
||||||
<div className='block'>
|
|
||||||
<h2>Recent Posts</h2>
|
|
||||||
<div className={style.container}>
|
|
||||||
{posts?.slice(0, 10)
|
|
||||||
.map(([slug, post]: any, i: number) => {
|
|
||||||
return (
|
|
||||||
<PostBlock
|
|
||||||
key={slug}
|
|
||||||
slug={slug}
|
|
||||||
title={post.title}
|
|
||||||
otime={post.otime} />
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{
|
|
||||||
posts.length > 10 &&
|
|
||||||
<div className={style.more}>
|
|
||||||
<Link href='/posts' >More...</Link>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default RecentPosts;
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
.container {
|
|
||||||
text-align: center;
|
|
||||||
margin: auto;
|
|
||||||
background-color: var(--main-background-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.container .title {
|
|
||||||
border-bottom: 1px solid #FFFFFF;
|
|
||||||
max-width: 95%;
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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: linear-gradient(to bottom right, #1a3a15, #09351b) no-repeat center center fixed;
|
|
||||||
overflow-y: hidden;
|
|
||||||
overflow-x: auto;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
'use client'
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
import { Fragment } from 'react';
|
|
||||||
|
|
||||||
import style from './title.module.css';
|
|
||||||
import SiteMap from '../../../public/sitemap.json';
|
|
||||||
import { Sites } from '../lib/site';
|
|
||||||
|
|
||||||
function createPathElements(ancestors: Array<{ name: string, path: string }>) {
|
|
||||||
let currentPath = '';
|
|
||||||
return ancestors.map((ancestor, id) => {
|
|
||||||
currentPath += `/${ancestor.path}`
|
|
||||||
return (
|
|
||||||
<Fragment key={currentPath} >
|
|
||||||
<Link href={currentPath}>{ancestor.name}</Link>
|
|
||||||
<> / </>
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Title() {
|
|
||||||
const pagePath = usePathname();
|
|
||||||
const splitPath: Array<{ name: string, path: string }> = [];
|
|
||||||
|
|
||||||
// TODO(Paul): clean this up
|
|
||||||
let currRoot: Sites = SiteMap.pages;
|
|
||||||
let title: string | null = null;
|
|
||||||
if (pagePath && pagePath !== '/') {
|
|
||||||
const subPaths = pagePath.split('?')[0].split('#')[0].split('/');
|
|
||||||
for (const p of subPaths.slice(1, subPaths.length)) {
|
|
||||||
if (!p || !currRoot[p])
|
|
||||||
continue;
|
|
||||||
splitPath.push({ name: currRoot[p].title, path: p });
|
|
||||||
|
|
||||||
if (currRoot === undefined
|
|
||||||
|| currRoot[p] === undefined
|
|
||||||
|| currRoot[p].pages === undefined)
|
|
||||||
break;
|
|
||||||
currRoot = currRoot[p].pages!;
|
|
||||||
}
|
|
||||||
if (splitPath !== undefined && splitPath.length > 0)
|
|
||||||
title = splitPath.pop()!.name;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
const pathElements = splitPath && createPathElements(splitPath) || <></>;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* <head>
|
|
||||||
<title>{title && `${title} | PaulW.XYZ` || 'PaulW.XYZ'}</title>
|
|
||||||
</head> */}
|
|
||||||
<div className={style.container}>
|
|
||||||
<h1 className={style.title}>
|
|
||||||
{title || 'PaulW.XYZ'}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<div className={style.nav}>
|
|
||||||
{
|
|
||||||
title
|
|
||||||
? <><Link href='/'>PaulW.XYZ</Link> / {pathElements}{title}</>
|
|
||||||
: <>PaulW.XYZ /</>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,414 +0,0 @@
|
|||||||
: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);
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Cantarell';
|
|
||||||
src: url('/assets/fonts/Cantarell/Cantarell-Regular.otf') format('opentype');
|
|
||||||
font-weight: normal;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Cantarell';
|
|
||||||
src: url('/assets/fonts/Cantarell/Cantarell-Thin.otf') format('opentype');
|
|
||||||
font-weight: 100;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Cantarell';
|
|
||||||
src: url('/assets/fonts/Cantarell/Cantarell-Light.otf') format('opentype');
|
|
||||||
font-weight: 300;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Cantarell';
|
|
||||||
src: url('/assets/fonts/Cantarell/Cantarell-Bold.otf') format('opentype');
|
|
||||||
font-weight: bold;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Cantarell';
|
|
||||||
src: url('/assets/fonts/Cantarell/Cantarell-ExtraBold.otf') format('opentype');
|
|
||||||
font-weight: 800;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'EB Garamond';
|
|
||||||
src: url('/assets/fonts/EB_Garamond/static/EBGaramond-Regular.ttf') format('truetype');
|
|
||||||
font-weight: normal;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'EB Garamond';
|
|
||||||
src: url('/assets/fonts/EB_Garamond/static/EBGaramond-Bold.ttf') format('truetype');
|
|
||||||
font-weight: bold;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'EB Garamond';
|
|
||||||
src: url('/assets/fonts/EB_Garamond/static/EBGaramond-ExtraBold.ttf') format('truetype');
|
|
||||||
font-weight: 800;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Hack';
|
|
||||||
src: url('/assets/fonts/Hack/hack-regular-subset.woff2') format('woff2'), url('/assets/fonts/Hack/hack-regular-subset.woff') format('woff');
|
|
||||||
font-weight: normal;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Hack';
|
|
||||||
src: url('/assets/fonts/Hack/hack-bold-subset.woff2') format('woff2'), url('/assets/fonts/Hack/hack-bold-subset.woff') format('woff');
|
|
||||||
font-weight: bold;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Hack';
|
|
||||||
src: url('/assets/fonts/Hack/hack-italic-subset.woff2') format('woff2'), url('/assets/fonts/Hack/hack-italic-webfont.woff') format('woff');
|
|
||||||
font-weight: normal;
|
|
||||||
font-style: italic;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Hack';
|
|
||||||
src: url('/assets/fonts/Hack/hack-bolditalic-subset.woff2') format('woff2'), url('/assets/fonts/Hack/hack-bolditalic-subset.woff') format('woff');
|
|
||||||
font-weight: bold;
|
|
||||||
font-style: italic;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
article,
|
|
||||||
aside,
|
|
||||||
figcaption,
|
|
||||||
figure,
|
|
||||||
footer,
|
|
||||||
header,
|
|
||||||
hgroup,
|
|
||||||
main,
|
|
||||||
nav,
|
|
||||||
section {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Cantarell', 'Trebuchet MS', 'Lucida Sans Unicode', 'Lucida Grande', 'Lucida Sans', 'Helvetica Neue', 'Helvetica', Arial, sans-serif;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
hr {
|
|
||||||
box-sizing: content-box;
|
|
||||||
height: 0;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4,
|
|
||||||
h5,
|
|
||||||
h6 {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
|
||||||
.h1 {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2,
|
|
||||||
.h2 {
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3,
|
|
||||||
.h3 {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h4,
|
|
||||||
.h4 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h5,
|
|
||||||
.h5 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h6,
|
|
||||||
.h6 {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--link-color);
|
|
||||||
text-decoration: underline;
|
|
||||||
background-color: transparent;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:focus {
|
|
||||||
text-decoration: underline dotted;
|
|
||||||
}
|
|
||||||
|
|
||||||
section {
|
|
||||||
margin: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 100%;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre,
|
|
||||||
kbd,
|
|
||||||
code {
|
|
||||||
font-family: 'Hack', 'Source Code Pro', Consolas, monospace;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
margin: 1rem auto;
|
|
||||||
width:100%;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
table thead+tbody tr {
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
table thead {
|
|
||||||
background: var(--secondary-green);
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
table tbody,
|
|
||||||
table tr:last-of-type
|
|
||||||
{
|
|
||||||
border-bottom-left-radius: 0.5rem;
|
|
||||||
border-bottom-right-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
table tbody tr:nth-of-type(2n) {
|
|
||||||
background-color: var(--table-even-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
table tbody tr:nth-of-type(2n+1) {
|
|
||||||
background-color: var(--table-odd-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
table thead tr th,
|
|
||||||
table tbody tr td {
|
|
||||||
padding: .25rem 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul li {
|
|
||||||
list-style-type: square;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lambda-logo {
|
|
||||||
width: 256px;
|
|
||||||
height: 256px;
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
margin: 0 0.5rem;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (min-width: 818px) {
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:hover {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:focus {
|
|
||||||
box-shadow: 0 0 0 2px #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.blue {
|
|
||||||
background: var(--primary-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.blue:hover {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.blue:focus {
|
|
||||||
box-shadow: 0 0 0 2px #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text.center {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.none.display {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.link::after {
|
|
||||||
content: ' \2192';
|
|
||||||
display: inline-block;
|
|
||||||
transition: 50ms ease-in-out all;
|
|
||||||
margin-left: 0.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.link:hover {
|
|
||||||
display: inline-block;
|
|
||||||
transition: 50ms ease-in-out all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.link:hover::after {
|
|
||||||
transform: translateX(0.25rem) scale(1.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.link.extern:hover::after {
|
|
||||||
transform: rotateZ(-45deg) scale(1.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.link.back::before {
|
|
||||||
content: ' \2190';
|
|
||||||
display: inline-block;
|
|
||||||
transition: 100ms ease-in-out all;
|
|
||||||
margin-right: 0.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.link:hover::before {
|
|
||||||
transform: translateX(-0.2rem) scale(1.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.link.back::after {
|
|
||||||
content: '';
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sans.serif {
|
|
||||||
font-family: 'Cantarell', 'Trebuchet MS', 'Lucida Sans Unicode', 'Lucida Grande', 'Lucida Sans', 'Helvetica Neue', 'Helvetica', Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.serif {
|
|
||||||
font-family: 'EB Garamond', 'Garamond', 'Times New Roman', Times, serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.monospace {
|
|
||||||
font-family: 'Hack', 'Source Code Pro', Consolas, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.license {
|
|
||||||
background-color: #222222;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import type {Metadata} from 'next'
|
|
||||||
import 'normalize.css'
|
|
||||||
import './global.css'
|
|
||||||
import Container from './components/container'
|
|
||||||
import Title from './components/title'
|
|
||||||
|
|
||||||
export default function RootLayout({children,}: Readonly<{children: React.ReactNode}>) {
|
|
||||||
return (
|
|
||||||
<html lang='en'>
|
|
||||||
<body>
|
|
||||||
<Title />
|
|
||||||
<Container>
|
|
||||||
{children}
|
|
||||||
</Container>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
// getMonth() method ranges from 0-11 so no reason to account for it
|
|
||||||
const months = [
|
|
||||||
'January',
|
|
||||||
'February',
|
|
||||||
'March',
|
|
||||||
'April',
|
|
||||||
'May',
|
|
||||||
'June',
|
|
||||||
'July',
|
|
||||||
'August',
|
|
||||||
'September',
|
|
||||||
'October',
|
|
||||||
'November',
|
|
||||||
'December'
|
|
||||||
];
|
|
||||||
|
|
||||||
function get12HourTime(pdate: Date | string): string {
|
|
||||||
const date = (typeof pdate === 'string') ? new Date(pdate) : pdate;
|
|
||||||
let hours = date.getHours();
|
|
||||||
const minutes = date.getMinutes();
|
|
||||||
let meridiem = 'A.M.';
|
|
||||||
|
|
||||||
let strhours = ''
|
|
||||||
|
|
||||||
if (hours > 12) {
|
|
||||||
hours -= 12;
|
|
||||||
meridiem = 'P.M.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hours === 0)
|
|
||||||
hours = 12;
|
|
||||||
|
|
||||||
return `${hours}:${minutes < 10 ? '0' : ''}${minutes} ${meridiem}`;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
function toHumanReadableDate(date: Date | string, disable?: { year?: boolean, month?: boolean, day?: boolean }) {
|
|
||||||
const oDate = (typeof date === 'string') ? new Date(date) : date;
|
|
||||||
|
|
||||||
const year = oDate.getFullYear();
|
|
||||||
const month = months[oDate.getMonth()];
|
|
||||||
const day = oDate.getDate();
|
|
||||||
const suffix = getOrdinalDaySuffix(day)
|
|
||||||
let out = '';
|
|
||||||
out = !disable?.month ? `${month}` : '';
|
|
||||||
out = !disable?.day ? `${out} ${day}${suffix}` : out;
|
|
||||||
out = !disable?.year ? `${out}, ${year}` : out;
|
|
||||||
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function getOrdinalDaySuffix(day: number): string {
|
|
||||||
switch (day) {
|
|
||||||
case 1:
|
|
||||||
case 21:
|
|
||||||
case 31:
|
|
||||||
return 'st';
|
|
||||||
case 2:
|
|
||||||
case 22:
|
|
||||||
return 'nd';
|
|
||||||
case 3:
|
|
||||||
case 23:
|
|
||||||
return 'rd';
|
|
||||||
default:
|
|
||||||
return 'th';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toLocaleString(pdate: Date | string): string {
|
|
||||||
const date = (typeof pdate === 'string') ? new Date(pdate) : pdate;
|
|
||||||
return `${toHumanReadableDate(date)} at ${get12HourTime(date)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toRelativeDate(date: Date | string): string {
|
|
||||||
const oDate = (typeof date === 'string') ? new Date(date) : date;
|
|
||||||
|
|
||||||
|
|
||||||
if (!isValid(oDate)) {
|
|
||||||
return 'Invalid Date';
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const diff = now.getTime() - oDate.getTime();
|
|
||||||
|
|
||||||
let tdiff = Math.floor(diff / 1000);
|
|
||||||
|
|
||||||
if (tdiff < 0) {
|
|
||||||
return toHumanReadableDate(oDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tdiff < 60) {
|
|
||||||
return `${tdiff} seconds ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
tdiff = Math.floor(tdiff / 60);
|
|
||||||
if (tdiff < 60) {
|
|
||||||
return `${tdiff} minute${tdiff === 1 ? '' : 's'} ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
tdiff = Math.floor(tdiff / 60);
|
|
||||||
if (tdiff < 24) {
|
|
||||||
return `${tdiff} hour${tdiff === 1 ? '' : 's'} ago`;
|
|
||||||
}
|
|
||||||
if (tdiff < 48) {
|
|
||||||
return `Yesterday`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (oDate.getFullYear() != now.getFullYear())
|
|
||||||
return toHumanReadableDate(oDate);
|
|
||||||
return toHumanReadableDate(oDate, { year: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getFullMonth(month: number) {
|
|
||||||
if (month >= 1 && month <= 12)
|
|
||||||
return months[month];
|
|
||||||
return 'Invalid Month';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isValid(date: any) {
|
|
||||||
return (new Date(date)).toString() !== 'Invalid Date';
|
|
||||||
}
|
|
||||||
|
|
||||||
const DateTool = {
|
|
||||||
toRelativeDate,
|
|
||||||
getFullMonth,
|
|
||||||
isValid,
|
|
||||||
getOrdinalDaySuffix,
|
|
||||||
toLocaleString,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DateTool;
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { readFile } from 'fs/promises';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
export default async function readMarkdown(directory: string, slug: string, withoutTitle: boolean = false): Promise<string> {
|
|
||||||
const content = await readFile(path.join(process.cwd(), directory, `${slug}.md`), 'utf-8');
|
|
||||||
if (withoutTitle)
|
|
||||||
return content.substring(content.indexOf('\n') + 1, content.length);
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
|
|
||||||
export interface Site {
|
|
||||||
title: string;
|
|
||||||
pages?: Sites;
|
|
||||||
mtime?: string;
|
|
||||||
otime?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Sites {
|
|
||||||
[slug: string]: Site;
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import style from '../components/title.module.css';
|
|
||||||
|
|
||||||
function NotFoundPage() {
|
|
||||||
// TODO: figure out a way to somehow get next to ignore layout in special cases. tried /not-found/page.tsx but it doesn't work :X
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* <head>
|
|
||||||
<title>404: Not Found | PaulW.XYZ</title>
|
|
||||||
</head>
|
|
||||||
<div className={style.container}>
|
|
||||||
<h1 className={style.title}>
|
|
||||||
Page Not Found
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<div className={`${style.nav} h1`}><Link href='/'>PaulW.XYZ</Link> / ... ??? / 404: Not Found</div>
|
|
||||||
<div className='container'>*/}
|
|
||||||
<section className='block text center'>
|
|
||||||
<h1>Error 404</h1>
|
|
||||||
<p>
|
|
||||||
<strong>Uh oh! The page you are looking for does not exist...</strong><br />
|
|
||||||
</p>
|
|
||||||
<Link href='/' className='button green back link'>Go Home</Link>
|
|
||||||
<a className='button blue link extern' href='https://en.wikipedia.org/wiki/List_of_HTTP_status_codes'>
|
|
||||||
More on HTTP status codes
|
|
||||||
</a>
|
|
||||||
</section>
|
|
||||||
{/*</div>*/}
|
|
||||||
</>
|
|
||||||
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default NotFoundPage;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
.last-updated {
|
|
||||||
text-align: right;
|
|
||||||
display: block;
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 1rem;
|
|
||||||
margin: 0.5rem 0.75rem;
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import ReactMarkdown from 'react-markdown';
|
|
||||||
import { PluggableList } from 'unified';
|
|
||||||
|
|
||||||
import remarkGfm from 'remark-gfm';
|
|
||||||
import remarkMath from 'remark-math';
|
|
||||||
|
|
||||||
import rehypeKatex from 'rehype-katex';
|
|
||||||
import rehypeRaw from 'rehype-raw';
|
|
||||||
import rehypeSlug from 'rehype-slug';
|
|
||||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
|
|
||||||
import rehypeHighlight from 'rehype-highlight';
|
|
||||||
import rehypeHighlightCodeLines, { type HighlightLinesOptions } from 'rehype-highlight-code-lines';
|
|
||||||
|
|
||||||
import readMarkdown from '../../lib/read-markdown';
|
|
||||||
import { toLocaleString } from '../../lib/date';
|
|
||||||
import NotesInfo from '../../../../public/notes.json';
|
|
||||||
|
|
||||||
import style from './note.module.css';
|
|
||||||
import 'highlight.js/styles/monokai-sublime.css';
|
|
||||||
import 'katex/dist/katex.min.css';
|
|
||||||
|
|
||||||
interface Note {
|
|
||||||
title: string,
|
|
||||||
mtime: string,
|
|
||||||
content?: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Notes {
|
|
||||||
[slug: string]: Note;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Markdown({ content }: any) {
|
|
||||||
const remarkPlugins: PluggableList = [
|
|
||||||
remarkGfm,
|
|
||||||
remarkMath,
|
|
||||||
];
|
|
||||||
const rehypePlugins: PluggableList = [
|
|
||||||
rehypeSlug,
|
|
||||||
rehypeAutolinkHeadings,
|
|
||||||
rehypeRaw,
|
|
||||||
rehypeHighlight,
|
|
||||||
rehypeKatex,
|
|
||||||
];
|
|
||||||
return <ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins}>
|
|
||||||
{content}
|
|
||||||
</ReactMarkdown>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function Note({params}: {params: Promise<{note: string}>}) {
|
|
||||||
const note = (await params).note
|
|
||||||
const n = await getNotes(note)
|
|
||||||
return (<>
|
|
||||||
<span className={style['last-updated']}>
|
|
||||||
Last updated: {toLocaleString(n.mtime)}
|
|
||||||
</span>
|
|
||||||
<section className='block'>
|
|
||||||
<Markdown content={n.content} />
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getNotes(name: string) {
|
|
||||||
const notesInfo: Notes = NotesInfo;
|
|
||||||
return {...notesInfo[name], content: await readMarkdown('notes', name, true)}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import NotesInfo from '../../../public/notes.json';
|
|
||||||
import { NoteEntry } from '../components/note-entry';
|
|
||||||
|
|
||||||
function NotesPage() {
|
|
||||||
const notes = Object.entries(NotesInfo)
|
|
||||||
.map(([slug, note]) => {
|
|
||||||
return {
|
|
||||||
slug,
|
|
||||||
title: note.title,
|
|
||||||
mtime: new Date(note.mtime)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.sort(
|
|
||||||
(a, b) => {
|
|
||||||
return b.mtime.getTime() - a.mtime.getTime();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{
|
|
||||||
!notes || notes.length === 0
|
|
||||||
&& <>No notes found</>
|
|
||||||
|| <table>
|
|
||||||
<tbody>
|
|
||||||
{notes.map(
|
|
||||||
(note: any) => {
|
|
||||||
return (<NoteEntry note={note} key={note.slug} />);
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export default NotesPage;
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import QuickLinks from './components/quick-links';
|
|
||||||
import RecentNotes from './components/recent-notes';
|
|
||||||
import RecentPosts from './components/recent-posts';
|
|
||||||
import RootInfo from '../../public/home.json';
|
|
||||||
|
|
||||||
function Nav() {
|
|
||||||
const nav = Object.entries(RootInfo);
|
|
||||||
return (
|
|
||||||
<div className='block'>
|
|
||||||
<h2>Navigation</h2>
|
|
||||||
{
|
|
||||||
nav.map(([slug, info]) => {
|
|
||||||
return <Link key={slug} href={slug} className='button green'>{info.title}</Link>
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function HomePage() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<QuickLinks />
|
|
||||||
<RecentPosts />
|
|
||||||
<RecentNotes />
|
|
||||||
<Nav />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default HomePage;
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import ReactMarkdown from 'react-markdown';
|
|
||||||
import style from '../../styles/post.module.css';
|
|
||||||
import PostsInfo from '../../../../public/posts.json';
|
|
||||||
import readMarkdown from '../../lib/read-markdown';
|
|
||||||
import DateTool, { toLocaleString } from '../../lib/date';
|
|
||||||
|
|
||||||
interface IPost {
|
|
||||||
title: string;
|
|
||||||
mtime: string;
|
|
||||||
otime?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TimeBlock({ mtime, otime }: { mtime: string, otime: string }) {
|
|
||||||
const ampm = (h: number) => { if (h >= 12) return 'p.m.'; return 'a.m.'; };
|
|
||||||
|
|
||||||
const mdate = new Date(mtime);
|
|
||||||
const odate = new Date(otime);
|
|
||||||
|
|
||||||
const format = (date: Date) => {
|
|
||||||
const day = date.getDay();
|
|
||||||
const ord = <sup>{DateTool.getOrdinalDaySuffix(date.getDay())}</sup>;
|
|
||||||
const month = DateTool.getFullMonth(date.getMonth());
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const hours = date.getHours() > 12 ? date.getHours() - 12 : date.getHours();
|
|
||||||
const minPrefix = date.getMinutes() < 10 ? '0' : '';
|
|
||||||
const minutes = date.getMinutes();
|
|
||||||
const twelveSfx = ampm(date.getHours());
|
|
||||||
return <>{day}{ord} {month} {year} at {hours}:{minPrefix}{minutes} {twelveSfx}</>
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ textAlign: 'right', fontSize: '16px', fontFamily: 'Cantarell', fontStyle: 'italic' }}>
|
|
||||||
{
|
|
||||||
mtime ?
|
|
||||||
<div className='mtime' data-text={mdate.toISOString()}>
|
|
||||||
Last updated: {format(mdate)}
|
|
||||||
</div>
|
|
||||||
:
|
|
||||||
<></>
|
|
||||||
}
|
|
||||||
<div className='otime'>
|
|
||||||
{format(odate)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// post: IPost & { content: string, cover?: string, otime: string, mtime?: string }
|
|
||||||
export default async function Post({ params }: {params: Promise<{post: string}>}) {
|
|
||||||
const post = await getPost((await params).post);
|
|
||||||
if (!post)
|
|
||||||
return <></>;
|
|
||||||
return (<>
|
|
||||||
<div className='container'>
|
|
||||||
{ post.otime !== post.mtime && post.mtime &&
|
|
||||||
<span className={style.time}>
|
|
||||||
Last updated: {toLocaleString(post.mtime)}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
<span className={style.time}>
|
|
||||||
{toLocaleString(post.otime)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{<div className={style.imageBlock}
|
|
||||||
style={{
|
|
||||||
backgroundImage:
|
|
||||||
post.cover ?
|
|
||||||
`url(/assets/images/${post.cover})` :
|
|
||||||
'linear-gradient(to bottom right, rgb(5, 51, 11), rgb(5, 45, 13) 15%, rgb(5, 39,15) 40%, rgb(0, 30, 16) 80%)'
|
|
||||||
}}></div>}
|
|
||||||
<div className={`${style.spacer} ${post.cover ? style.background : ''}`}></div>
|
|
||||||
<section className={`${style.block} block`}>
|
|
||||||
<div className='container'>
|
|
||||||
<ReactMarkdown>{post.content}</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<div className={style.spacer}></div>
|
|
||||||
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getPost(n: string) {
|
|
||||||
const postsInfo: Record<string, (IPost & { cover?: string, otime: string, mtime?: string })> = PostsInfo;
|
|
||||||
return {...postsInfo[n], content: await readMarkdown('posts', n, true)};
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
import date from '../lib/date';
|
|
||||||
import PostsInfo from '../../../public/posts.json';
|
|
||||||
|
|
||||||
function PostsPage() {
|
|
||||||
return (<>
|
|
||||||
{Object.keys(PostsInfo).length && <Posts /> || <NoPosts />}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NoPosts() {
|
|
||||||
return (<div className='text center'>
|
|
||||||
<div>**crickets**</div>
|
|
||||||
<div>No posts found...</div>
|
|
||||||
<div><Link href='/' className='link button green back'>Go Home</Link></div>
|
|
||||||
</div>);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Posts() {
|
|
||||||
const posts = Object.entries(PostsInfo);
|
|
||||||
return (
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
{
|
|
||||||
posts.map(([slug, post]: [string, any]) => {
|
|
||||||
return (<tr key={slug} style={{ alignItems: 'center' }}>
|
|
||||||
<td style={{ display: 'inline-block', textAlign: 'right', fontSize: '0.9rem' }}>
|
|
||||||
<div style={{ fontStyle: 'italics', fontSize: '.8rem' }}>{
|
|
||||||
post.mtime && (post.mtime != post.otime) && `Updated ${date.toRelativeDate(new Date(post.mtime))}`
|
|
||||||
}</div>
|
|
||||||
<div>{date.toRelativeDate(new Date(post.otime))}</div>
|
|
||||||
</td>
|
|
||||||
<td style={{
|
|
||||||
fontFamily: `'EB Garamond', 'Garamond', 'Times New Roman', Times, serif`
|
|
||||||
, fontSize: '1.25rem'
|
|
||||||
}}>
|
|
||||||
<Link href={`/posts/${slug}`} style={{ textDecoration: 'none' }}>{post.title}</Link>
|
|
||||||
</td>
|
|
||||||
</tr>)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export default PostsPage;
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
import { Sites } from '../lib/site';
|
|
||||||
import SiteMap from '../../../public/sitemap.json';
|
|
||||||
|
|
||||||
function Desc(props: any) {
|
|
||||||
return (
|
|
||||||
<dl style={props.style}>
|
|
||||||
<dt>{props.term}</dt>
|
|
||||||
<dd>{props.details}</dd>
|
|
||||||
{props.children}
|
|
||||||
</dl>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function traverseMap(head?: Sites, cwd = '', depth = 0) {
|
|
||||||
if (!head) return [];
|
|
||||||
let elements = [];
|
|
||||||
for (const [slug, site] of Object.entries(head)) {
|
|
||||||
if (slug === 'sitemap')
|
|
||||||
continue;
|
|
||||||
|
|
||||||
let details;
|
|
||||||
let list;
|
|
||||||
|
|
||||||
const path = `${cwd}/${slug}`;
|
|
||||||
details = <Link href={path}>paulw.xyz{path}</Link>;
|
|
||||||
list = traverseMap(site.pages, path, depth + 1);
|
|
||||||
|
|
||||||
elements.push(<Desc style={{marginLeft: '3rem'}} key={site.title} term={site.title} details={details}>{list}</Desc>)
|
|
||||||
}
|
|
||||||
return elements;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SiteMapPage() {
|
|
||||||
return <>
|
|
||||||
{traverseMap(SiteMap.pages)}
|
|
||||||
</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SiteMapPage;
|
|
||||||
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
.imageBlock {
|
|
||||||
position: fixed;
|
|
||||||
z-index: -1;
|
|
||||||
right: 0;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
background-size: cover;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: top center;
|
|
||||||
min-height: 100%;
|
|
||||||
background-attachment: fixed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block {
|
|
||||||
font-family: 'EB Garamond', 'Garamond', 'Times New Roman', Times, serif;
|
|
||||||
background-color: rgba(13, 17, 23, 0.97);
|
|
||||||
margin: 0 auto;
|
|
||||||
border-radius: 0;
|
|
||||||
font-size: 1.4rem;
|
|
||||||
line-height: 2.5rem;
|
|
||||||
padding: 2rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spacer {
|
|
||||||
height: 6.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.background.spacer {
|
|
||||||
height: 25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time {
|
|
||||||
text-align: center;
|
|
||||||
display: block;
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 1rem;
|
|
||||||
margin: 0.5rem 0.75rem;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: 'Cardo';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url('@fontsource/cardo/files/cardo-latin-400-normal.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Cardo';
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url('@fontsource/cardo/files/cardo-latin-400-italic.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Cardo';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 700;
|
||||||
|
src: url('@fontsource/cardo/files/cardo-latin-700-normal.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 100;
|
||||||
|
src: url('@fontsource/inter/files/inter-latin-100-normal.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 300;
|
||||||
|
src: url('@fontsource/inter/files/inter-latin-300-normal.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url('@fontsource/inter/files/inter-latin-400-normal.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 700;
|
||||||
|
src: url('@fontsource/inter/files/inter-latin-700-normal.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 800;
|
||||||
|
src: url('@fontsource/inter/files/inter-latin-800-normal.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Iosevka';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url('@fontsource/iosevka/files/iosevka-latin-400-normal.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Iosevka';
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url('@fontsource/iosevka/files/iosevka-latin-400-italic.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Iosevka';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 700;
|
||||||
|
src: url('@fontsource/iosevka/files/iosevka-latin-700-normal.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Iosevka';
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 700;
|
||||||
|
src: url('@fontsource/iosevka/files/iosevka-latin-700-italic.woff2') format('woff2');
|
||||||
|
}
|
||||||
+829
@@ -0,0 +1,829 @@
|
|||||||
|
:root {
|
||||||
|
--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;
|
||||||
|
}
|
||||||
|
|
||||||
|
article,
|
||||||
|
aside,
|
||||||
|
figcaption,
|
||||||
|
figure,
|
||||||
|
footer,
|
||||||
|
header,
|
||||||
|
hgroup,
|
||||||
|
main,
|
||||||
|
nav,
|
||||||
|
section {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
[tabindex='-1']:focus {
|
||||||
|
outline: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
box-sizing: content-box;
|
||||||
|
height: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
.h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2,
|
||||||
|
.h2 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3,
|
||||||
|
.h3 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4,
|
||||||
|
.h4 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h5,
|
||||||
|
.h5 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h6,
|
||||||
|
.h6 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--link-color);
|
||||||
|
text-decoration: underline;
|
||||||
|
background-color: transparent;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:focus {
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:focus-visible,
|
||||||
|
summary:focus-visible,
|
||||||
|
.sidenote-number:focus-visible {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
margin: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre,
|
||||||
|
kbd,
|
||||||
|
code {
|
||||||
|
font-family: var(--monospace-font);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
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 {
|
||||||
|
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 {
|
||||||
|
background: var(--secondary-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-table tbody tr {
|
||||||
|
transition: 100ms ease-in-out all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-table tbody tr td:first-child {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.posts-list {
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
font-family: var(--serif-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.posts-list li {
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.posts-list time {
|
||||||
|
margin-left: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent ul {
|
||||||
|
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(--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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-date {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-date:hover::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
table thead tr th,
|
||||||
|
table tbody tr td {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-last-updated {
|
||||||
|
font-style: italic;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul li {
|
||||||
|
list-style-type: square;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
margin: 0 0.5rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 818px) {
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc summary {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.posts-page {
|
||||||
|
font-family: var(--serif-font);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.posts-page p {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-page.posts-page h2,
|
||||||
|
.article-page.posts-page h3,
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-page .block {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-page h2,
|
||||||
|
.article-page h3,
|
||||||
|
.article-page h4,
|
||||||
|
.article-page h5,
|
||||||
|
.article-page h6 {
|
||||||
|
font-family: var(--sans-serif-font);
|
||||||
|
line-height: 1.25;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.posts-page h2::before,
|
||||||
|
.posts-page h3::before,
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.posts-page h2:hover::before,
|
||||||
|
.posts-page h3:hover::before,
|
||||||
|
.posts-page h4:hover::before,
|
||||||
|
.posts-page h5:hover::before,
|
||||||
|
.posts-page h6:hover::before {
|
||||||
|
content: '§';
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-page .toc {
|
||||||
|
font-family: var(--sans-serif-font);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc details {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc > details > ol {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc ol ol ol {
|
||||||
|
list-style-type: lower-roman;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc li {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
padding-left: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc a:hover {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-page pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--main-border-color);
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-page .code-block {
|
||||||
|
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.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(--secondary-border-color);
|
||||||
|
color: #ffffff;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-code:hover {
|
||||||
|
background-color: var(--tertiary-green);
|
||||||
|
color: #cccccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-code:focus-visible {
|
||||||
|
outline: 2px solid var(--link-color);
|
||||||
|
outline-offset: -3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-page pre.line-numbers {
|
||||||
|
padding-left: 3.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-page pre > code {
|
||||||
|
display: block;
|
||||||
|
overflow: visible;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-page hr {
|
||||||
|
margin: 2rem 0;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--main-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-page table {
|
||||||
|
width: max-content;
|
||||||
|
border: 1px solid var(--main-border-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-scroll {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-page table thead {
|
||||||
|
background-color: var(--secondary-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-page table th,
|
||||||
|
.article-page table td {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.margin-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidenote {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidenote-number {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-page {
|
||||||
|
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::before {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-number {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 1400px) {
|
||||||
|
.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::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-page .toc summary {
|
||||||
|
cursor: default;
|
||||||
|
list-style: none;
|
||||||
|
pointer-events: 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:focus {
|
||||||
|
box-shadow: 0 0 0 2px #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.blue {
|
||||||
|
background: var(--primary-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.blue:hover {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.blue:focus {
|
||||||
|
box-shadow: 0 0 0 2px #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text.center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.link::after {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.link:hover::after {
|
||||||
|
transform: translateX(0.25rem) scale(1.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.link.extern::after {
|
||||||
|
transform: rotateZ(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.link.extern:hover::after {
|
||||||
|
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.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.link.back::after {
|
||||||
|
content: '';
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-heading {
|
||||||
|
text-align: center;
|
||||||
|
margin: auto;
|
||||||
|
background-color: var(--main-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-heading .title {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draft-banner {
|
||||||
|
margin: 0.5rem 0.75rem 0;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--main-border-color);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draft-banner code {
|
||||||
|
font-family: var(--monospace-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
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 relativeTimeUnits = [
|
||||||
|
[31536000, 'year'],
|
||||||
|
[2592000, 'month'],
|
||||||
|
[604800, 'week'],
|
||||||
|
[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.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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
try {
|
||||||
|
await copyText(code.textContent)
|
||||||
|
button.textContent = 'Copied'
|
||||||
|
} catch {
|
||||||
|
button.textContent = 'Copy failed'
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
button.textContent = 'Copy'
|
||||||
|
}, 1500)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-value]').forEach((element) => {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = absoluteDateFormatter.format(date)
|
||||||
|
element.textContent = element.classList.contains('last-updated') ? `Last updated: ${formatted}` : formatted
|
||||||
|
})
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.article-page .code-block pre[class*='language-'] {
|
||||||
|
background: transparent;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-page .code-block code[class*='language-'] {
|
||||||
|
font-family: 'Iosevka', 'SFMono-Regular', Consolas, monospace;
|
||||||
|
}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "es2023",
|
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
|
||||||
"allowJs": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"strict": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"module": "esnext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"jsx": "preserve",
|
|
||||||
"importHelpers": true,
|
|
||||||
"incremental": true,
|
|
||||||
"plugins": [
|
|
||||||
{
|
|
||||||
"name": "next"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"next-env.d.ts",
|
|
||||||
"**/*.ts",
|
|
||||||
"**/*.tsx",
|
|
||||||
"lib/slug.js",
|
|
||||||
".next/types/**/*.ts"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"node_modules"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
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 = {}
|
||||||
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||||
|
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)
|
||||||
|
} else if (entry.name === '404.html' && directory === '.generated') {
|
||||||
|
inputs['404'] = path.resolve(filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inputs
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyGeneratedFiles() {
|
||||||
|
const files = ['feed.xml', 'sitemap.xml']
|
||||||
|
return {
|
||||||
|
name: 'copy-generated-files',
|
||||||
|
closeBundle() {
|
||||||
|
const dist = path.resolve('dist')
|
||||||
|
if (!fs.existsSync(dist)) return
|
||||||
|
for (const file of files) {
|
||||||
|
const source = path.resolve('.generated', file)
|
||||||
|
if (fs.existsSync(source)) fs.copyFileSync(source, path.join(dist, file))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentGenerator() {
|
||||||
|
let timer
|
||||||
|
let generator
|
||||||
|
let rerun = false
|
||||||
|
|
||||||
|
const generate = (server) => {
|
||||||
|
if (generator) {
|
||||||
|
rerun = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
generator = spawn(process.execPath, ['generator.js', '--drafts'], { stdio: 'inherit' })
|
||||||
|
generator.on('error', (error) => {
|
||||||
|
console.error(`Content generation failed: ${error.message}`)
|
||||||
|
})
|
||||||
|
generator.on('close', (code) => {
|
||||||
|
generator = undefined
|
||||||
|
if (code === 0) server.ws.send({ type: 'full-reload' })
|
||||||
|
if (rerun) {
|
||||||
|
rerun = false
|
||||||
|
generate(server)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'content-generator',
|
||||||
|
configureServer(server) {
|
||||||
|
const sources = [
|
||||||
|
path.resolve('notes'),
|
||||||
|
path.resolve('posts'),
|
||||||
|
path.resolve('README.md'),
|
||||||
|
path.resolve('THIRD_PARTY_LICENSES.md'),
|
||||||
|
path.resolve('external.json'),
|
||||||
|
path.resolve('generator.js'),
|
||||||
|
path.resolve('html.js'),
|
||||||
|
]
|
||||||
|
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 === 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)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [contentGenerator(), copyGeneratedFiles()],
|
||||||
|
root: '.generated',
|
||||||
|
publicDir: '../public',
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'/src': path.resolve('src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
watch: {
|
||||||
|
ignored: ['**/.generated/**'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: path.resolve('dist'),
|
||||||
|
emptyOutDir: true,
|
||||||
|
rollupOptions: {
|
||||||
|
input: generatedPages('.generated'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user