refactor: rename blog app to www

This commit is contained in:
Lewis Wynne 2026-01-29 00:56:59 +00:00
parent 5ff2a3056e
commit 87c8260c80
40 changed files with 6 additions and 6 deletions

6
apps/www/.env.example Normal file
View file

@ -0,0 +1,6 @@
ASTRO_DB_REMOTE_URL=libsql://your-db.turso.io
ASTRO_DB_APP_TOKEN=your-token
GITHUB_CLIENT_ID=your-client-id
GITHUB_CLIENT_SECRET=your-client-secret
ADMIN_GITHUB_ID=your-github-user-id
AUTH_SECRET=generate-with-openssl-rand-base64-32

24
apps/www/astro.config.mjs Normal file
View file

@ -0,0 +1,24 @@
import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel';
import db from '@astrojs/db';
import auth from 'auth-astro';
import remarkDirective from 'remark-directive';
import remarkGfm from 'remark-gfm';
import remarkSlug from 'remark-slug';
import remarkSmartypants from 'remark-smartypants';
import remarkAside from './src/plugins/remark-aside.ts';
export default defineConfig({
output: 'static',
adapter: vercel(),
integrations: [db(), auth()],
markdown: {
remarkPlugins: [
remarkGfm,
remarkDirective,
remarkAside,
remarkSlug,
remarkSmartypants
]
}
});

25
apps/www/auth.config.ts Normal file
View file

@ -0,0 +1,25 @@
import GitHub from '@auth/core/providers/github';
import { defineConfig } from 'auth-astro';
export default defineConfig({
providers: [
GitHub({
clientId: import.meta.env.GITHUB_CLIENT_ID,
clientSecret: import.meta.env.GITHUB_CLIENT_SECRET,
}),
],
callbacks: {
jwt({ token, account, profile }) {
if (account && profile) {
token.id = profile.id;
}
return token;
},
session({ session, token }) {
if (session.user && token.id) {
(session.user as any).id = String(token.id);
}
return session;
},
},
});

16
apps/www/db/config.ts Normal file
View file

@ -0,0 +1,16 @@
import { defineDb, defineTable, column } from 'astro:db';
const Guestbook = defineTable({
columns: {
id: column.number({ primaryKey: true }),
name: column.text(),
message: column.text(),
url: column.text({ optional: true }),
createdAt: column.date({ default: new Date() }),
approved: column.boolean({ default: false }),
},
});
export default defineDb({
tables: { Guestbook },
});

22
apps/www/db/seed.ts Normal file
View file

@ -0,0 +1,22 @@
import { db, Guestbook } from 'astro:db';
export default async function seed() {
await db.insert(Guestbook).values([
{
id: 1,
name: 'alice',
message: 'love the site!',
url: 'https://example.com',
createdAt: new Date('2026-01-20'),
approved: true,
},
{
id: 2,
name: 'bob',
message: 'great blog posts',
url: null,
createdAt: new Date('2026-01-18'),
approved: true,
},
]);
}

6861
apps/www/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

27
apps/www/package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "@ily/www",
"type": "module",
"scripts": {
"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"
},
"dependencies": {
"@astrojs/db": "^0.19.0",
"@astrojs/rss": "^4.0.15",
"@astrojs/vercel": "^9.0.4",
"@auth/core": "^0.37.4",
"astro": "^5.16.13",
"auth-astro": "^4.2.0",
"js-yaml": "^4.1.1"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"remark-directive": "^3.0.0",
"remark-gfm": "^4.0.0",
"remark-slug": "^7.0.1",
"remark-smartypants": "^3.0.2",
"unist-util-visit": "^5.1.0"
}
}

View file

@ -0,0 +1 @@
2026-01-23 - hello. we've got the skeleton of something here

View file

@ -0,0 +1,3 @@
pinned:
- now.txt
- cv.txt

View file

@ -0,0 +1,6 @@
lewis@wynne.rs
swe@thecodeguy 2025-now
python@tcz 2024-2025
freelance swe 2020-2024
1st class cyber 2020

