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

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