feat: extracts some repeated logic out into lib/ files

This commit is contained in:
Lewis Wynne 2026-01-31 22:10:03 +00:00
parent 38653f2aa1
commit 99c1539aad
22 changed files with 200 additions and 336 deletions

View file

@ -11,7 +11,7 @@ function countWords(text) {
} }
// Count blog posts and their words // Count blog posts and their words
const postsDir = path.join(root, 'src/content/posts'); const postsDir = path.join(root, 'src/content/md');
const posts = fs.existsSync(postsDir) const posts = fs.existsSync(postsDir)
? fs.readdirSync(postsDir).filter(f => f.endsWith('.md')) ? fs.readdirSync(postsDir).filter(f => f.endsWith('.md'))
: []; : [];

View file

@ -3,15 +3,14 @@ import { glob, file } from 'astro/loaders';
import { z } from 'astro/zod'; import { z } from 'astro/zod';
import yaml from 'js-yaml'; import yaml from 'js-yaml';
const posts = defineCollection({ const md = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/posts' }), loader: glob({ pattern: '**/*.md', base: './src/content/md' }),
schema: z.object({ schema: z.object({
title: z.string(), title: z.string(),
date: z.coerce.date(), date: z.coerce.date(),
pinned: z.boolean().optional(), pinned: z.boolean().optional(),
category: z.string().optional(), category: z.string().optional(),
draft: z.boolean().optional(), draft: z.boolean().optional(),
slug: z.string(),
}) })
}); });
@ -29,4 +28,4 @@ const bookmarks = defineCollection({
}) })
}); });
export const collections = { posts, bookmarks }; export const collections = { md, bookmarks };

19
www/src/lib/api.ts Normal file
View file

@ -0,0 +1,19 @@
import { isAdmin } from './auth';
export function jsonResponse(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
export function errorResponse(message: string, status: number): Response {
return jsonResponse({ error: message }, status);
}
export function requireAdmin(session: { user?: { id?: string } } | null): Response | null {
if (!session?.user?.id || !isAdmin(session.user.id)) {
return errorResponse('Unauthorized', 403);
}
return null;
}

15
www/src/lib/format.ts Normal file
View file

@ -0,0 +1,15 @@
export 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}`;
}
export function extractDomain(url: string): string {
try {
const parsed = new URL(url);
return parsed.hostname.replace(/^www\./, '');
} catch {
return url;
}
}

35
www/src/lib/posts.ts Normal file
View file

@ -0,0 +1,35 @@
import type { CollectionEntry } from 'astro:content';
type Post = CollectionEntry<'md'>;
export function sortPosts(posts: Post[]): Post[] {
return posts.slice().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();
});
}
export function organizePostsByCategory(posts: Post[]): {
grouped: Record<string, Post[]>;
categories: string[];
} {
const grouped = posts.reduce((acc, post) => {
const category = post.data.category ?? 'md';
if (!acc[category]) acc[category] = [];
acc[category].push(post);
return acc;
}, {} as Record<string, Post[]>);
const categories = Object.keys(grouped).sort((a, b) => {
if (a === 'md') return -1;
if (b === 'md') return 1;
return a.localeCompare(b);
});
for (const category of categories) {
grouped[category] = sortPosts(grouped[category]);
}
return { grouped, categories };
}

53
www/src/lib/txt.ts Normal file
View file

@ -0,0 +1,53 @@
import fs from 'node:fs';
import path from 'node:path';
import yaml from 'js-yaml';
import { getGitDate } from '../utils';
export interface TxtFile {
name: string;
date: Date;
pinned: boolean;
}
export interface TxtConfig {
pinned?: string[];
}
export function getTxtDir(): string {
return path.join(process.cwd(), 'public/txt');
}
export function loadTxtConfig(): TxtConfig {
const configPath = path.join(getTxtDir(), 'config.yaml');
return fs.existsSync(configPath)
? yaml.load(fs.readFileSync(configPath, 'utf8')) as TxtConfig
: {};
}
export function getTxtFiles(): TxtFile[] {
const txtDir = getTxtDir();
if (!fs.existsSync(txtDir)) return [];
const config = loadTxtConfig();
const pinnedSet = new Set(config.pinned || []);
return fs.readdirSync(txtDir)
.filter(file => file.endsWith('.txt'))
.map(name => ({
name,
date: getGitDate(path.join(txtDir, name)),
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();
});
}
export function getTxtFileNames(): string[] {
const txtDir = getTxtDir();
if (!fs.existsSync(txtDir)) return [];
return fs.readdirSync(txtDir).filter(file => file.endsWith('.txt'));
}

View file

@ -5,6 +5,7 @@ import { getSession } from 'auth-astro/server';
import { getPendingEntries, type GuestbookEntry } from '../lib/db'; import { getPendingEntries, type GuestbookEntry } from '../lib/db';
import { isAdmin } from '../lib/auth'; import { isAdmin } from '../lib/auth';
import Layout from '../layouts/Layout.astro'; import Layout from '../layouts/Layout.astro';
import { formatDate } from '../lib/format';
let session; let session;
try { try {
@ -27,13 +28,6 @@ try {
} catch { } catch {
// handle error // 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}> <Layout title="admin - guestbook" showHeader={false}>

View file

@ -1,36 +1,23 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import { getSession } from 'auth-astro/server'; import { getSession } from 'auth-astro/server';
import { isAdmin } from '../../lib/auth'; import { jsonResponse, errorResponse, requireAdmin } from '../../lib/api';
export const prerender = false; export const prerender = false;
export const POST: APIRoute = async ({ request }) => { export const POST: APIRoute = async ({ request }) => {
const session = await getSession(request); const session = await getSession(request);
const authError = requireAdmin(session);
if (!session?.user?.id || !isAdmin(session.user.id)) { if (authError) return authError;
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
const hookUrl = import.meta.env.VERCEL_DEPLOY_HOOK; const hookUrl = import.meta.env.VERCEL_DEPLOY_HOOK;
if (!hookUrl) { if (!hookUrl) {
return new Response(JSON.stringify({ error: 'Deploy hook not configured' }), { return errorResponse('Deploy hook not configured', 500);
status: 500,
headers: { 'Content-Type': 'application/json' },
});
} }
const res = await fetch(hookUrl, { method: 'POST' }); const res = await fetch(hookUrl, { method: 'POST' });
if (!res.ok) { if (!res.ok) {
return new Response(JSON.stringify({ error: 'Failed to trigger deploy' }), { return errorResponse('Failed to trigger deploy', 502);
status: 502,
headers: { 'Content-Type': 'application/json' },
});
} }
return new Response(JSON.stringify({ success: true }), { return jsonResponse({ success: true });
headers: { 'Content-Type': 'application/json' },
});
}; };

View file

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

View file

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

View file

@ -1,26 +1,11 @@
--- ---
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
import Layout from '../../layouts/Layout.astro'; import Layout from '../../layouts/Layout.astro';
import { formatDate, extractDomain } from '../../lib/format';
const bookmarksCollection = await getCollection('bookmarks'); const bookmarksCollection = await getCollection('bookmarks');
const bookmarks = bookmarksCollection const bookmarks = bookmarksCollection
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime()); .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."> <Layout title="bookmarks - lewis m.w.">

View file

@ -5,6 +5,8 @@ import { getSession } from 'auth-astro/server';
import { getCollection, render } from 'astro:content'; import { getCollection, render } from 'astro:content';
import { isAdmin } from '../../lib/auth'; import { isAdmin } from '../../lib/auth';
import Layout from '../../layouts/Layout.astro'; import Layout from '../../layouts/Layout.astro';
import { formatDate } from '../../lib/format';
import { getSlug } from '../../utils';
let session; let session;
try { try {
@ -22,21 +24,14 @@ if (!isAdmin(session.user?.id)) {
} }
const slug = Astro.params.slug; const slug = Astro.params.slug;
const posts = await getCollection('posts', ({ data }) => data.draft === true); const posts = await getCollection('md', ({ data }) => data.draft === true);
const post = posts.find(p => p.id === slug); const post = posts.find(p => getSlug(p.id) === slug);
if (!post) { if (!post) {
return new Response('Not found', { status: 404 }); return new Response('Not found', { status: 404 });
} }
const { Content } = await render(post); 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.`}> <Layout title={`${post.data.title} - lewis m.w.`}>

View file

@ -5,6 +5,9 @@ import { getSession } from 'auth-astro/server';
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
import { isAdmin } from '../../lib/auth'; import { isAdmin } from '../../lib/auth';
import Layout from '../../layouts/Layout.astro'; import Layout from '../../layouts/Layout.astro';
import { formatDate } from '../../lib/format';
import { organizePostsByCategory } from '../../lib/posts';
import { getSlug } from '../../utils';
let session; let session;
try { try {
@ -21,38 +24,8 @@ if (!isAdmin(session.user?.id)) {
return new Response('Forbidden', { status: 403 }); return new Response('Forbidden', { status: 403 });
} }
const posts = await getCollection('posts', ({ data }) => data.draft === true); const posts = await getCollection('md', ({ data }) => data.draft === true);
const { grouped, categories: sortedCategories } = organizePostsByCategory(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="drafts - lewis m.w."> <Layout title="drafts - lewis m.w.">
@ -64,7 +37,7 @@ function formatDate(date: Date): string {
sortedCategories.map(category => ( sortedCategories.map(category => (
<details open> <details open>
<summary>{category}</summary> <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')} /> <pre set:html={grouped[category].map(post => `<span class="muted">${formatDate(post.data.date)}</span> <a href="/draft/${getSlug(post.id)}">${post.data.title}</a>${post.data.pinned ? ' [pinned]' : ''}`).join('\n')} />
</details> </details>
)) ))
)} )}

View file

@ -1,34 +1,19 @@
import rss from '@astrojs/rss'; import rss from '@astrojs/rss';
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
import fs from 'node:fs';
import path from 'node:path';
import type { APIContext } from 'astro'; import type { APIContext } from 'astro';
import { getGitDate } from '../utils'; import { getTxtFiles } from '../lib/txt';
import { getSlug } from '../utils';
interface TxtFile {
name: string;
date: Date;
}
export async function GET(context: APIContext) { export async function GET(context: APIContext) {
const posts = await getCollection('posts', ({ data }) => data.draft !== true); const posts = await getCollection('md', ({ data }) => data.draft !== true);
const bookmarks = await getCollection('bookmarks'); const bookmarks = await getCollection('bookmarks');
const txtFiles = getTxtFiles();
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 = [ const items = [
...posts.map(post => ({ ...posts.map(post => ({
title: post.data.title, title: post.data.title,
pubDate: post.data.date, pubDate: post.data.date,
link: `/md/${post.id}`, link: `/md/${getSlug(post.id)}`,
description: post.data.title, description: post.data.title,
})), })),
...txtFiles.map(txt => ({ ...txtFiles.map(txt => ({

View file

@ -1,6 +1,7 @@
--- ---
import Layout from '../../layouts/Layout.astro'; import Layout from '../../layouts/Layout.astro';
import { getApprovedEntries, type GuestbookEntry } from '../../lib/db'; import { getApprovedEntries, type GuestbookEntry } from '../../lib/db';
import { formatDate } from '../../lib/format';
let guestbookEntries: GuestbookEntry[] = []; let guestbookEntries: GuestbookEntry[] = [];
try { try {
@ -8,13 +9,6 @@ try {
} catch { } catch {
// DB not available during dev without env vars // 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."> <Layout title="guestbook - lewis m.w.">

View file

@ -1,73 +1,20 @@
--- ---
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
import Layout from '../layouts/Layout.astro'; 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 { getApprovedEntries, type GuestbookEntry } from '../lib/db';
import { getGitDate } from '../utils'; import { formatDate, extractDomain } from '../lib/format';
import { organizePostsByCategory } from '../lib/posts';
import { getTxtFiles } from '../lib/txt';
import { getSlug } from '../utils';
interface TxtFile { const posts = await getCollection('md', ({ data }) => data.draft !== true);
name: string; const { grouped, categories: sortedCategories } = organizePostsByCategory(posts);
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 bookmarksCollection = await getCollection('bookmarks');
const bookmarks = bookmarksCollection const bookmarks = bookmarksCollection
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime()); .sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
// Auto-discover txt files from public/txt/ const txtFiles = getTxtFiles();
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[] = []; let guestbookEntries: GuestbookEntry[] = [];
try { try {
@ -75,22 +22,6 @@ try {
} catch { } catch {
// DB not available during dev without env vars // 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> <Layout title="lewis m.w." isHome>
@ -100,7 +31,7 @@ function extractDomain(url: string): string {
<details open> <details open>
<summary>{category}</summary> <summary>{category}</summary>
<pre set:html={[ <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.slice(0, 10).map(post => `<span class="muted">${formatDate(post.data.date)}</span> <a href="/md/${getSlug(post.id)}">${post.data.title}</a>${post.data.pinned ? ' [pinned]' : ''}`),
...(categoryPosts.length > 10 ? [`<a href="/md/">+${categoryPosts.length - 10} more</a>`] : []) ...(categoryPosts.length > 10 ? [`<a href="/md/">+${categoryPosts.length - 10} more</a>`] : [])
].join('\n')} /> ].join('\n')} />
</details> </details>

View file

@ -1,24 +1,19 @@
--- ---
import { getCollection, render } from 'astro:content'; import { getCollection, render } from 'astro:content';
import Layout from '../../layouts/Layout.astro'; import Layout from '../../layouts/Layout.astro';
import { formatDate } from '../../lib/format';
import { getSlug } from '../../utils';
export async function getStaticPaths() { export async function getStaticPaths() {
const posts = await getCollection('posts', ({ data }) => data.draft !== true); const posts = await getCollection('md', ({ data }) => data.draft !== true);
return posts.map(post => ({ return posts.map(post => ({
params: { slug: post.data.slug }, params: { slug: getSlug(post.id) },
props: { post } props: { post }
})); }));
} }
const { post } = Astro.props; const { post } = Astro.props;
const { Content } = await render(post); 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.`}> <Layout title={`${post.data.title} - lewis m.w.`}>

View file

@ -1,46 +1,19 @@
--- ---
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
import Layout from '../../layouts/Layout.astro'; import Layout from '../../layouts/Layout.astro';
import { formatDate } from '../../lib/format';
import { organizePostsByCategory } from '../../lib/posts';
import { getSlug } from '../../utils';
const posts = await getCollection('posts', ({ data }) => data.draft !== true); const posts = await getCollection('md', ({ data }) => data.draft !== true);
const { grouped, categories: sortedCategories } = organizePostsByCategory(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."> <Layout title="md - lewis m.w.">
{sortedCategories.map(category => ( {sortedCategories.map(category => (
<details open> <details open>
<summary>{category}</summary> <summary>{category}</summary>
<pre set:html={grouped[category].map(post => `<span class="muted">${formatDate(post.data.date)}</span> <a href="/md/${post.data.slug}">${post.data.title}</a>${post.data.pinned ? ' [pinned]' : ''}`).join('\n')} /> <pre set:html={grouped[category].map(post => `<span class="muted">${formatDate(post.data.date)}</span> <a href="/md/${getSlug(post.id)}">${post.data.title}</a>${post.data.pinned ? ' [pinned]' : ''}`).join('\n')} />
</details> </details>
))} ))}
</Layout> </Layout>

View file

@ -1,22 +1,18 @@
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
import fs from 'node:fs';
import path from 'node:path';
import type { APIContext } from 'astro'; import type { APIContext } from 'astro';
import { getTxtFileNames } from '../lib/txt';
import { getSlug } from '../utils';
export const prerender = false; export const prerender = false;
export async function GET(context: APIContext) { export async function GET(context: APIContext) {
const site = context.site?.origin ?? 'https://wynne.rs'; const site = context.site?.origin ?? 'https://wynne.rs';
const posts = await getCollection('posts', ({ data }) => data.draft !== true); const posts = await getCollection('md', ({ data }) => data.draft !== true);
const bookmarks = await getCollection('bookmarks'); const bookmarks = await getCollection('bookmarks');
const txtFiles = getTxtFileNames();
const txtDir = path.join(process.cwd(), 'public/txt');
const txtFiles = fs.existsSync(txtDir)
? fs.readdirSync(txtDir).filter(file => file.endsWith('.txt'))
: [];
const urls = [ const urls = [
...posts.map(post => `/md/${post.id}`), ...posts.map(post => `/md/${getSlug(post.id)}`),
...txtFiles.map(txt => `/txt/${txt}`), ...txtFiles.map(txt => `/txt/${txt}`),
...bookmarks.map(b => b.data.url), ...bookmarks.map(b => b.data.url),
]; ];

View file

@ -1,7 +1,7 @@
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
import fs from 'node:fs';
import path from 'node:path';
import type { APIContext } from 'astro'; import type { APIContext } from 'astro';
import { getTxtFileNames } from '../lib/txt';
import { getSlug } from '../utils';
const SUBDOMAINS = [ const SUBDOMAINS = [
'https://penfield.wynne.rs/', 'https://penfield.wynne.rs/',
@ -9,17 +9,13 @@ const SUBDOMAINS = [
export async function GET(context: APIContext) { export async function GET(context: APIContext) {
const site = context.site?.origin ?? 'https://wynne.rs'; const site = context.site?.origin ?? 'https://wynne.rs';
const posts = await getCollection('posts', ({ data }) => data.draft !== true); const posts = await getCollection('md', ({ data }) => data.draft !== true);
const txtFiles = getTxtFileNames();
const txtDir = path.join(process.cwd(), 'public/txt');
const txtFiles = fs.existsSync(txtDir)
? fs.readdirSync(txtDir).filter(file => file.endsWith('.txt'))
: [];
const urls = [ const urls = [
'/', '/',
'/md', '/md',
...posts.map(post => `/md/${post.id}`), ...posts.map(post => `/md/${getSlug(post.id)}`),
'/txt', '/txt',
...txtFiles.map(txt => `/txt/${txt}`), ...txtFiles.map(txt => `/txt/${txt}`),
'/bookmarks', '/bookmarks',

View file

@ -1,47 +1,9 @@
--- ---
import Layout from '../../layouts/Layout.astro'; import Layout from '../../layouts/Layout.astro';
import fs from 'node:fs'; import { formatDate } from '../../lib/format';
import path from 'node:path'; import { getTxtFiles } from '../../lib/txt';
import yaml from 'js-yaml';
import { getGitDate } from '../../utils';
interface TxtFile { const txtFiles = getTxtFiles();
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."> <Layout title="txt - lewis m.w.">

View file

@ -1,5 +1,10 @@
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
export function getSlug(postId: string): string {
const parts = postId.split('/');
return parts[parts.length - 1];
}
export function getGitDate(filePath: string): Date { export function getGitDate(filePath: string): Date {
try { try {
const timestamp = execSync(`git log -1 --format=%cI -- "${filePath}"`, { encoding: 'utf8' }).trim(); const timestamp = execSync(`git log -1 --format=%cI -- "${filePath}"`, { encoding: 'utf8' }).trim();