View file

@ -0,0 +1,26 @@
23/1/26
what would i tell somebody i haven't seen in a year
right now i'm in the middle of buying a house,
though i'm at the start really. it's been waiting
more than anything. the couple we're buying from
are looking for something perfect and we're happy
to wait. but hopefully soon things will move
waiting for the house has put most things on hold
i don't want to join clubs where i am currently because
i wont be here soon(hopefully)
i'm working as an engineer. most of my time is
with a client in the voluntary sector. i maintain
a site used to refer people in need to the services
that can help them. it's old and full of legacy
code, but it's getting there now that i'm working
with it essentially full time
i spend 14 or so hours a week mentoring kid programmers,
where i teach mostly python. some javascript too
cheers,
lew

View file

@ -0,0 +1,11 @@
this site consists of [WORDS] words across [PAGES] pages
there are [POSTS] blog posts
[TXT] txt files
and [BOOKMARKS] bookmarks
[GUESTBOOK] people have signed the guestbook
this file was generated automatically on deploy
cheers,
lewis

View file

@ -0,0 +1,85 @@
import fs from 'node:fs';
import path from 'node:path';
import yaml from 'js-yaml';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.join(__dirname, '..');
function countWords(text) {
return text.split(/\s+/).filter(w => w.length > 0).length;
}
// Count blog posts and their words
const postsDir = path.join(root, 'src/content/posts');
const posts = fs.existsSync(postsDir)
? fs.readdirSync(postsDir).filter(f => f.endsWith('.md'))
: [];
let postWords = 0;
for (const post of posts) {
const content = fs.readFileSync(path.join(postsDir, post), 'utf-8');
// Remove frontmatter
const body = content.replace(/^---[\s\S]*?---/, '');
postWords += countWords(body);
}
// Count txt files and their words (excluding stats.txt which we're generating)
const txtDir = path.join(root, 'public/txt');
const txtFiles = fs.existsSync(txtDir)
? fs.readdirSync(txtDir).filter(f => f.endsWith('.txt') && f !== 'stats.txt')
: [];
let txtWords = 0;
for (const txt of txtFiles) {
const content = fs.readFileSync(path.join(txtDir, txt), 'utf-8');
txtWords += countWords(content);
}
// Count bookmarks
const bookmarksFile = path.join(root, 'src/data/bookmarks.yaml');
const bookmarks = fs.existsSync(bookmarksFile)
? yaml.load(fs.readFileSync(bookmarksFile, 'utf-8')) || []
: [];
// Guestbook count - read from built JSON file
const guestbookJsonFile = path.join(root, '.vercel/output/static/guestbook-count.json');
let guestbookCount = 0;
if (fs.existsSync(guestbookJsonFile)) {
const data = JSON.parse(fs.readFileSync(guestbookJsonFile, 'utf-8'));
guestbookCount = data.count;
}
// Calculate totals (excluding stats.txt words for now, we'll add them after generating)
const totalPages = 1 + 1 + posts.length + 1 + txtFiles.length + 1 + 1; // home, blog index, posts, txt index, txts, bookmarks, guestbook
// Read template from public/txt/stats.txt and replace placeholders
const template = fs.readFileSync(path.join(root, 'public/txt/stats.txt'), 'utf-8');
// First pass: generate stats without stats.txt word count
let stats = template
.replace('[PAGES]', totalPages.toString())
.replace('[POSTS]', posts.length.toString())
.replace('[TXT]', txtFiles.length.toString())
.replace('[BOOKMARKS]', bookmarks.length.toString())
.replace('[GUESTBOOK]', guestbookCount.toString());
// Count words in the stats file itself (before adding [WORDS])
const statsWords = countWords(stats.replace('[WORDS]', '0'));
const totalWords = postWords + txtWords + statsWords;
// Final pass: replace [WORDS] with actual total
stats = stats.replace('[WORDS]', totalWords.toString());
// Write to Vercel output
const outputDir = path.join(root, '.vercel/output/static/txt');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
fs.writeFileSync(path.join(outputDir, 'stats.txt'), stats);
console.log('Generated stats.txt');
console.log(` Words: ${totalWords}`);
console.log(` Pages: ${totalPages}`);
console.log(` Posts: ${posts.length}`);
console.log(` Txt files: ${txtFiles.length}`);
console.log(` Bookmarks: ${bookmarks.length}`);
console.log(` Guestbook: ${guestbookCount}`);

