flatten monorepo, migrate off Vercel
- Remove penfield (split to own repo on Forgejo) - Move www/ contents to root, rename to wynne.rs - Swap @astrojs/vercel for @astrojs/node, upgrade to Astro 6 - Remove auth-astro/GitHub OAuth (TinyAuth at proxy layer) - Remove Vercel deploy webhook - Switch to local SQLite DB (drop Turso) - Update generate-stats.js for new build output paths
This commit is contained in:
parent
f2acf36784
commit
8a9c56c3d5
52 changed files with 45 additions and 7135 deletions
31
src/content.config.ts
Normal file
31
src/content.config.ts
Normal 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: './content' }),
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
date: z.coerce.date(),
|
||||
updated: z.coerce.date().optional(),
|
||||
category: z.string().optional(),
|
||||
related: z.array(z.string()).optional(),
|
||||
})
|
||||
});
|
||||
|
||||
const bookmarks = defineCollection({
|
||||
loader: file('./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
src/env.d.ts
vendored
Normal file
10
src/env.d.ts
vendored
Normal 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;
|
||||
}
|
||||
47
src/layouts/Layout.astro
Normal file
47
src/layouts/Layout.astro
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
import '../styles/global.css';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
showHeader?: boolean;
|
||||
isHome?: boolean;
|
||||
urls?: string[];
|
||||
}
|
||||
|
||||
const { title, description = 'personal website of ' + title, showHeader = true, isHome = false, urls = [] } = Astro.props;
|
||||
---
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:type" content="website" />
|
||||
<link rel="alternate" type="application/rss+xml" title="wynne.rs" href="/feed.xml" />
|
||||
{urls.length > 0 && <script is:inline define:vars={{ urls }}>window.__urls = urls;</script>}
|
||||
<script is:inline src="/js/params.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
{showHeader && (
|
||||
<header>
|
||||
<span class="header-name">
|
||||
<a href="/">{isHome ? title : 'lewis m.w.'}</a>
|
||||
</span>
|
||||
<span class="header-links">
|
||||
<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="/?do=random">random</a>
|
||||
<a href="/?do=newest">newest</a>
|
||||
<a id="find" href="/?has=">find</a>
|
||||
</span>
|
||||
</header>
|
||||
)}
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
10
src/lib/api.ts
Normal file
10
src/lib/api.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
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);
|
||||
}
|
||||
12
src/lib/consts.ts
Normal file
12
src/lib/consts.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export const DEFAULT_CATEGORY = 'none';
|
||||
|
||||
export const SUBDOMAINS = [
|
||||
'https://c.wynne.rs/',
|
||||
'https://penfield.wynne.rs/',
|
||||
];
|
||||
|
||||
export const SECTIONS = {
|
||||
files: 'files',
|
||||
bookmarks: 'bookmarks',
|
||||
guestbook: 'guestbook',
|
||||
} as const;
|
||||
29
src/lib/db.ts
Normal file
29
src/lib/db.ts
Normal 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));
|
||||
}
|
||||
70
src/lib/format.ts
Normal file
70
src/lib/format.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
export function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export function excerpt(markdown: string | undefined, maxLen = 160): string {
|
||||
if (!markdown) return '';
|
||||
return markdown
|
||||
.replace(/^#+\s+.*$/gm, '')
|
||||
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
.replace(/[*_~`]/g, '')
|
||||
.replace(/:[a-z]+\[([^\]]*)\]/g, '$1')
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, maxLen);
|
||||
}
|
||||
|
||||
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 wordCount(markdown: string | undefined): string {
|
||||
if (!markdown) return '';
|
||||
const words = markdown
|
||||
.replace(/^---[\s\S]*?---/m, '')
|
||||
.replace(/^#+\s+.*$/gm, '')
|
||||
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
.replace(/[*_~`]/g, '')
|
||||
.replace(/:[a-z]+\[([^\]]*)\]/g, '$1')
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean).length;
|
||||
if (words < 100) return `${words} words`;
|
||||
const mins = Math.ceil(words / 200);
|
||||
return `${mins} min`;
|
||||
}
|
||||
|
||||
export function extractDomain(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function formatListItem(
|
||||
date: Date,
|
||||
url: string,
|
||||
title: string,
|
||||
options?: { suffix?: string }
|
||||
): string {
|
||||
const suffixHtml = options?.suffix ? `<span class="entry-suffix muted">${options.suffix}</span>` : '';
|
||||
return `<span class="list-meta"><span class="muted">${formatDate(date)}</span></span><span class="entry-content"><a href="${url}" title="${title}">${title}</a>${suffixHtml}</span>`;
|
||||
}
|
||||
|
||||
interface Sortable {
|
||||
date: Date;
|
||||
}
|
||||
|
||||
export function sortEntries<T>(items: T[], key?: (item: T) => Sortable): T[] {
|
||||
const get = key ?? (item => item as unknown as Sortable);
|
||||
return items.slice().sort((a, b) => get(b).date.getTime() - get(a).date.getTime());
|
||||
}
|
||||
42
src/lib/posts.ts
Normal file
42
src/lib/posts.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import type { CollectionEntry } from 'astro:content';
|
||||
import { DEFAULT_CATEGORY } from './consts';
|
||||
import { sortEntries } from './format';
|
||||
|
||||
export type Post = CollectionEntry<'posts'> & { body?: string };
|
||||
|
||||
export function getSlug(postId: string): string {
|
||||
const parts = postId.split('/');
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
|
||||
export function resolveRelatedPosts<T extends { id: string }>(
|
||||
slugs: string[],
|
||||
allPosts: T[],
|
||||
): T[] {
|
||||
const bySlug = new Map(allPosts.map(p => [getSlug(p.id), p]));
|
||||
return slugs.flatMap(s => bySlug.get(s) ?? []);
|
||||
}
|
||||
|
||||
export function organizePostsByCategory(posts: Post[]): {
|
||||
grouped: Record<string, Post[]>;
|
||||
categories: string[];
|
||||
} {
|
||||
const grouped = posts.reduce((acc, post) => {
|
||||
const category = post.data.category ?? DEFAULT_CATEGORY;
|
||||
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 === DEFAULT_CATEGORY) return -1;
|
||||
if (b === DEFAULT_CATEGORY) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
for (const category of categories) {
|
||||
grouped[category] = sortEntries(grouped[category], p => p.data);
|
||||
}
|
||||
|
||||
return { grouped, categories };
|
||||
}
|
||||
15
src/lib/rate-limit.ts
Normal file
15
src/lib/rate-limit.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
const requests = new Map<string, number[]>();
|
||||
|
||||
export function isRateLimited(key: string, maxRequests: number, windowMs: number): boolean {
|
||||
const now = Date.now();
|
||||
const timestamps = requests.get(key) ?? [];
|
||||
const recent = timestamps.filter(t => now - t < windowMs);
|
||||
|
||||
if (recent.length >= maxRequests) {
|
||||
return true;
|
||||
}
|
||||
|
||||
recent.push(now);
|
||||
requests.set(key, recent);
|
||||
return false;
|
||||
}
|
||||
45
src/lib/txt.ts
Normal file
45
src/lib/txt.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import yaml from 'js-yaml';
|
||||
import { sortEntries } from './format';
|
||||
|
||||
export interface TxtFile {
|
||||
name: string;
|
||||
date: Date;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface TxtConfig {
|
||||
descriptions?: Record<string, string>;
|
||||
dates?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function getTxtDir(): string {
|
||||
return path.join(process.cwd(), 'public');
|
||||
}
|
||||
|
||||
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 descriptions = config.descriptions || {};
|
||||
const dates = config.dates || {};
|
||||
|
||||
const files = fs.readdirSync(txtDir)
|
||||
.filter(file => file.endsWith('.txt'))
|
||||
.map(name => ({
|
||||
name,
|
||||
date: dates[name] ? new Date(dates[name]) : new Date(0),
|
||||
description: descriptions[name],
|
||||
}));
|
||||
return sortEntries(files);
|
||||
}
|
||||
|
||||
6
src/pages/404.astro
Normal file
6
src/pages/404.astro
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
---
|
||||
<Layout title="404 - lewis m.w.">
|
||||
<p>not found</p>
|
||||
</Layout>
|
||||
33
src/pages/[slug].astro
Normal file
33
src/pages/[slug].astro
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
---
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { formatDate, formatListItem, excerpt, wordCount } from '../lib/format';
|
||||
import { getSlug, resolveRelatedPosts, type Post } from '../lib/posts';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const allPosts = await getCollection('posts');
|
||||
return allPosts.map(post => ({
|
||||
params: { slug: getSlug(post.id) },
|
||||
props: { post, allPosts }
|
||||
}));
|
||||
}
|
||||
|
||||
const { post, allPosts } = Astro.props;
|
||||
const { Content } = await render(post);
|
||||
const related = post.data.related ? resolveRelatedPosts(post.data.related, allPosts) : [];
|
||||
const description = excerpt((post as Post).body) || undefined;
|
||||
---
|
||||
<Layout title={`${post.data.title} - lewis m.w.`} description={description}>
|
||||
|
||||
<article>
|
||||
<h1>{post.data.title}</h1>
|
||||
<p class="muted" style="margin-top: 0;">{formatDate(post.data.date)}{post.data.updated && ` (updated ${formatDate(post.data.updated)})`} · {wordCount((post as Post).body)}{post.data.category && ` · ${post.data.category}`}</p>
|
||||
<Content />
|
||||
</article>
|
||||
{related.length > 0 && (
|
||||
<section>
|
||||
<span class="section-label">related</span>
|
||||
<pre set:html={related.map(p => formatListItem(p.data.date, `/${getSlug(p.id)}`, p.data.title)).join('\n')} />
|
||||
</section>
|
||||
)}
|
||||
</Layout>
|
||||
51
src/pages/admin.astro
Normal file
51
src/pages/admin.astro
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
---
|
||||
export const prerender = false;
|
||||
|
||||
import { getPendingEntries, type GuestbookEntry } from '../lib/db';
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { formatDate } from '../lib/format';
|
||||
|
||||
let entries: GuestbookEntry[] = [];
|
||||
try {
|
||||
entries = await getPendingEntries();
|
||||
} catch {
|
||||
// handle error
|
||||
}
|
||||
---
|
||||
<Layout title="admin - guestbook" showHeader={false}>
|
||||
|
||||
<h1>guestbook admin</h1>
|
||||
|
||||
{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.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>
|
||||
28
src/pages/api/guestbook.ts
Normal file
28
src/pages/api/guestbook.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
import { createEntry } from '../../lib/db';
|
||||
import { jsonResponse, errorResponse } from '../../lib/api';
|
||||
import { isRateLimited } from '../../lib/rate-limit';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
|
||||
if (isRateLimited(ip, 3, 60_000)) {
|
||||
return errorResponse('Too many requests, try again later', 429);
|
||||
}
|
||||
|
||||
const data = await request.json();
|
||||
const { name, message, url } = data;
|
||||
|
||||
if (!name || !message) {
|
||||
return errorResponse('Name and message are required', 400);
|
||||
}
|
||||
|
||||
await createEntry(name.slice(0, 100), message.slice(0, 500), url?.slice(0, 200) || null);
|
||||
|
||||
return jsonResponse({ success: true }, 201);
|
||||
} catch {
|
||||
return errorResponse('Failed to create entry', 500);
|
||||
}
|
||||
};
|
||||
21
src/pages/api/guestbook/[id].ts
Normal file
21
src/pages/api/guestbook/[id].ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
import { approveEntry, deleteEntry } from '../../../lib/db';
|
||||
import { jsonResponse, errorResponse } from '../../../lib/api';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const PATCH: APIRoute = async ({ params }) => {
|
||||
const id = parseInt(params.id!, 10);
|
||||
if (isNaN(id)) return errorResponse('Invalid ID', 400);
|
||||
|
||||
await approveEntry(id);
|
||||
return jsonResponse({ success: true });
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ params }) => {
|
||||
const id = parseInt(params.id!, 10);
|
||||
if (isNaN(id)) return errorResponse('Invalid ID', 400);
|
||||
|
||||
await deleteEntry(id);
|
||||
return jsonResponse({ success: true });
|
||||
};
|
||||
33
src/pages/feed.xml.ts
Normal file
33
src/pages/feed.xml.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import rss from '@astrojs/rss';
|
||||
import { getCollection } from 'astro:content';
|
||||
import type { APIContext } from 'astro';
|
||||
import { getSlug, type Post } from '../lib/posts';
|
||||
import { getTxtFiles } from '../lib/txt';
|
||||
import { excerpt } from '../lib/format';
|
||||
|
||||
export async function GET(context: APIContext) {
|
||||
const posts = await getCollection('posts');
|
||||
const txtFiles = getTxtFiles();
|
||||
|
||||
const items = [
|
||||
...posts.map(post => ({
|
||||
title: post.data.title,
|
||||
pubDate: post.data.date,
|
||||
link: `/${getSlug(post.id)}`,
|
||||
description: excerpt((post as Post).body) || post.data.title,
|
||||
})),
|
||||
...txtFiles.map(txt => ({
|
||||
title: txt.name,
|
||||
pubDate: txt.date,
|
||||
link: `/${txt.name}`,
|
||||
description: txt.description || txt.name,
|
||||
})),
|
||||
].sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime());
|
||||
|
||||
return rss({
|
||||
title: 'wynne.rs',
|
||||
description: 'personal website of lewis m.w.',
|
||||
site: context.site ?? 'https://wynne.rs',
|
||||
items,
|
||||
});
|
||||
}
|
||||
17
src/pages/guestbook-count.json.ts
Normal file
17
src/pages/guestbook-count.json.ts
Normal 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' },
|
||||
});
|
||||
};
|
||||
86
src/pages/index.astro
Normal file
86
src/pages/index.astro
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { getApprovedEntries, type GuestbookEntry } from '../lib/db';
|
||||
import { formatDate, formatListItem, extractDomain, wordCount, escapeHtml } from '../lib/format';
|
||||
import { organizePostsByCategory, getSlug } from '../lib/posts';
|
||||
import { getTxtFiles } from '../lib/txt';
|
||||
import { DEFAULT_CATEGORY, SECTIONS, SUBDOMAINS } from '../lib/consts';
|
||||
|
||||
const posts = await getCollection('posts');
|
||||
const { grouped, categories: sortedCategories } = organizePostsByCategory(posts);
|
||||
|
||||
const bookmarksCollection = await getCollection('bookmarks');
|
||||
const bookmarks = bookmarksCollection
|
||||
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
|
||||
|
||||
const txtFiles = getTxtFiles();
|
||||
|
||||
let guestbookEntries: GuestbookEntry[] = [];
|
||||
try {
|
||||
guestbookEntries = await getApprovedEntries();
|
||||
} catch {
|
||||
// DB not available during dev without env vars
|
||||
}
|
||||
|
||||
const urls = [
|
||||
...posts.map(post => ({ url: `/${getSlug(post.id)}`, date: post.data.date.getTime() })),
|
||||
...txtFiles.map(f => ({ url: `/${f.name}`, date: f.date.getTime() })),
|
||||
].sort((a, b) => b.date - a.date).map(e => e.url).concat(SUBDOMAINS);
|
||||
---
|
||||
<Layout title="lewis m.w." isHome urls={urls}>
|
||||
|
||||
{sortedCategories.map(category => {
|
||||
const categoryPosts = grouped[category];
|
||||
const isDefault = category === DEFAULT_CATEGORY;
|
||||
return (
|
||||
<section data-section={category}>
|
||||
{!isDefault && <a class="section-label" href={`?just=${category}`}>{category}</a>}
|
||||
<div class="entry-list" set:html={categoryPosts.map(post =>
|
||||
`<span class="entry">${formatListItem(post.data.date, `/${getSlug(post.id)}`, post.data.title, { suffix: wordCount(post.body) })}</span>`
|
||||
).join('')} />
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
<section data-section={SECTIONS.files}>
|
||||
<a class="section-label" href={`?just=${SECTIONS.files}`}>{SECTIONS.files}</a>
|
||||
<div class="entry-list" set:html={txtFiles.map(f => {
|
||||
const name = f.name.replace(/\.txt$/, '');
|
||||
return `<span class="entry">${formatListItem(f.date, `/${f.name}`, name, { suffix: f.description })}</span>`;
|
||||
}).join('')} />
|
||||
</section>
|
||||
|
||||
<section data-section={SECTIONS.bookmarks}>
|
||||
<a class="section-label" href={`?just=${SECTIONS.bookmarks}`}>{SECTIONS.bookmarks}</a>
|
||||
<div class="entry-list" set:html={bookmarks.map(b =>
|
||||
`<span class="entry">${formatListItem(b.data.date, b.data.url, b.data.title, { suffix: extractDomain(b.data.url) })}</span>`
|
||||
).join('')} />
|
||||
</section>
|
||||
|
||||
<section data-section={SECTIONS.guestbook}>
|
||||
<a class="section-label" href={`?just=${SECTIONS.guestbook}`}>{SECTIONS.guestbook}</a>
|
||||
<div class="guestbook-entries" set:html={guestbookEntries.map(e => {
|
||||
const safeName = escapeHtml(e.name);
|
||||
const safeMessage = escapeHtml(e.message.replace(/\n/g, ' '));
|
||||
const nameHtml = e.url ? `<a href="${escapeHtml(e.url)}"><b>${safeName}</b></a>` : `<b>${safeName}</b>`;
|
||||
return `<span class="guestbook-entry"><span class="list-meta"><span class="muted">${formatDate(e.createdAt)}</span> </span><span>${nameHtml} ${safeMessage}</span></span>`;
|
||||
}).join('')} /></div>
|
||||
<form id="guestbook-form" class="guestbook-form">
|
||||
<label class="sr-only" for="gb-name">name</label>
|
||||
<input id="gb-name" type="text" name="name" placeholder="name" required maxlength="100" /><br />
|
||||
<label class="sr-only" for="gb-message">message</label>
|
||||
<input id="gb-message" type="text" name="message" placeholder="message" required maxlength="500" /><br />
|
||||
<label class="sr-only" for="gb-url">url</label>
|
||||
<input id="gb-url" type="url" name="url" placeholder="url (optional)" maxlength="200" /><br />
|
||||
<button type="submit">sign</button>
|
||||
<span id="guestbook-status"></span>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
import { initGuestbookForm } from '../scripts/guestbook-sign';
|
||||
initGuestbookForm();
|
||||
|
||||
</script>
|
||||
</Layout>
|
||||
21
src/pages/sitemap.txt.ts
Normal file
21
src/pages/sitemap.txt.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { getCollection } from 'astro:content';
|
||||
import type { APIContext } from 'astro';
|
||||
import { getSlug } from '../lib/posts';
|
||||
import { getTxtFiles } from '../lib/txt';
|
||||
import { SUBDOMAINS } from '../lib/consts';
|
||||
|
||||
export async function GET(context: APIContext) {
|
||||
const site = context.site?.origin ?? 'https://wynne.rs';
|
||||
const posts = await getCollection('posts');
|
||||
const txtFiles = getTxtFiles().map(f => f.name);
|
||||
|
||||
const urls = [
|
||||
'/',
|
||||
...posts.map(post => `/${getSlug(post.id)}`),
|
||||
...txtFiles.map(txt => `/${txt}`),
|
||||
].map(p => `${site}${p}`);
|
||||
|
||||
return new Response([...urls, ...SUBDOMAINS].join('\n'), {
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
}
|
||||
49
src/plugins/remark-aside.ts
Normal file
49
src/plugins/remark-aside.ts
Normal 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;
|
||||
});
|
||||
};
|
||||
}
|
||||
43
src/scripts/guestbook-sign.ts
Normal file
43
src/scripts/guestbook-sign.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
export function initGuestbookForm() {
|
||||
const form = document.getElementById('guestbook-form') as HTMLFormElement | null;
|
||||
if (!form) return;
|
||||
|
||||
const status = document.getElementById('guestbook-status')!;
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
status.textContent = '';
|
||||
|
||||
const data = new FormData(form);
|
||||
const name = (data.get('name') as string).trim();
|
||||
const message = (data.get('message') as string).trim();
|
||||
const url = (data.get('url') as string).trim() || null;
|
||||
|
||||
if (!name || !message) return;
|
||||
|
||||
const button = form.querySelector('button')!;
|
||||
button.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/guestbook', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, message, url }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
status.textContent = ' thanks! pending approval.';
|
||||
form.reset();
|
||||
} else if (res.status === 429) {
|
||||
status.textContent = ' too many requests, try later.';
|
||||
} else {
|
||||
const body = await res.json().catch(() => null);
|
||||
status.textContent = body?.error ? ` ${body.error}` : ' error';
|
||||
}
|
||||
} catch {
|
||||
status.textContent = ' failed';
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
182
src/styles/global.css
Normal file
182
src/styles/global.css
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
body {
|
||||
box-sizing: border-box;
|
||||
max-width: 34rem;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
text-align: justify;
|
||||
font-family: 'Times New Roman', serif;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.left, .right {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (min-width: 58rem) {
|
||||
.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;
|
||||
}
|
||||
|
||||
section {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
|
||||
html[data-has] .guestbook-form {
|
||||
display: none;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.header-name {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-links {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
section pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.entry-list {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: grid;
|
||||
grid-template-columns: 4rem 1fr;
|
||||
align-items: baseline;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.entry-content {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entry-content > a {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.entry-suffix {
|
||||
flex: 1 10000 0%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: right;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.guestbook-entries {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.guestbook-entry {
|
||||
display: grid;
|
||||
grid-template-columns: 4rem 1fr;
|
||||
align-items: baseline;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.guestbook-form {
|
||||
margin-top: 0.5rem;
|
||||
margin-left: 4rem;
|
||||
}
|
||||
|
||||
html[data-compact] .list-meta {
|
||||
display: none;
|
||||
}
|
||||
html[data-compact] .entry {
|
||||
display: block;
|
||||
}
|
||||
html[data-compact] .entry-list {
|
||||
columns: 1;
|
||||
}
|
||||
html[data-compact] .guestbook-entry {
|
||||
display: block;
|
||||
}
|
||||
html[data-compact] .guestbook-form {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue