flatten monorepo, migrate off Vercel

- Remove penfield (split to own repo on Forgejo)
- Move www/ contents to root, rename to wynne.rs
- Swap @astrojs/vercel for @astrojs/node, upgrade to Astro 6
- Remove auth-astro/GitHub OAuth (TinyAuth at proxy layer)
- Remove Vercel deploy webhook
- Switch to local SQLite DB (drop Turso)
- Update generate-stats.js for new build output paths
This commit is contained in:
Lewis Wynne 2026-04-05 01:04:11 +01:00
parent f2acf36784
commit 8a9c56c3d5
52 changed files with 45 additions and 7135 deletions

31
src/content.config.ts Normal file
View file

@ -0,0 +1,31 @@
import { defineCollection } from 'astro:content';
import { glob, file } from 'astro/loaders';
import { z } from 'astro/zod';
import yaml from 'js-yaml';
const posts = defineCollection({
loader: glob({ pattern: '**/*.md', base: './content' }),
schema: z.object({
title: z.string(),
date: z.coerce.date(),
updated: z.coerce.date().optional(),
category: z.string().optional(),
related: z.array(z.string()).optional(),
})
});
const bookmarks = defineCollection({
loader: file('./content/bookmarks.yaml', {
parser: (text) => {
const data = yaml.load(text) as Array<Record<string, unknown>>;
return data.map((item, i) => ({ id: String(i), ...item }));
},
}),
schema: z.object({
title: z.string(),
url: z.string().url(),
date: z.coerce.date(),
})
});
export const collections = { posts, bookmarks };