View file

@ -0,0 +1,15 @@
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';
const posts = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/posts' }),
schema: z.object({
title: z.string(),
date: z.coerce.date(),
pinned: z.boolean().optional(),
category: z.string().optional(),
})
});
export const collections = { posts };

View file

@ -0,0 +1,34 @@
---
title: hello world
date: 2026-01-23
pinned: true
---
i've always kept my projects online and public, and i've always had some sort of homepage. it was originally bebo, then some sites i made through school, then facebook.
:right[i liked farmville but otherwise didnt use facebook much]
my next site after that was one that i kept updated for years. but every time i updated it, i also tacked on features i liked, and then ultimately it became a mess of ideas. it was never supposed to be well-engineered, but i like clean code and i ended up too busy fixing things to do anything new or interesting
so i deleted everything i could think of that was even remotely fancy and made this
there's one bit of javascript on this site, and that's the [/random](https://wynne.rs/random/) route to take you to a random page
everything else is mostly vanilla. there's no pagination or searching, though i did add categorisation
plain text files are hosted and indexed at [/txt](https://wynne.rs/txt/),
longer form things are in categories at [/md](https://wynne.rs/txt/),
there are [/bookmarks](https://wynne.rs/bookmarks/) for things that link elsewhere,
and there's a [/guestbook](https://wynne.rs/guestbook/) that used to have placeable stickers and selectable card backgrounds, and hand-drawn art and pagination, and a gallery section where people could paint pictures, and... is now just a list of names and the text that was left, which i prefer
:right[the old guestbook still exists; its just not the one i link anymore]
in the end, everything on this site is visible straight from the index page in one massive list, ordered by date, with pinned posts at the top of their respective category.
and that's it. i don't need anything else to archive things i care about
cheers,
lewis x

View file

@ -0,0 +1,19 @@
- date: 2026-01-23
title: personal websites with a /now page
url: https://nownownow.com/
- date: 2025-04-18
title: Accessible UK Train Timetables
url: https://traintimes.org.uk/
- date: 2024-05-20
title: Game Programming Patterns
url: https://gameprogrammingpatterns.com/contents.html
- date: 2023-09-04
title: Procedural Map Generation Techniques
url: https://www.youtube.com/watch?v=TlLIOgWYVpI
- date: 2023-09-04
title: Procedural Level Design in Brogue and Beyond
url: https://www.youtube.com/watch?v=Uo9-IcHhq_w

10
apps/www/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;
}

View file

@ -0,0 +1,23 @@
---
import '../styles/global.css';
interface Props {
title: string;
showHeader?: boolean;
isHome?: boolean;
}
const { title, showHeader = true, isHome = false } = Astro.props;
---
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>{title}</title><link rel="alternate" type="application/rss+xml" title="wynne.rs" href="/feed.xml" /></head>
<body>
{showHeader && (
<header>
<pre>{isHome ? 'lewis m.w.' : <a href="/">lewis m.w.</a>} <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="/random">random</a></pre>
</header>
)}
<slot />
</body>
</html>

3
apps/www/src/lib/auth.ts Normal file
View file

@ -0,0 +1,3 @@
export function isAdmin(userId: string | undefined): boolean {
return userId === import.meta.env.ADMIN_GITHUB_ID;
}

29
apps/www/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));
}

View file

