refactor: moves apps outside of apps/ path

This commit is contained in:
Lewis Wynne 2026-01-29 02:34:45 +00:00
parent b2d1a5ae9e
commit c85e2e2357
45 changed files with 4 additions and 3 deletions

6
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
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
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
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
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
www/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

27
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

6
www/public/txt/cv.txt Normal file
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

26
www/public/txt/now.txt Normal file
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

11
www/public/txt/stats.txt Normal file
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/content/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}`);

1
www/src/content Submodule

@ -0,0 +1 @@
Subproject commit b0e2b5104e32f8cbfabc698415fbb9478d02534c

31
www/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: './src/content/posts' }),
schema: z.object({
title: z.string(),
date: z.coerce.date(),
pinned: z.boolean().optional(),
category: z.string().optional(),
draft: z.boolean().optional(),
})
});
const bookmarks = defineCollection({
loader: file('./src/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
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
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
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));
}

93
www/src/pages/admin.astro Normal file
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,31 @@
---
import { getCollection } from 'astro:content';
import Layout from '../../layouts/Layout.astro';
const bookmarksCollection = await getCollection('bookmarks');
const bookmarks = bookmarksCollection
.sort((a, b) => 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}`;
}
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">${formatDate(b.data.date)}</span> <a href="${b.data.url}">${b.data.title}</a> <span class="muted">(${extractDomain(b.data.url)})</span>`).join('\n')} />
</details>
</Layout>

View file

@ -0,0 +1,48 @@
---
export const prerender = false;
import { getSession } from 'auth-astro/server';
import { getCollection, render } from 'astro:content';
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 });
}
const slug = Astro.params.slug;
const posts = await getCollection('posts', ({ data }) => data.draft === true);
const post = posts.find(p => p.id === slug);
if (!post) {
return new Response('Not found', { status: 404 });
}
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,71 @@
---
export const prerender = false;
import { getSession } from 'auth-astro/server';
import { getCollection } from 'astro:content';
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 });
}
const posts = await getCollection('posts', ({ data }) => data.draft === true);
// 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="drafts - lewis m.w.">
<p class="muted">logged in as {session.user?.name} <a href="/api/auth/signout">sign out</a></p>
{sortedCategories.length === 0 ? (
<p class="muted">no drafts</p>
) : (
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="/draft/${post.id}">${post.data.title}</a>${post.data.pinned ? ' [pinned]' : ''}`).join('\n')} />
</details>
))
)}
</Layout>

54
www/src/pages/feed.xml.ts Normal file
View file

@ -0,0 +1,54 @@
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
import fs from 'node:fs';
import path from 'node:path';
import type { APIContext } from 'astro';
import { getGitDate } from '../utils';
interface TxtFile {
name: string;
date: Date;
}
export async function GET(context: APIContext) {
const posts = await getCollection('posts', ({ data }) => data.draft !== true);
const bookmarks = await getCollection('bookmarks');
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.data.title,
pubDate: b.data.date,
link: b.data.url,
description: b.data.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>

172
www/src/pages/index.astro Normal file
View file

@ -0,0 +1,172 @@
---
import { getCollection } from 'astro:content';
import Layout from '../layouts/Layout.astro';
import yaml from 'js-yaml';
import fs from 'node:fs';
import path from 'node:path';
import { getApprovedEntries, type GuestbookEntry } from '../lib/db';
import { getGitDate } from '../utils';
interface TxtFile {
name: string;
date: Date;
pinned: boolean;
}
interface TxtConfig {
pinned?: string[];
}
const posts = await getCollection('posts', ({ data }) => data.draft !== true);
// 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 bookmarksCollection = await getCollection('bookmarks');
const bookmarks = bookmarksCollection
.sort((a, b) => b.data.date.getTime() - a.data.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 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">${formatDate(b.data.date)}</span> <a href="${b.data.url}">${b.data.title}</a> <span class="muted">(${extractDomain(b.data.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', ({ data }) => data.draft !== true);
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', ({ data }) => data.draft !== true);
// 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>

28
www/src/pages/random.ts Normal file
View file

@ -0,0 +1,28 @@
import { getCollection } from 'astro:content';
import fs from 'node:fs';
import path from 'node:path';
import type { APIContext } from 'astro';
export const prerender = false;
export async function GET(context: APIContext) {
const site = context.site?.origin ?? 'https://wynne.rs';
const posts = await getCollection('posts', ({ data }) => data.draft !== true);
const bookmarks = await getCollection('bookmarks');
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.data.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', ({ data }) => data.draft !== true);
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;
});
};
}

96
www/src/styles/global.css Normal file
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
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);
}
}