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

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