@ -0,0 +1,93 @@
---
export const prerender = false;
import { getSession } from 'auth-astro/server';
import { getPendingEntries, type GuestbookEntry } from '../lib/db';
import { isAdmin } from '../lib/auth';
import Layout from '../layouts/Layout.astro';
let session;
try {
session = await getSession(Astro.request);
} catch {
return new Response('Auth not configured', { status: 500 });
}
if (!session) {
return Astro.redirect('/api/auth/signin');
}
if (!isAdmin(session.user?.id)) {
return new Response('Forbidden', { status: 403 });
}
let entries: GuestbookEntry[] = [];
try {
entries = await getPendingEntries();
} catch {
// handle error
}
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}`;
}
---
<Layout title="admin - guestbook" showHeader={false}>
<h1>guestbook admin</h1>
<p>logged in as {session.user?.name} <a href="/api/auth/signout">sign out</a></p>
<p><button id="deploy">redeploy site</button> <span id="deploy-status"></span></p>
{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.getElementById('deploy')?.addEventListener('click', async (e) => {
const btn = e.target as HTMLButtonElement;
const status = document.getElementById('deploy-status');
btn.disabled = true;
if (status) status.textContent = 'deploying...';
const res = await fetch('/api/deploy', { method: 'POST' });
if (res.ok) {
if (status) status.textContent = 'deploy triggered!';
} else {
const data = await res.json();
if (status) status.textContent = data.error || 'deploy failed';
}
btn.disabled = false;
});
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,36 @@
import type { APIRoute } from 'astro';
import { getSession } from 'auth-astro/server';
import { isAdmin } from '../../lib/auth';
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
const session = await getSession(request);
if (!session?.user?.id || !isAdmin(session.user.id)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
const hookUrl = import.meta.env.VERCEL_DEPLOY_HOOK;
if (!hookUrl) {
return new Response(JSON.stringify({ error: 'Deploy hook not configured' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
const res = await fetch(hookUrl, { method: 'POST' });
if (!res.ok) {
return new Response(JSON.stringify({ error: 'Failed to trigger deploy' }), {
status: 502,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json' },
});
};

View file

@ -0,0 +1,30 @@
import type { APIRoute } from 'astro';
import { createEntry } from '../../lib/db';
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
try {
const data = await request.json();
const { name, message, url } = data;
if (!name || !message) {
return new Response(JSON.stringify({ error: 'Name and message are required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
await createEntry(name.slice(0, 100), message.slice(0, 500), url?.slice(0, 200) || null);
return new Response(JSON.stringify({ success: true }), {
status: 201,
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
return new Response(JSON.stringify({ error: 'Failed to create entry' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
};

View file

@ -0,0 +1,54 @@
import type { APIRoute } from 'astro';
import { getSession } from 'auth-astro/server';
import { approveEntry, deleteEntry } from '../../../lib/db';
import { isAdmin } from '../../../lib/auth';
export const prerender = false;
export const PATCH: APIRoute = async ({ params, request }) => {
const session = await getSession(request);
if (!session?.user?.id || !isAdmin(session.user.id)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
const id = parseInt(params.id!, 10);
if (isNaN(id)) {
return new Response(JSON.stringify({ error: 'Invalid ID' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
await approveEntry(id);
return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json' },
});
};
export const DELETE: APIRoute = async ({ params, request }) => {
const session = await getSession(request);
if (!session?.user?.id || !isAdmin(session.user.id)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
const id = parseInt(params.id!, 10);
if (isNaN(id)) {
return new Response(JSON.stringify({ error: 'Invalid ID' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
await deleteEntry(id);
return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json' },
});
};

View file

@ -0,0 +1,42 @@
---
import Layout from '../../layouts/Layout.astro';
import yaml from 'js-yaml';
import bookmarksRaw from '../../data/bookmarks.yaml?raw';
interface Bookmark {
date: string;
title: string;
url: string;
}
const bookmarks = (yaml.load(bookmarksRaw) as Bookmark[])
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
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}`;
}
function formatBookmarkDate(dateStr: string): string {
const date = new Date(dateStr);
return formatDate(date);
}
function extractDomain(url: string): string {
try {
const parsed = new URL(url);
return parsed.hostname.replace(/^www\./, '');
} catch {
return url;
}
}
---
<Layout title="bookmarks - lewis m.w.">
<details open>
<summary>bookmarks</summary>
<pre set:html={bookmarks.map(b => `<span class="muted">${formatBookmarkDate(b.date)}</span> <a href="${b.url}">${b.title}</a> <span class="muted">(${extractDomain(b.url)})</span>`).join('\n')} />
</details>
</Layout>