10
src/env.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
/// <reference types="astro/client" />
interface ImportMetaEnv {
readonly VERCEL_DEPLOY_HOOK: string;
readonly ADMIN_GITHUB_ID: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

47
src/layouts/Layout.astro Normal file
View file

@ -0,0 +1,47 @@
---
import '../styles/global.css';
interface Props {
title: string;
description?: string;
showHeader?: boolean;
isHome?: boolean;
urls?: string[];
}
const { title, description = 'personal website of ' + title, showHeader = true, isHome = false, urls = [] } = Astro.props;
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<meta name="description" content={description} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:type" content="website" />
<link rel="alternate" type="application/rss+xml" title="wynne.rs" href="/feed.xml" />
{urls.length > 0 && <script is:inline define:vars={{ urls }}>window.__urls = urls;</script>}
<script is:inline src="/js/params.js"></script>
</head>
<body>
{showHeader && (
<header>
<span class="header-name">
<a href="/">{isHome ? title : 'lewis m.w.'}</a>
</span>
<span class="header-links">
<a href="mailto:lewis@wynne.rs">mail</a>
<a href="https://github.com/llywelwyn">gh</a>
<a href="/feed.xml">rss</a>
<a href="/sitemap.txt">sitemap</a>
<a href="/?do=random">random</a>
<a href="/?do=newest">newest</a>
<a id="find" href="/?has=">find</a>
</span>
</header>
)}
<slot />
</body>
</html>

10
src/lib/api.ts Normal file
View file

@ -0,0 +1,10 @@
export function jsonResponse(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
export function errorResponse(message: string, status: number): Response {
return jsonResponse({ error: message }, status);
}

12
src/lib/consts.ts Normal file
View file

@ -0,0 +1,12 @@
export const DEFAULT_CATEGORY = 'none';
export const SUBDOMAINS = [
'https://c.wynne.rs/',
'https://penfield.wynne.rs/',
];
export const SECTIONS = {
files: 'files',
bookmarks: 'bookmarks',
guestbook: 'guestbook',
} as const;

29
src/lib/db.ts Normal file
View file

@ -0,0 +1,29 @@
import { db, Guestbook, eq, desc } from 'astro:db';
export type GuestbookEntry = typeof Guestbook.$inferSelect;
export async function getApprovedEntries(): Promise<GuestbookEntry[]> {
return db.select().from(Guestbook).where(eq(Guestbook.approved, true)).orderBy(desc(Guestbook.createdAt));
}
export async function getPendingEntries(): Promise<GuestbookEntry[]> {
return db.select().from(Guestbook).where(eq(Guestbook.approved, false)).orderBy(desc(Guestbook.createdAt));
}
export async function createEntry(name: string, message: string, url: string | null): Promise<void> {
await db.insert(Guestbook).values({
name,
message,
url,
createdAt: new Date(),
approved: false,
});
}
export async function approveEntry(id: number): Promise<void> {
await db.update(Guestbook).set({ approved: true }).where(eq(Guestbook.id, id));
}
export async function deleteEntry(id: number): Promise<void> {
await db.delete(Guestbook).where(eq(Guestbook.id, id));
}

70
src/lib/format.ts Normal file
View file

@ -0,0 +1,70 @@
export function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
export function excerpt(markdown: string | undefined, maxLen = 160): string {
if (!markdown) return '';
return markdown
.replace(/^#+\s+.*$/gm, '')
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/[*_~`]/g, '')
.replace(/:[a-z]+\[([^\]]*)\]/g, '$1')
.replace(/\n+/g, ' ')
.trim()
.slice(0, maxLen);
}
export function formatDate(date: Date): string {
const d = String(date.getDate()).padStart(2, '0');
const m = String(date.getMonth() + 1).padStart(2, '0');
const y = String(date.getFullYear()).slice(-2);
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 }
): string {
const suffixHtml = options?.suffix ? `<span class="entry-suffix muted">${options.suffix}</span>` : '';
return `<span class="list-meta"><span class="muted">${formatDate(date)}</span></span><span class="entry-content"><a href="${url}" title="${title}">${title}</a>${suffixHtml}</span>`;
}
interface Sortable {
date: Date;
}
export function sortEntries<T>(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());
}

42
src/lib/posts.ts Normal file
View file

@ -0,0 +1,42 @@
import type { CollectionEntry } from 'astro:content';
import { DEFAULT_CATEGORY } from './consts';
import { sortEntries } from './format';
export type Post = CollectionEntry<'posts'> & { body?: string };
export function getSlug(postId: string): string {
const parts = postId.split('/');
return parts[parts.length - 1];
}
export function resolveRelatedPosts<T extends { id: string }>(
slugs: string[],
allPosts: T[],
): T[] {
const bySlug = new Map(allPosts.map(p => [getSlug(p.id), p]));
return slugs.flatMap(s => bySlug.get(s) ?? []);
}
export function organizePostsByCategory(posts: Post[]): {
grouped: Record<string, Post[]>;
categories: string[];
} {
const grouped = posts.reduce((acc, post) => {
const category = post.data.category ?? DEFAULT_CATEGORY;
if (!acc[category]) acc[category] = [];
acc[category].push(post);
return acc;
}, {} as Record<string, Post[]>);
const categories = Object.keys(grouped).sort((a, b) => {
if (a === DEFAULT_CATEGORY) return -1;
if (b === DEFAULT_CATEGORY) return 1;
return a.localeCompare(b);
});
for (const category of categories) {
grouped[category] = sortEntries(grouped[category], p => p.data);
}
return { grouped, categories };
}

15
src/lib/rate-limit.ts Normal file
View file

@ -0,0 +1,15 @@
const requests = new Map<string, number[]>();
export function isRateLimited(key: string, maxRequests: number, windowMs: number): boolean {
const now = Date.now();
const timestamps = requests.get(key) ?? [];
const recent = timestamps.filter(t => now - t < windowMs);
if (recent.length >= maxRequests) {
return true;
}
recent.push(now);
requests.set(key, recent);
return false;
}

45
src/lib/txt.ts Normal file
View file

@ -0,0 +1,45 @@
import fs from 'node:fs';
import path from 'node:path';
import yaml from 'js-yaml';
import { sortEntries } from './format';
export interface TxtFile {
name: string;
date: Date;
description?: string;
}
export interface TxtConfig {
descriptions?: Record<string, string>;
dates?: Record<string, string>;
}
export function getTxtDir(): string {
return path.join(process.cwd(), 'public');
}
export function loadTxtConfig(): TxtConfig {
const configPath = path.join(getTxtDir(), 'config.yaml');
return fs.existsSync(configPath)
? yaml.load(fs.readFileSync(configPath, 'utf8')) as TxtConfig
: {};
}
export function getTxtFiles(): TxtFile[] {
const txtDir = getTxtDir();
if (!fs.existsSync(txtDir)) return [];
const config = loadTxtConfig();
const descriptions = config.descriptions || {};
const dates = config.dates || {};
const files = fs.readdirSync(txtDir)
.filter(file => file.endsWith('.txt'))
.map(name => ({
name,
date: dates[name] ? new Date(dates[name]) : new Date(0),
description: descriptions[name],
}));
return sortEntries(files);
}

6
src/pages/404.astro Normal file
View file

@ -0,0 +1,6 @@
---
import Layout from '../layouts/Layout.astro';
---
<Layout title="404 - lewis m.w.">
<p>not found</p>
</Layout>

33
src/pages/[slug].astro Normal file
View file

@ -0,0 +1,33 @@
---
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';
export async function getStaticPaths() {
const allPosts = await getCollection('posts');
return allPosts.map(post => ({
params: { slug: getSlug(post.id) },
props: { post, allPosts }
}));
}
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;
---
<Layout title={`${post.data.title} - lewis m.w.`} description={description}>
<article>
<h1>{post.data.title}</h1>
<p class="muted" style="margin-top: 0;">{formatDate(post.data.date)}{post.data.updated && ` (updated ${formatDate(post.data.updated)})`} · {wordCount((post as Post).body)}{post.data.category && ` · ${post.data.category}`}</p>
<Content />
</article>
{related.length > 0 && (
<section>
<span class="section-label">related</span>
<pre set:html={related.map(p => formatListItem(p.data.date, `/${getSlug(p.id)}`, p.data.title)).join('\n')} />
</section>
)}
</Layout>

51
src/pages/admin.astro Normal file
View file

@ -0,0 +1,51 @@
---
export const prerender = false;
import { getPendingEntries, type GuestbookEntry } from '../lib/db';
import Layout from '../layouts/Layout.astro';
import { formatDate } from '../lib/format';
let entries: GuestbookEntry[] = [];
try {
entries = await getPendingEntries();
} catch {
// handle error
}
---
<Layout title="admin - guestbook" showHeader={false}>
<h1>guestbook admin</h1>
{entries.length === 0 ? (
<p class="muted">no pending entries</p>
) : (
<ul id="entries">
{entries.map(e => (
<li data-id={e.id}>
<span class="muted">{formatDate(e.createdAt)}</span>
<strong>{e.name}</strong>
{e.url && <a href={e.url}>{e.url}</a>}
<p>{e.message}</p>
<button class="approve">approve</button>
<button class="reject">reject</button>
</li>
))}
</ul>
)}
<script>
document.querySelectorAll('#entries li').forEach(li => {
const id = li.getAttribute('data-id');
li.querySelector('.approve')?.addEventListener('click', async () => {
const res = await fetch(`/api/guestbook/${id}`, { method: 'PATCH' });
if (res.ok) li.remove();
});
li.querySelector('.reject')?.addEventListener('click', async () => {
const res = await fetch(`/api/guestbook/${id}`, { method: 'DELETE' });
if (res.ok) li.remove();
});
});
</script>
</Layout>

View file

@ -0,0 +1,28 @@
import type { APIRoute } from 'astro';
import { createEntry } from '../../lib/db';
import { jsonResponse, errorResponse } from '../../lib/api';
import { isRateLimited } from '../../lib/rate-limit';
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
try {
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
if (isRateLimited(ip, 3, 60_000)) {
return errorResponse('Too many requests, try again later', 429);
}
const data = await request.json();
const { name, message, url } = data;
if (!name || !message) {
return errorResponse('Name and message are required', 400);
}
await createEntry(name.slice(0, 100), message.slice(0, 500), url?.slice(0, 200) || null);
return jsonResponse({ success: true }, 201);
} catch {
return errorResponse('Failed to create entry', 500);
}
};

View file

@ -0,0 +1,21 @@
import type { APIRoute } from 'astro';
import { approveEntry, deleteEntry } from '../../../lib/db';
import { jsonResponse, errorResponse } from '../../../lib/api';
export const prerender = false;
export const PATCH: APIRoute = async ({ params }) => {
const id = parseInt(params.id!, 10);
if (isNaN(id)) return errorResponse('Invalid ID', 400);
await approveEntry(id);
return jsonResponse({ success: true });
};
export const DELETE: APIRoute = async ({ params }) => {
const id = parseInt(params.id!, 10);
if (isNaN(id)) return errorResponse('Invalid ID', 400);
await deleteEntry(id);
return jsonResponse({ success: true });
};

33
src/pages/feed.xml.ts Normal file
View file

@ -0,0 +1,33 @@
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
import type { APIContext } from 'astro';
import { getSlug, type Post } from '../lib/posts';
import { getTxtFiles } from '../lib/txt';
import { excerpt } from '../lib/format';
export async function GET(context: APIContext) {
const posts = await getCollection('posts');
const txtFiles = getTxtFiles();
const items = [
...posts.map(post => ({
title: post.data.title,
pubDate: post.data.date,
link: `/${getSlug(post.id)}`,
description: excerpt((post as Post).body) || post.data.title,
})),
...txtFiles.map(txt => ({
title: txt.name,
pubDate: txt.date,
link: `/${txt.name}`,
description: txt.description || txt.name,
})),
].sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime());
return rss({
title: 'wynne.rs',
description: 'personal website of lewis m.w.',
site: context.site ?? 'https://wynne.rs',
items,
});
}

View file

@ -0,0 +1,17 @@
import type { APIRoute } from 'astro';
import { getApprovedEntries } from '../lib/db';
export const prerender = true;
export const GET: APIRoute = async () => {
let count = 0;
try {
const entries = await getApprovedEntries();
count = entries.length;
} catch {
// DB not available
}
return new Response(JSON.stringify({ count }), {
headers: { 'Content-Type': 'application/json' },
});
};

86
src/pages/index.astro Normal file
View file

@ -0,0 +1,86 @@
---
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 { getTxtFiles } from '../lib/txt';
import { DEFAULT_CATEGORY, SECTIONS, SUBDOMAINS } from '../lib/consts';
const posts = await getCollection('posts');
const { grouped, categories: sortedCategories } = organizePostsByCategory(posts);
const bookmarksCollection = await getCollection('bookmarks');
const bookmarks = bookmarksCollection
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
const txtFiles = getTxtFiles();
let guestbookEntries: GuestbookEntry[] = [];
try {
guestbookEntries = await getApprovedEntries();
} catch {
// DB not available during dev without env vars
}
const urls = [
...posts.map(post => ({ url: `/${getSlug(post.id)}`, date: post.data.date.getTime() })),
...txtFiles.map(f => ({ url: `/${f.name}`, date: f.date.getTime() })),
].sort((a, b) => b.date - a.date).map(e => e.url).concat(SUBDOMAINS);
---
<Layout title="lewis m.w." isHome urls={urls}>
{sortedCategories.map(category => {
const categoryPosts = grouped[category];
const isDefault = category === DEFAULT_CATEGORY;
return (
<section data-section={category}>
{!isDefault && <a class="section-label" href={`?just=${category}`}>{category}</a>}
<div class="entry-list" set:html={categoryPosts.map(post =>
`<span class="entry">${formatListItem(post.data.date, `/${getSlug(post.id)}`, post.data.title, { suffix: wordCount(post.body) })}</span>`
).join('')} />
</section>
);
})}
<section data-section={SECTIONS.files}>
<a class="section-label" href={`?just=${SECTIONS.files}`}>{SECTIONS.files}</a>
<div class="entry-list" set:html={txtFiles.map(f => {
const name = f.name.replace(/\.txt$/, '');
return `<span class="entry">${formatListItem(f.date, `/${f.name}`, name, { suffix: f.description })}</span>`;
}).join('')} />
</section>
<section data-section={SECTIONS.bookmarks}>
<a class="section-label" href={`?just=${SECTIONS.bookmarks}`}>{SECTIONS.bookmarks}</a>
<div class="entry-list" set:html={bookmarks.map(b =>
`<span class="entry">${formatListItem(b.data.date, b.data.url, b.data.title, { suffix: extractDomain(b.data.url) })}</span>`
).join('')} />
</section>
<section data-section={SECTIONS.guestbook}>
<a class="section-label" href={`?just=${SECTIONS.guestbook}`}>{SECTIONS.guestbook}</a>
<div class="guestbook-entries" set:html={guestbookEntries.map(e => {
const safeName = escapeHtml(e.name);
const safeMessage = escapeHtml(e.message.replace(/\n/g, ' '));
const nameHtml = e.url ? `<a href="${escapeHtml(e.url)}"><b>${safeName}</b></a>` : `<b>${safeName}</b>`;
return `<span class="guestbook-entry"><span class="list-meta"><span class="muted">${formatDate(e.createdAt)}</span> </span><span>${nameHtml} ${safeMessage}</span></span>`;
}).join('')} /></div>
<form id="guestbook-form" class="guestbook-form">
<label class="sr-only" for="gb-name">name</label>
<input id="gb-name" type="text" name="name" placeholder="name" required maxlength="100" /><br />
<label class="sr-only" for="gb-message">message</label>
<input id="gb-message" type="text" name="message" placeholder="message" required maxlength="500" /><br />
<label class="sr-only" for="gb-url">url</label>
<input id="gb-url" type="url" name="url" placeholder="url (optional)" maxlength="200" /><br />
<button type="submit">sign</button>
<span id="guestbook-status"></span>
</form>
</section>
<script>
import { initGuestbookForm } from '../scripts/guestbook-sign';
initGuestbookForm();
</script>
</Layout>

21
src/pages/sitemap.txt.ts Normal file
View file

@ -0,0 +1,21 @@
import { getCollection } from 'astro:content';
import type { APIContext } from 'astro';
import { getSlug } from '../lib/posts';
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 txtFiles = getTxtFiles().map(f => f.name);
const urls = [
'/',
...posts.map(post => `/${getSlug(post.id)}`),
...txtFiles.map(txt => `/${txt}`),
].map(p => `${site}${p}`);
return new Response([...urls, ...SUBDOMAINS].join('\n'), {
headers: { 'Content-Type': 'text/plain' },
});
}

View file

@ -0,0 +1,49 @@
import { visit } from 'unist-util-visit';
import type { Root } from 'mdast';
function nodeToText(node: any): string {
if (node.type === 'text') return node.value;
if (node.children) return node.children.map(nodeToText).join('');
return '';
}
export default function remarkAside() {
return (tree: Root) => {
visit(tree, ['textDirective', 'leafDirective'], (node: any) => {
if (node.name !== 'left' && node.name !== 'right') return;
const data = node.data || (node.data = {});
data.hName = 'span';
data.hProperties = { className: [node.name] };
});
visit(tree, 'containerDirective', (node: any) => {
if (node.name !== 'grid') return;
let html = '<div class="grid">';
for (const child of node.children) {
if (child.data?.directiveLabel) continue;
if (child.type === 'paragraph' && child.children?.length > 0) {
const firstChild = child.children[0];
if (firstChild?.type === 'textDirective' && firstChild.name === 'label') {
const labelText = nodeToText(firstChild);
const href = firstChild.attributes?.href;
const contentText = child.children
.slice(1)
.map(nodeToText)
.join('')
.replace(/^\s+/, '');
if (href) {
html += `<a class="link" href="${href}">${labelText}</a>`;
} else {
html += `<span class="label">${labelText}</span>`;
}
html += `<div class="content">${contentText}</div>`;
}
}
}
html += '</div>';
node.type = 'html';
node.value = html;
delete node.children;
});
};
}

View file

@ -0,0 +1,43 @@
export function initGuestbookForm() {
const form = document.getElementById('guestbook-form') as HTMLFormElement | null;
if (!form) return;
const status = document.getElementById('guestbook-status')!;
form.addEventListener('submit', async (e) => {
e.preventDefault();
status.textContent = '';
const data = new FormData(form);
const name = (data.get('name') as string).trim();
const message = (data.get('message') as string).trim();
const url = (data.get('url') as string).trim() || null;
if (!name || !message) return;
const button = form.querySelector('button')!;
button.disabled = true;
try {
const res = await fetch('/api/guestbook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, message, url }),
});
if (res.ok) {
status.textContent = ' thanks! pending approval.';
form.reset();
} else if (res.status === 429) {
status.textContent = ' too many requests, try later.';
} else {
const body = await res.json().catch(() => null);
status.textContent = body?.error ? ` ${body.error}` : ' error';
}
} catch {
status.textContent = ' failed';
} finally {
button.disabled = false;
}
});
}

182
src/styles/global.css Normal file
View file

@ -0,0 +1,182 @@
body {
box-sizing: border-box;
max-width: 34rem;
margin: 0 auto;
padding: 1rem;
text-align: justify;
font-family: 'Times New Roman', serif;
}
img {
max-width: 100%;
height: auto;
}
h1, h2, h3, h4, h5, h6 {
margin-bottom: 0.25rem;
}
.muted {
color: #888;
font-size: 0.9rem;
}
.left, .right {
display: block;
font-size: 0.9rem;
}
@media (min-width: 58rem) {
.left, .right {
display: inline;
position: relative;
width: 10rem;
margin-top: -2.25em;
}
.left {
float: left;
margin-left: -12rem;
}
.right {
float: right;
margin-right: -12rem;
}
}
div.grid {
display: grid;
grid-template-columns: minmax(2.375rem, min-content) 1fr;
gap: 0.375rem 0.75rem;
line-height: 1.25;
margin-bottom: 2rem;
margin-top: 0;
}
div.grid .label,
div.grid .link {
display: block;
margin-top: 0.375rem;
margin-block: 0;
white-space: nowrap;
font-variant-numeric: tabular-nums;
line-height: 1.4;
}
div.grid .label {
text-align: right;
color: #888;
font-style: italic;
}
div.grid .link {
text-align: right;
}
div.grid .content {
display: block;
}
section {
margin: 1rem 0;
}
html[data-has] .guestbook-form {
display: none;
}
header {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 2rem;
}
.header-name {
white-space: nowrap;
}
.header-links {
text-align: right;
}
section pre {
margin: 0;
}
.entry-list {
margin: 0;
}
.entry {
display: grid;
grid-template-columns: 4rem 1fr;
align-items: baseline;
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;
}
.guestbook-entry {
display: grid;
grid-template-columns: 4rem 1fr;
align-items: baseline;
break-inside: avoid;
}
.guestbook-form {
margin-top: 0.5rem;
margin-left: 4rem;
}
html[data-compact] .list-meta {
display: none;
}
html[data-compact] .entry {
display: block;
}
html[data-compact] .entry-list {
columns: 1;
}
html[data-compact] .guestbook-entry {
display: block;
}
html[data-compact] .guestbook-form {
margin-left: 0 !important;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}