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

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' },
});
}