View file

@ -0,0 +1,62 @@
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
import yaml from 'js-yaml';
import bookmarksRaw from '../data/bookmarks.yaml?raw';
import fs from 'node:fs';
import path from 'node:path';
import type { APIContext } from 'astro';
import { getGitDate } from '../utils';
interface Bookmark {
date: string;
title: string;
url: string;
}
interface TxtFile {
name: string;
date: Date;
}
export async function GET(context: APIContext) {
const posts = await getCollection('posts');
const bookmarks = yaml.load(bookmarksRaw) as Bookmark[];
const txtDir = path.join(process.cwd(), 'public/txt');
const txtFiles: TxtFile[] = fs.existsSync(txtDir)
? fs.readdirSync(txtDir)
.filter(file => file.endsWith('.txt'))
.map(name => ({
name,
date: getGitDate(path.join(txtDir, name)),
}))
: [];
const items = [
...posts.map(post => ({
title: post.data.title,
pubDate: post.data.date,
link: `/md/${post.id}`,
description: post.data.title,
})),
...txtFiles.map(txt => ({
title: txt.name,
pubDate: txt.date,
link: `/txt/${txt.name}`,
description: txt.name,
})),
...bookmarks.map(b => ({
title: b.title,
pubDate: new Date(b.date),
link: b.url,
description: b.title,
})),
].sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime());
return rss({
title: 'wynne.rs',
description: '',
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' },
});
};

View file

