diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..3535d88 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,25 @@ +name: Validate content +on: + push: + paths: + - 'www/content/**' + - 'www/public/*.txt' + - 'www/public/config.yaml' + pull_request: + paths: + - 'www/content/**' + - 'www/public/*.txt' + - 'www/public/config.yaml' + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm validate:www diff --git a/package.json b/package.json index 6ebe20f..c870fd5 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dev:penfield": "pnpm --filter @ily/penfield dev", "dev:www": "pnpm --filter @ily/www dev", "build:penfield": "pnpm --filter @ily/penfield build", - "build:www": "pnpm --filter @ily/www build" + "build:www": "pnpm --filter @ily/www build", + "validate:www": "pnpm --filter @ily/www validate" } } diff --git a/www/content/hello.md b/www/content/hello.md index 72cdeaf..4ef35ee 100644 --- a/www/content/hello.md +++ b/www/content/hello.md @@ -1,6 +1,7 @@ --- title: hello -date: 2026-02-26 +date: 2023-02-26 +pinned: true --- i've always had some sort of homepage. it was originally on bebo, then that died and i didn't ever get into other social media, so i made a website diff --git a/www/package.json b/www/package.json index cfa3cc7..5c7ad4d 100644 --- a/www/package.json +++ b/www/package.json @@ -5,7 +5,8 @@ "dev": "astro dev --port 4322", "build": "astro build --remote && node scripts/generate-stats.js", "preview": "astro preview", - "serve": "pnpm build && npx serve .vercel/output/static -l 4322" + "serve": "pnpm build && npx serve .vercel/output/static -l 4322", + "validate": "node scripts/validate-content.js" }, "dependencies": { "@astrojs/db": "^0.19.0", diff --git a/www/public/changelog.txt b/www/public/changelog.txt index 5882854..a1b4dbe 100644 --- a/www/public/changelog.txt +++ b/www/public/changelog.txt @@ -1,4 +1,3 @@ -2026-03-27 - narrower layout (34rem), single-column entries, serif font, smaller muted text 2026-03-26 - inline section labels, compact layout 2026-02-07 - related posts ! 2026-01-31 - text files now live at cleaner URLs (/*.txt instead of /txt/*.txt) diff --git a/www/public/config.yaml b/www/public/config.yaml index c1c9ffa..2b7aa3a 100644 --- a/www/public/config.yaml +++ b/www/public/config.yaml @@ -1,3 +1,4 @@ +pinned: [] descriptions: cv.txt: curriculum vitae now.txt: what i'm doing now diff --git a/www/public/js/params.js b/www/public/js/params.js index 01fbf1b..c466e18 100644 --- a/www/public/js/params.js +++ b/www/public/js/params.js @@ -4,7 +4,8 @@ var just = p.get('just'); if (just && /^[a-z0-9-]+$/.test(just)) { document.documentElement.dataset.just = just; - var css = 'section[data-section]:not([data-section="' + just + '"]){display:none}'; + var css = 'section[data-section]:not([data-section="' + just + '"]){display:none}' + + ' section[data-section="' + just + '"] .section-label{pointer-events:none;text-decoration:none;color:inherit}'; document.head.appendChild(Object.assign(document.createElement('style'), { textContent: css })); } @@ -29,10 +30,7 @@ if (has) { document.documentElement.dataset.has = has; has = has.toLowerCase(); - } - - document.addEventListener('DOMContentLoaded', function() { - if (has) { + document.addEventListener('DOMContentLoaded', function() { document.querySelectorAll('section[data-section] .entry').forEach(function(entry) { if (entry.textContent.toLowerCase().indexOf(has) === -1) { entry.style.display = 'none'; @@ -43,22 +41,6 @@ entry.style.display = 'none'; } }); - } - - document.querySelectorAll('.section-label').forEach(function(a) { - var link = new URLSearchParams(a.search); - p.forEach(function(v, k) { if (!link.has(k)) link.set(k, v); }); - a.href = '?' + link.toString(); }); - - var find = document.getElementById('find'); - if (find) find.addEventListener('click', function(e) { - e.preventDefault(); - var term = prompt('find:'); - if (!term) return; - var q = new URLSearchParams(location.search); - q.set('has', term); - location.search = q.toString(); - }); - }); + } }(); diff --git a/www/scripts/validate-content.js b/www/scripts/validate-content.js new file mode 100644 index 0000000..3cce1f2 --- /dev/null +++ b/www/scripts/validate-content.js @@ -0,0 +1,43 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import yaml from 'js-yaml'; + +const root = path.resolve(import.meta.dirname, '..'); +const contentDir = path.join(root, 'content'); +const publicDir = path.join(root, 'public'); +const errors = []; + +const mdFiles = fs.readdirSync(contentDir).filter(f => f.endsWith('.md')); +for (const file of mdFiles) { + const content = fs.readFileSync(path.join(contentDir, file), 'utf8'); + const match = content.match(/^---\n([\s\S]*?)\n---/); + if (!match) { + errors.push(`${file}: missing frontmatter`); + continue; + } + const frontmatter = match[1]; + if (!/^date:\s*.+/m.test(frontmatter)) { + errors.push(`${file}: missing required 'date' field`); + } +} + +const configPath = path.join(publicDir, 'config.yaml'); +const config = fs.existsSync(configPath) + ? yaml.load(fs.readFileSync(configPath, 'utf8')) + : {}; +const configDates = config.dates || {}; +const txtFiles = fs.readdirSync(publicDir).filter(f => f.endsWith('.txt')); +for (const file of txtFiles) { + if (!configDates[file]) { + errors.push(`${file}: missing date in config.yaml`); + } +} + +if (errors.length) { + console.error('Content validation failed:\n'); + for (const err of errors) console.error(` - ${err}`); + console.error(''); + process.exit(1); +} else { + console.log(`Validated ${mdFiles.length} posts and ${txtFiles.length} txt files.`); +} diff --git a/www/src/content.config.ts b/www/src/content.config.ts index 6efc211..6499a0a 100644 --- a/www/src/content.config.ts +++ b/www/src/content.config.ts @@ -3,12 +3,13 @@ import { glob, file } from 'astro/loaders'; import { z } from 'astro/zod'; import yaml from 'js-yaml'; -const posts = defineCollection({ +const md = defineCollection({ loader: glob({ pattern: '**/*.md', base: './content' }), schema: z.object({ title: z.string(), date: z.coerce.date(), updated: z.coerce.date().optional(), + pinned: z.boolean().optional(), category: z.string().optional(), related: z.array(z.string()).optional(), }) @@ -28,4 +29,4 @@ const bookmarks = defineCollection({ }) }); -export const collections = { posts, bookmarks }; +export const collections = { md, bookmarks }; diff --git a/www/src/layouts/Layout.astro b/www/src/layouts/Layout.astro index ee442c2..4f59fe1 100644 --- a/www/src/layouts/Layout.astro +++ b/www/src/layouts/Layout.astro @@ -9,7 +9,7 @@ interface Props { urls?: string[]; } -const { title, description = 'personal website of ' + title, showHeader = true, isHome = false, urls = [] } = Astro.props; +const { title, description = 'personal website of lewis m.w.', showHeader = true, isHome = false, urls = [] } = Astro.props; --- @@ -28,18 +28,7 @@ const { title, description = 'personal website of ' + title, showHeader = true, {showHeader && (
- - {isHome ? title : 'lewis m.w.'} - - - mail - gh - rss - sitemap - random - newest - find - +
{isHome ? lewis m.w.lewis m.w. : lewis m.w.} mail gh rss sitemap random newest
)} diff --git a/www/src/lib/api.ts b/www/src/lib/api.ts index 6e4622e..1961a01 100644 --- a/www/src/lib/api.ts +++ b/www/src/lib/api.ts @@ -1,3 +1,5 @@ +import { isAdmin } from './auth'; + export function jsonResponse(data: unknown, status = 200): Response { return new Response(JSON.stringify(data), { status, @@ -8,3 +10,10 @@ export function jsonResponse(data: unknown, status = 200): Response { export function errorResponse(message: string, status: number): Response { return jsonResponse({ error: message }, status); } + +export function requireAdmin(session: { user?: { id?: string } } | null): Response | null { + if (!session?.user?.id || !isAdmin(session.user.id)) { + return errorResponse('Unauthorized', 403); + } + return null; +} diff --git a/www/src/lib/auth.ts b/www/src/lib/auth.ts index eaac841..28b0d8b 100644 --- a/www/src/lib/auth.ts +++ b/www/src/lib/auth.ts @@ -1,26 +1,29 @@ import { getSession } from 'auth-astro/server'; -export type Session = { user?: { id?: string; name?: string | null } }; +type Session = { user?: { id?: string; name?: string | null } }; -export type AuthResult = - | { status: 'admin'; session: Session } - | { status: 'unauthenticated' } - | { status: 'forbidden' } - | { status: 'error' }; +export function isAdmin(userId: string | undefined): boolean { + return userId === import.meta.env.ADMIN_GITHUB_ID; +} -export async function getAdminSession(request: Request): Promise { +export async function requireAdminSession(request: Request): Promise< + | { session: Session; error: null } + | { session: null; error: Response | null } +> { let session: Session | null; try { session = await getSession(request); } catch { - return { status: 'error' }; + return { session: null, error: new Response('Auth not configured', { status: 500 }) }; } - if (!session) return { status: 'unauthenticated' }; - - if (session.user?.id !== import.meta.env.ADMIN_GITHUB_ID) { - return { status: 'forbidden' }; + if (!session) { + return { session: null, error: null }; } - return { status: 'admin', session }; + if (!isAdmin(session.user?.id)) { + return { session: null, error: new Response('Forbidden', { status: 403 }) }; + } + + return { session, error: null }; } diff --git a/www/src/lib/consts.ts b/www/src/lib/consts.ts index a2e8d87..a19834c 100644 --- a/www/src/lib/consts.ts +++ b/www/src/lib/consts.ts @@ -6,7 +6,7 @@ export const SUBDOMAINS = [ ]; export const SECTIONS = { - files: 'files', + plaintext: 'plaintext', bookmarks: 'bookmarks', guestbook: 'guestbook', } as const; diff --git a/www/src/lib/format.ts b/www/src/lib/format.ts index 9257bde..cd07266 100644 --- a/www/src/lib/format.ts +++ b/www/src/lib/format.ts @@ -26,45 +26,25 @@ export function formatDate(date: Date): string { return `${d}/${m}/${y}`; } -export function wordCount(markdown: string | undefined): string { - if (!markdown) return ''; - const words = markdown - .replace(/^---[\s\S]*?---/m, '') - .replace(/^#+\s+.*$/gm, '') - .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') - .replace(/[*_~`]/g, '') - .replace(/:[a-z]+\[([^\]]*)\]/g, '$1') - .trim() - .split(/\s+/) - .filter(Boolean).length; - if (words < 100) return `${words} words`; - const mins = Math.ceil(words / 200); - return `${mins} min`; -} - -export function extractDomain(url: string): string { - try { - return new URL(url).hostname.replace(/^www\./, ''); - } catch { - return ''; - } -} - export function formatListItem( date: Date, url: string, title: string, - options?: { suffix?: string } + options?: { pinned?: boolean } ): string { - const suffixHtml = options?.suffix ? `${options.suffix}` : ''; - return `${formatDate(date)}${title}${suffixHtml}`; + const pinnedBadge = options?.pinned ? ' [pinned]' : ''; + return `${formatDate(date)}${title}${pinnedBadge}`; } interface Sortable { date: Date; + pinned?: boolean; } -export function sortEntries(items: T[], key?: (item: T) => Sortable): T[] { - const get = key ?? (item => item as unknown as Sortable); - return items.slice().sort((a, b) => get(b).date.getTime() - get(a).date.getTime()); +export function sortByPinnedThenDate(items: T[]): T[] { + return items.slice().sort((a, b) => { + if (a.pinned && !b.pinned) return -1; + if (!a.pinned && b.pinned) return 1; + return b.date.getTime() - a.date.getTime(); + }); } diff --git a/www/src/lib/posts.ts b/www/src/lib/md.ts similarity index 63% rename from www/src/lib/posts.ts rename to www/src/lib/md.ts index 6b44477..378c2c6 100644 --- a/www/src/lib/posts.ts +++ b/www/src/lib/md.ts @@ -1,14 +1,22 @@ import type { CollectionEntry } from 'astro:content'; import { DEFAULT_CATEGORY } from './consts'; -import { sortEntries } from './format'; -export type Post = CollectionEntry<'posts'> & { body?: string }; +type Post = CollectionEntry<'md'>; export function getSlug(postId: string): string { const parts = postId.split('/'); return parts[parts.length - 1]; } +function sortPosts(posts: Post[], { alphabetically = false } = {}): Post[] { + return posts.slice().sort((a, b) => { + if (a.data.pinned && !b.data.pinned) return -1; + if (!a.data.pinned && b.data.pinned) return 1; + if (alphabetically) return a.data.title.localeCompare(b.data.title); + return b.data.date.getTime() - a.data.date.getTime(); + }); +} + export function resolveRelatedPosts( slugs: string[], allPosts: T[], @@ -17,7 +25,7 @@ export function resolveRelatedPosts( return slugs.flatMap(s => bySlug.get(s) ?? []); } -export function organizePostsByCategory(posts: Post[]): { +export function organizePostsByCategory(posts: Post[], { sortAlphabetically = false } = {}): { grouped: Record; categories: string[]; } { @@ -35,7 +43,7 @@ export function organizePostsByCategory(posts: Post[]): { }); for (const category of categories) { - grouped[category] = sortEntries(grouped[category], p => p.data); + grouped[category] = sortPosts(grouped[category], { alphabetically: sortAlphabetically }); } return { grouped, categories }; diff --git a/www/src/lib/txt.ts b/www/src/lib/txt.ts index ad13389..ee03984 100644 --- a/www/src/lib/txt.ts +++ b/www/src/lib/txt.ts @@ -1,15 +1,17 @@ import fs from 'node:fs'; import path from 'node:path'; import yaml from 'js-yaml'; -import { sortEntries } from './format'; +import { sortByPinnedThenDate } from './format'; export interface TxtFile { name: string; date: Date; + pinned: boolean; description?: string; } export interface TxtConfig { + pinned?: string[]; descriptions?: Record; dates?: Record; } @@ -30,6 +32,7 @@ export function getTxtFiles(): TxtFile[] { if (!fs.existsSync(txtDir)) return []; const config = loadTxtConfig(); + const pinnedSet = new Set(config.pinned || []); const descriptions = config.descriptions || {}; const dates = config.dates || {}; @@ -38,8 +41,9 @@ export function getTxtFiles(): TxtFile[] { .map(name => ({ name, date: dates[name] ? new Date(dates[name]) : new Date(0), + pinned: pinnedSet.has(name), description: descriptions[name], })); - return sortEntries(files); + return sortByPinnedThenDate(files); } diff --git a/www/src/pages/[slug].astro b/www/src/pages/[slug].astro index 5f92dde..c2a81a8 100644 --- a/www/src/pages/[slug].astro +++ b/www/src/pages/[slug].astro @@ -1,11 +1,11 @@ --- import { getCollection, render } from 'astro:content'; import Layout from '../layouts/Layout.astro'; -import { formatDate, formatListItem, excerpt, wordCount } from '../lib/format'; -import { getSlug, resolveRelatedPosts, type Post } from '../lib/posts'; +import { formatDate, formatListItem, excerpt } from '../lib/format'; +import { getSlug, resolveRelatedPosts } from '../lib/md'; export async function getStaticPaths() { - const allPosts = await getCollection('posts'); + const allPosts = await getCollection('md'); return allPosts.map(post => ({ params: { slug: getSlug(post.id) }, props: { post, allPosts } @@ -15,13 +15,13 @@ export async function getStaticPaths() { const { post, allPosts } = Astro.props; const { Content } = await render(post); const related = post.data.related ? resolveRelatedPosts(post.data.related, allPosts) : []; -const description = excerpt((post as Post).body) || undefined; +const description = excerpt((post as any).body) || undefined; ---

{post.data.title}

-

{formatDate(post.data.date)}{post.data.updated && ` (updated ${formatDate(post.data.updated)})`} · {wordCount((post as Post).body)}{post.data.category && ` · ${post.data.category}`}

+

{formatDate(post.data.date)}{post.data.updated && ` (updated ${formatDate(post.data.updated)})`}

{related.length > 0 && ( diff --git a/www/src/pages/admin.astro b/www/src/pages/admin.astro index 66ff49c..f540c6c 100644 --- a/www/src/pages/admin.astro +++ b/www/src/pages/admin.astro @@ -2,15 +2,13 @@ export const prerender = false; import { getPendingEntries, type GuestbookEntry } from '../lib/db'; -import { getAdminSession } from '../lib/auth'; +import { requireAdminSession } from '../lib/auth'; import Layout from '../layouts/Layout.astro'; import { formatDate } from '../lib/format'; -const auth = await getAdminSession(Astro.request); -if (auth.status === 'error') return new Response('Auth not configured', { status: 500 }); -if (auth.status === 'unauthenticated') return Astro.redirect('/api/auth/signin'); -if (auth.status !== 'admin') return new Response('Forbidden', { status: 403 }); -const { session } = auth; +const { session, error } = await requireAdminSession(Astro.request); +if (error) return error; +if (!session) return Astro.redirect('/api/auth/signin'); let entries: GuestbookEntry[] = []; try { diff --git a/www/src/pages/api/deploy.ts b/www/src/pages/api/deploy.ts index c6f7ab4..57ad7ec 100644 --- a/www/src/pages/api/deploy.ts +++ b/www/src/pages/api/deploy.ts @@ -1,18 +1,23 @@ import type { APIRoute } from 'astro'; -import { jsonResponse, errorResponse } from '../../lib/api'; -import { getAdminSession } from '../../lib/auth'; +import { getSession } from 'auth-astro/server'; +import { jsonResponse, errorResponse, requireAdmin } from '../../lib/api'; export const prerender = false; export const POST: APIRoute = async ({ request }) => { - const auth = await getAdminSession(request); - if (auth.status !== 'admin') return errorResponse('Unauthorized', 403); + const session = await getSession(request); + const authError = requireAdmin(session); + if (authError) return authError; const hookUrl = import.meta.env.VERCEL_DEPLOY_HOOK; - if (!hookUrl) return errorResponse('Deploy hook not configured', 500); + if (!hookUrl) { + return errorResponse('Deploy hook not configured', 500); + } const res = await fetch(hookUrl, { method: 'POST' }); - if (!res.ok) return errorResponse('Failed to trigger deploy', 502); + if (!res.ok) { + return errorResponse('Failed to trigger deploy', 502); + } return jsonResponse({ success: true }); }; diff --git a/www/src/pages/api/guestbook/[id].ts b/www/src/pages/api/guestbook/[id].ts index 32d92d9..a8a91a6 100644 --- a/www/src/pages/api/guestbook/[id].ts +++ b/www/src/pages/api/guestbook/[id].ts @@ -1,27 +1,33 @@ import type { APIRoute } from 'astro'; +import { getSession } from 'auth-astro/server'; import { approveEntry, deleteEntry } from '../../../lib/db'; -import { jsonResponse, errorResponse } from '../../../lib/api'; -import { getAdminSession } from '../../../lib/auth'; +import { jsonResponse, errorResponse, requireAdmin } from '../../../lib/api'; export const prerender = false; export const PATCH: APIRoute = async ({ params, request }) => { - const auth = await getAdminSession(request); - if (auth.status !== 'admin') return errorResponse('Unauthorized', 403); + const session = await getSession(request); + const authError = requireAdmin(session); + if (authError) return authError; const id = parseInt(params.id!, 10); - if (isNaN(id)) return errorResponse('Invalid ID', 400); + if (isNaN(id)) { + return errorResponse('Invalid ID', 400); + } await approveEntry(id); return jsonResponse({ success: true }); }; export const DELETE: APIRoute = async ({ params, request }) => { - const auth = await getAdminSession(request); - if (auth.status !== 'admin') return errorResponse('Unauthorized', 403); + const session = await getSession(request); + const authError = requireAdmin(session); + if (authError) return authError; const id = parseInt(params.id!, 10); - if (isNaN(id)) return errorResponse('Invalid ID', 400); + if (isNaN(id)) { + return errorResponse('Invalid ID', 400); + } await deleteEntry(id); return jsonResponse({ success: true }); diff --git a/www/src/pages/feed.xml.ts b/www/src/pages/feed.xml.ts index 1b26c9b..d350661 100644 --- a/www/src/pages/feed.xml.ts +++ b/www/src/pages/feed.xml.ts @@ -1,12 +1,12 @@ import rss from '@astrojs/rss'; import { getCollection } from 'astro:content'; import type { APIContext } from 'astro'; -import { getSlug, type Post } from '../lib/posts'; +import { getSlug } from '../lib/md'; import { getTxtFiles } from '../lib/txt'; import { excerpt } from '../lib/format'; export async function GET(context: APIContext) { - const posts = await getCollection('posts'); + const posts = await getCollection('md'); const txtFiles = getTxtFiles(); const items = [ @@ -14,7 +14,7 @@ export async function GET(context: APIContext) { title: post.data.title, pubDate: post.data.date, link: `/${getSlug(post.id)}`, - description: excerpt((post as Post).body) || post.data.title, + description: excerpt((post as any).body) || post.data.title, })), ...txtFiles.map(txt => ({ title: txt.name, diff --git a/www/src/pages/index.astro b/www/src/pages/index.astro index 9f6f8ef..75f592f 100644 --- a/www/src/pages/index.astro +++ b/www/src/pages/index.astro @@ -2,12 +2,12 @@ import { getCollection } from 'astro:content'; import Layout from '../layouts/Layout.astro'; import { getApprovedEntries, type GuestbookEntry } from '../lib/db'; -import { formatDate, formatListItem, extractDomain, wordCount, escapeHtml } from '../lib/format'; -import { organizePostsByCategory, getSlug } from '../lib/posts'; +import { formatDate, formatListItem, escapeHtml } from '../lib/format'; +import { organizePostsByCategory, getSlug } from '../lib/md'; import { getTxtFiles } from '../lib/txt'; import { DEFAULT_CATEGORY, SECTIONS, SUBDOMAINS } from '../lib/consts'; -const posts = await getCollection('posts'); +const posts = await getCollection('md'); const { grouped, categories: sortedCategories } = organizePostsByCategory(posts); const bookmarksCollection = await getCollection('bookmarks'); @@ -37,24 +37,24 @@ const urls = [
{!isDefault && }
- `${formatListItem(post.data.date, `/${getSlug(post.id)}`, post.data.title, { suffix: wordCount(post.body) })}` + `${formatListItem(post.data.date, `/${getSlug(post.id)}`, post.data.title, { pinned: post.data.pinned })}` ).join('')} />
); })} -
- +
+
{ const name = f.name.replace(/\.txt$/, ''); - return `${formatListItem(f.date, `/${f.name}`, name, { suffix: f.description })}`; + return `${formatListItem(f.date, `/${f.name}`, name, { pinned: f.pinned })}`; }).join('')} />
- `${formatListItem(b.data.date, b.data.url, b.data.title, { suffix: extractDomain(b.data.url) })}` + `${formatListItem(b.data.date, b.data.url, b.data.title)}` ).join('')} />
diff --git a/www/src/pages/sitemap.txt.ts b/www/src/pages/sitemap.txt.ts index 634b568..2121a21 100644 --- a/www/src/pages/sitemap.txt.ts +++ b/www/src/pages/sitemap.txt.ts @@ -1,12 +1,12 @@ import { getCollection } from 'astro:content'; import type { APIContext } from 'astro'; -import { getSlug } from '../lib/posts'; +import { getSlug } from '../lib/md'; import { getTxtFiles } from '../lib/txt'; import { SUBDOMAINS } from '../lib/consts'; export async function GET(context: APIContext) { const site = context.site?.origin ?? 'https://wynne.rs'; - const posts = await getCollection('posts'); + const posts = await getCollection('md'); const txtFiles = getTxtFiles().map(f => f.name); const urls = [ diff --git a/www/src/styles/global.css b/www/src/styles/global.css index 4e595c6..4332b9c 100644 --- a/www/src/styles/global.css +++ b/www/src/styles/global.css @@ -1,10 +1,9 @@ body { box-sizing: border-box; - max-width: 34rem; + max-width: 48rem; margin: 0 auto; padding: 1rem; text-align: justify; - font-family: 'Times New Roman', serif; } img { @@ -18,7 +17,6 @@ h1, h2, h3, h4, h5, h6 { .muted { color: #888; - font-size: 0.9rem; } .left, .right { @@ -26,7 +24,7 @@ h1, h2, h3, h4, h5, h6 { font-size: 0.9rem; } -@media (min-width: 58rem) { +@media (min-width: 63rem) { .left, .right { display: inline; position: relative; @@ -80,24 +78,24 @@ section { margin: 1rem 0; } +section .section-label { + font-family: monospace; +} -html[data-has] .guestbook-form { +.home-name-link { display: none; } -header { - display: flex; - justify-content: space-between; - align-items: baseline; - gap: 2rem; +html[data-just] .home-name { + display: none; } -.header-name { - white-space: nowrap; +html[data-just] .home-name-link { + display: inline; } -.header-links { - text-align: right; +html[data-has] .guestbook-form { + display: none; } section pre { @@ -105,52 +103,42 @@ section pre { } .entry-list { + columns: 2 24ch; + column-gap: 3ch; + font-family: monospace; margin: 0; } .entry { display: grid; - grid-template-columns: 4rem 1fr; - align-items: baseline; + grid-template-columns: 10ch 1fr; break-inside: avoid; } .entry-content { - display: flex; overflow: hidden; white-space: nowrap; -} - -.entry-content > a { - flex: 0 1 auto; - min-width: 0; - overflow: hidden; text-overflow: ellipsis; } -.entry-suffix { - flex: 1 10000 0%; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - text-align: right; - padding-left: 0.5rem; -} - .guestbook-entries { - margin: 0; + font-family: monospace; + white-space: pre; } .guestbook-entry { display: grid; - grid-template-columns: 4rem 1fr; - align-items: baseline; - break-inside: avoid; + grid-template-columns: 10ch 1fr; +} + +.guestbook-entry > span:last-child { + white-space: normal; } .guestbook-form { margin-top: 0.5rem; - margin-left: 4rem; + margin-left: 10ch; + font-family: monospace; } html[data-compact] .list-meta {