@ -0,0 +1,67 @@
---
import Layout from '../../layouts/Layout.astro';
import { getApprovedEntries, type GuestbookEntry } from '../../lib/db';
let guestbookEntries: GuestbookEntry[] = [];
try {
guestbookEntries = await getApprovedEntries();
} catch {
// DB not available during dev without env vars
}
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}`;
}
---
<Layout title="guestbook - lewis m.w.">
<details open>
<summary>guestbook</summary>
<div class="guestbook-entries">
{guestbookEntries.map(e => (
<div class="guestbook-entry">
<span class="muted">{formatDate(e.createdAt)}</span>
<span><b set:html={e.url ? `<a href="${e.url}">${e.name}</a>` : e.name} /> {e.message}</span>
</div>
))}
<div class="guestbook-entry">
<span></span>
<span><a href="#" id="sign-guestbook">sign</a><span id="guestbook-status"></span></span>
</div>
</div>
</details>
<script>
document.getElementById('sign-guestbook')?.addEventListener('click', async (e) => {
e.preventDefault();
const status = document.getElementById('guestbook-status')!;
const name = prompt('name:');
if (!name) return;
const message = prompt('message:');
if (!message) return;
const url = prompt('url (optional):');
try {
const res = await fetch('/api/guestbook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, message, url: url || null }),
});
if (res.ok) {
status.textContent = ' thanks! pending approval.';
} else {
status.textContent = ' error';
}
} catch {
status.textContent = ' failed';
}
});
</script>
</Layout>

View file

@ -0,0 +1,183 @@
---
import { getCollection } from 'astro:content';
import Layout from '../layouts/Layout.astro';
import yaml from 'js-yaml';
import bookmarksRaw from '../data/bookmarks.yaml?raw';
import fs from 'node:fs';
import path from 'node:path';
import { getApprovedEntries, type GuestbookEntry } from '../lib/db';
import { getGitDate } from '../utils';
interface Bookmark {
date: string;
title: string;
url: string;
}
interface TxtFile {
name: string;
date: Date;
pinned: boolean;
}
interface TxtConfig {
pinned?: string[];
}
const posts = await getCollection('posts');
// Group by category (default: "posts")
const grouped = posts.reduce((acc, post) => {
const category = post.data.category ?? 'posts';
if (!acc[category]) acc[category] = [];
acc[category].push(post);
return acc;
}, {} as Record<string, typeof posts>);
// Sort categories: "posts" first, then alphabetically
const sortedCategories = Object.keys(grouped).sort((a, b) => {
if (a === 'posts') return -1;
if (b === 'posts') return 1;
return a.localeCompare(b);
});
// Sort posts within each category: pinned first, then by date descending
for (const category of sortedCategories) {
grouped[category].sort((a, b) => {
if (a.data.pinned && !b.data.pinned) return -1;
if (!a.data.pinned && b.data.pinned) return 1;
return b.data.date.getTime() - a.data.date.getTime();
});
}
const bookmarks = (yaml.load(bookmarksRaw) as Bookmark[])
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
// Auto-discover txt files from public/txt/
const txtDir = path.join(process.cwd(), 'public/txt');
const txtConfigPath = path.join(txtDir, 'config.yaml');
const txtConfig: TxtConfig = fs.existsSync(txtConfigPath)
? yaml.load(fs.readFileSync(txtConfigPath, 'utf8')) as TxtConfig
: {};
const pinnedSet = new Set(txtConfig.pinned || []);
const txtFiles: TxtFile[] = fs.existsSync(txtDir)
? fs.readdirSync(txtDir)
.filter(file => file.endsWith('.txt'))
.map(name => {
const filePath = path.join(txtDir, name);
return { name, date: getGitDate(filePath), pinned: pinnedSet.has(name) };
})
.sort((a, b) => {
if (a.pinned && !b.pinned) return -1;
if (!a.pinned && b.pinned) return 1;
return b.date.getTime() - a.date.getTime();
})
: [];
let guestbookEntries: GuestbookEntry[] = [];
try {
guestbookEntries = await getApprovedEntries();
} catch {
// DB not available during dev without env vars
}
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}`;
}
function formatBookmarkDate(dateStr: string): string {
const date = new Date(dateStr);
return formatDate(date);
}
function extractDomain(url: string): string {
try {
const parsed = new URL(url);
return parsed.hostname.replace(/^www\./, '');
} catch {
return url;
}
}
---
<Layout title="lewis m.w." isHome>
{sortedCategories.map(category => {
const categoryPosts = grouped[category];
return (
<details open>
<summary>{category}</summary>
<pre set:html={[
...categoryPosts.slice(0, 10).map(post => `<span class="muted">${formatDate(post.data.date)}</span> <a href="/md/${post.id}">${post.data.title}</a>${post.data.pinned ? ' [pinned]' : ''}`),
...(categoryPosts.length > 10 ? [`<a href="/md/">+${categoryPosts.length - 10} more</a>`] : [])
].join('\n')} />
</details>
);
})}
<details open>
<summary>txt</summary>
<pre set:html={[
...txtFiles.slice(0, 10).map(f => `<span class="muted">${formatDate(f.date)}</span> <a href="/txt/${f.name}">${f.name}</a>${f.pinned ? ' [pinned]' : ''}`),
...(txtFiles.length > 10 ? [`<a href="/txt/">+${txtFiles.length - 10} more</a>`] : [])
].join('\n')} />
</details>
<details open>
<summary>bookmarks</summary>
<pre set:html={[
...bookmarks.slice(0, 10).map(b => `<span class="muted">${formatBookmarkDate(b.date)}</span> <a href="${b.url}">${b.title}</a> <span class="muted">(${extractDomain(b.url)})</span>`),
...(bookmarks.length > 10 ? [`<a href="/bookmarks/">+${bookmarks.length - 10} more</a>`] : [])
].join('\n')} />
</details>
<details open>
<summary>guestbook</summary>
<div class="guestbook-entries">
{guestbookEntries.slice(0, 10).map(e => (
<div class="guestbook-entry">
<span class="muted">{formatDate(e.createdAt)}</span>
<span><b set:html={e.url ? `<a href="${e.url}">${e.name}</a>` : e.name} /> {e.message}</span>
</div>
))}
<div class="guestbook-entry">
<span>{guestbookEntries.length > 10 && <a href="/guestbook/">+{guestbookEntries.length - 10} more</a>}</span>
<span><a href="#" id="sign-guestbook">sign</a><span id="guestbook-status"></span></span>
</div>
</div>
</details>
<script>
document.getElementById('sign-guestbook')?.addEventListener('click', async (e) => {
e.preventDefault();
const status = document.getElementById('guestbook-status')!;
const name = prompt('name:');
if (!name) return;
const message = prompt('message:');
if (!message) return;
const url = prompt('url (optional):');
try {
const res = await fetch('/api/guestbook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, message, url: url || null }),
});
if (res.ok) {
status.textContent = ' thanks! pending approval.';
} else {
status.textContent = ' error';
}
} catch {
status.textContent = ' failed';
}
});
</script>
</Layout>

View file

@ -0,0 +1,30 @@
---
import { getCollection, render } from 'astro:content';
import Layout from '../../layouts/Layout.astro';
export async function getStaticPaths() {
const posts = await getCollection('posts');
return posts.map(post => ({
params: { slug: post.id },
props: { post }
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
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}`;
}
---
<Layout title={`${post.data.title} - lewis m.w.`}>
<article>
<h1>{post.data.title}</h1>
<p class="muted" style="margin-top: 0;">{formatDate(post.data.date)}</p>
<Content />
</article>
</Layout>

View file

@ -0,0 +1,46 @@
---
import { getCollection } from 'astro:content';
import Layout from '../../layouts/Layout.astro';
const posts = await getCollection('posts');
// Group by category (default: "posts")
const grouped = posts.reduce((acc, post) => {
const category = post.data.category ?? 'posts';
if (!acc[category]) acc[category] = [];
acc[category].push(post);
return acc;
}, {} as Record<string, typeof posts>);
// Sort categories: "posts" first, then alphabetically
const sortedCategories = Object.keys(grouped).sort((a, b) => {
if (a === 'posts') return -1;
if (b === 'posts') return 1;
return a.localeCompare(b);
});
// Sort posts within each category: pinned first, then by date descending
for (const category of sortedCategories) {
grouped[category].sort((a, b) => {
if (a.data.pinned && !b.data.pinned) return -1;
if (!a.data.pinned && b.data.pinned) return 1;
return b.data.date.getTime() - a.data.date.getTime();
});
}
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}`;
}
---
<Layout title="md - lewis m.w.">
{sortedCategories.map(category => (
<details open>
<summary>{category}</summary>
<pre set:html={grouped[category].map(post => `<span class="muted">${formatDate(post.data.date)}</span> <a href="/md/${post.id}">${post.data.title}</a>${post.data.pinned ? ' [pinned]' : ''}`).join('\n')} />
</details>
))}
</Layout>

View file

@ -0,0 +1,36 @@
import { getCollection } from 'astro:content';
import yaml from 'js-yaml';
import bookmarksRaw from '../data/bookmarks.yaml?raw';
import fs from 'node:fs';
import path from 'node:path';
import type { APIContext } from 'astro';
export const prerender = false;
interface Bookmark {
date: string;
title: string;
url: string;
}
export async function GET(context: APIContext) {
const site = context.site?.origin ?? 'https://wynne.rs';
const posts = await getCollection('posts');
const bookmarks = yaml.load(bookmarksRaw) as Bookmark[];
const txtDir = path.join(process.cwd(), 'public/txt');
const txtFiles = fs.existsSync(txtDir)
? fs.readdirSync(txtDir).filter(file => file.endsWith('.txt'))
: [];
const urls = [
...posts.map(post => `/md/${post.id}`),
...txtFiles.map(txt => `/txt/${txt}`),
...bookmarks.map(b => b.url),
];
const random = urls[Math.floor(Math.random() * urls.length)];
const redirectUrl = random.startsWith('http') ? random : `${site}${random}`;
return Response.redirect(redirectUrl, 302);
}

View file

@ -0,0 +1,32 @@
import { getCollection } from 'astro:content';
import fs from 'node:fs';
import path from 'node:path';
import type { APIContext } from 'astro';
const SUBDOMAINS = [
'https://penfield.wynne.rs/',
];
export async function GET(context: APIContext) {
const site = context.site?.origin ?? 'https://wynne.rs';
const posts = await getCollection('posts');
const txtDir = path.join(process.cwd(), 'public/txt');
const txtFiles = fs.existsSync(txtDir)
? fs.readdirSync(txtDir).filter(file => file.endsWith('.txt'))
: [];
const urls = [
'/',
'/md',
...posts.map(post => `/md/${post.id}`),
'/txt',
...txtFiles.map(txt => `/txt/${txt}`),
'/bookmarks',
'/guestbook',
].map(p => `${site}${p}`);
return new Response([...urls, ...SUBDOMAINS].join('\n'), {
headers: { 'Content-Type': 'text/plain' },
});
}

View file

@ -0,0 +1,52 @@
---
import Layout from '../../layouts/Layout.astro';
import fs from 'node:fs';
import path from 'node:path';
import yaml from 'js-yaml';
import { getGitDate } from '../../utils';
interface TxtFile {
name: string;
date: Date;
pinned: boolean;
}
interface TxtConfig {
pinned?: string[];
}
const txtDir = path.join(process.cwd(), 'public/txt');
const configPath = path.join(txtDir, 'config.yaml');
const config: TxtConfig = fs.existsSync(configPath)
? yaml.load(fs.readFileSync(configPath, 'utf8')) as TxtConfig
: {};
const pinnedSet = new Set(config.pinned || []);
const txtFiles: TxtFile[] = fs.existsSync(txtDir)
? fs.readdirSync(txtDir)
.filter(file => file.endsWith('.txt'))
.map(name => {
const filePath = path.join(txtDir, name);
return { name, date: getGitDate(filePath), pinned: pinnedSet.has(name) };
})
.sort((a, b) => {
if (a.pinned && !b.pinned) return -1;
if (!a.pinned && b.pinned) return 1;
return b.date.getTime() - a.date.getTime();
})
: [];
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}`;
}
---
<Layout title="txt - lewis m.w.">
<details open>
<summary>txt</summary>
<pre set:html={txtFiles.map(f => `<span class="muted">${formatDate(f.date)}</span> <a href="/txt/${f.name}">${f.name}</a>${f.pinned ? ' [pinned]' : ''}`).join('\n')} />
</details>
</Layout>

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,96 @@
body {
max-width: 38rem;
margin: 0 auto;
padding: 1rem;
}
h1, h2, h3, h4, h5, h6 {
margin-bottom: 0.25rem;
}
.muted {
color: #888;
}
.left, .right {
display: block;
font-size: 0.9rem;
}
@media (min-width: 63rem) {
.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;
}
details {
margin: 1rem 0;
}
summary {
cursor: pointer;
font-family: monospace;
list-style: none;
}
summary::-webkit-details-marker {
display: none;
}
details pre {
margin: 0;
}
.guestbook-entries {
font-family: monospace;
}
.guestbook-entry {
display: grid;
grid-template-columns: 8ch 1fr;
gap: 0 4ch;
}

10
apps/www/src/utils.ts Normal file
View file

@ -0,0 +1,10 @@
import { execSync } from 'node:child_process';
export function getGitDate(filePath: string): Date {
try {
const timestamp = execSync(`git log -1 --format=%cI -- "${filePath}"`, { encoding: 'utf8' }).trim();
return timestamp ? new Date(timestamp) : new Date(0);
} catch {
return new Date(0);
}
}