Compare commits
10 commits
8209d036cd
...
f2acf36784
| Author | SHA1 | Date | |
|---|---|---|---|
| f2acf36784 | |||
| 66360b9c7a | |||
| 30212a2eaf | |||
| 917ef06879 | |||
| 3809e7c9dd | |||
| e4052fc145 | |||
| 20811f107b | |||
| c647fd62c3 | |||
| 5cc122bf39 | |||
| 384ca71f89 |
24 changed files with 171 additions and 215 deletions
25
.github/workflows/validate.yml
vendored
25
.github/workflows/validate.yml
vendored
|
|
@ -1,25 +0,0 @@
|
||||||
name: Validate content
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- 'www/content/**'
|
|
||||||
- 'www/public/*.txt'
|
|
||||||
- 'www/public/config.yaml'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- 'www/content/**'
|
|
||||||
- 'www/public/*.txt'
|
|
||||||
- 'www/public/config.yaml'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
validate:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
cache: pnpm
|
|
||||||
- run: pnpm install --frozen-lockfile
|
|
||||||
- run: pnpm validate:www
|
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
"dev:penfield": "pnpm --filter @ily/penfield dev",
|
"dev:penfield": "pnpm --filter @ily/penfield dev",
|
||||||
"dev:www": "pnpm --filter @ily/www dev",
|
"dev:www": "pnpm --filter @ily/www dev",
|
||||||
"build:penfield": "pnpm --filter @ily/penfield build",
|
"build:penfield": "pnpm --filter @ily/penfield build",
|
||||||
"build:www": "pnpm --filter @ily/www build",
|
"build:www": "pnpm --filter @ily/www build"
|
||||||
"validate:www": "pnpm --filter @ily/www validate"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
---
|
---
|
||||||
title: hello
|
title: hello
|
||||||
date: 2023-02-26
|
date: 2026-02-26
|
||||||
pinned: true
|
|
||||||
---
|
---
|
||||||
|
|
||||||
i've always had some sort of homepage. it was originally on bebo, then that died and i didn't ever get into other social media, so i made a website
|
i've always had some sort of homepage. it was originally on bebo, then that died and i didn't ever get into other social media, so i made a website
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,7 @@
|
||||||
"dev": "astro dev --port 4322",
|
"dev": "astro dev --port 4322",
|
||||||
"build": "astro build --remote && node scripts/generate-stats.js",
|
"build": "astro build --remote && node scripts/generate-stats.js",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"serve": "pnpm build && npx serve .vercel/output/static -l 4322",
|
"serve": "pnpm build && npx serve .vercel/output/static -l 4322"
|
||||||
"validate": "node scripts/validate-content.js"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/db": "^0.19.0",
|
"@astrojs/db": "^0.19.0",
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
2026-03-27 - narrower layout (34rem), single-column entries, serif font, smaller muted text
|
||||||
2026-03-26 - inline section labels, compact layout
|
2026-03-26 - inline section labels, compact layout
|
||||||
2026-02-07 - related posts !
|
2026-02-07 - related posts !
|
||||||
2026-01-31 - text files now live at cleaner URLs (/*.txt instead of /txt/*.txt)
|
2026-01-31 - text files now live at cleaner URLs (/*.txt instead of /txt/*.txt)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
pinned: []
|
|
||||||
descriptions:
|
descriptions:
|
||||||
cv.txt: curriculum vitae
|
cv.txt: curriculum vitae
|
||||||
now.txt: what i'm doing now
|
now.txt: what i'm doing now
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,7 @@
|
||||||
var just = p.get('just');
|
var just = p.get('just');
|
||||||
if (just && /^[a-z0-9-]+$/.test(just)) {
|
if (just && /^[a-z0-9-]+$/.test(just)) {
|
||||||
document.documentElement.dataset.just = just;
|
document.documentElement.dataset.just = just;
|
||||||
var css = 'section[data-section]:not([data-section="' + just + '"]){display:none}'
|
var css = 'section[data-section]:not([data-section="' + just + '"]){display:none}';
|
||||||
+ ' section[data-section="' + just + '"] .section-label{pointer-events:none;text-decoration:none;color:inherit}';
|
|
||||||
document.head.appendChild(Object.assign(document.createElement('style'), { textContent: css }));
|
document.head.appendChild(Object.assign(document.createElement('style'), { textContent: css }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,7 +29,10 @@
|
||||||
if (has) {
|
if (has) {
|
||||||
document.documentElement.dataset.has = has;
|
document.documentElement.dataset.has = has;
|
||||||
has = has.toLowerCase();
|
has = has.toLowerCase();
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
if (has) {
|
||||||
document.querySelectorAll('section[data-section] .entry').forEach(function(entry) {
|
document.querySelectorAll('section[data-section] .entry').forEach(function(entry) {
|
||||||
if (entry.textContent.toLowerCase().indexOf(has) === -1) {
|
if (entry.textContent.toLowerCase().indexOf(has) === -1) {
|
||||||
entry.style.display = 'none';
|
entry.style.display = 'none';
|
||||||
|
|
@ -41,6 +43,22 @@
|
||||||
entry.style.display = 'none';
|
entry.style.display = 'none';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.section-label').forEach(function(a) {
|
||||||
|
var link = new URLSearchParams(a.search);
|
||||||
|
p.forEach(function(v, k) { if (!link.has(k)) link.set(k, v); });
|
||||||
|
a.href = '?' + link.toString();
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
var find = document.getElementById('find');
|
||||||
|
if (find) find.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var term = prompt('find:');
|
||||||
|
if (!term) return;
|
||||||
|
var q = new URLSearchParams(location.search);
|
||||||
|
q.set('has', term);
|
||||||
|
location.search = q.toString();
|
||||||
|
});
|
||||||
|
});
|
||||||
}();
|
}();
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import yaml from 'js-yaml';
|
|
||||||
|
|
||||||
const root = path.resolve(import.meta.dirname, '..');
|
|
||||||
const contentDir = path.join(root, 'content');
|
|
||||||
const publicDir = path.join(root, 'public');
|
|
||||||
const errors = [];
|
|
||||||
|
|
||||||
const mdFiles = fs.readdirSync(contentDir).filter(f => f.endsWith('.md'));
|
|
||||||
for (const file of mdFiles) {
|
|
||||||
const content = fs.readFileSync(path.join(contentDir, file), 'utf8');
|
|
||||||
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
||||||
if (!match) {
|
|
||||||
errors.push(`${file}: missing frontmatter`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const frontmatter = match[1];
|
|
||||||
if (!/^date:\s*.+/m.test(frontmatter)) {
|
|
||||||
errors.push(`${file}: missing required 'date' field`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const configPath = path.join(publicDir, 'config.yaml');
|
|
||||||
const config = fs.existsSync(configPath)
|
|
||||||
? yaml.load(fs.readFileSync(configPath, 'utf8'))
|
|
||||||
: {};
|
|
||||||
const configDates = config.dates || {};
|
|
||||||
const txtFiles = fs.readdirSync(publicDir).filter(f => f.endsWith('.txt'));
|
|
||||||
for (const file of txtFiles) {
|
|
||||||
if (!configDates[file]) {
|
|
||||||
errors.push(`${file}: missing date in config.yaml`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errors.length) {
|
|
||||||
console.error('Content validation failed:\n');
|
|
||||||
for (const err of errors) console.error(` - ${err}`);
|
|
||||||
console.error('');
|
|
||||||
process.exit(1);
|
|
||||||
} else {
|
|
||||||
console.log(`Validated ${mdFiles.length} posts and ${txtFiles.length} txt files.`);
|
|
||||||
}
|
|
||||||
|
|
@ -3,13 +3,12 @@ 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 md = defineCollection({
|
const posts = defineCollection({
|
||||||
loader: glob({ pattern: '**/*.md', base: './content' }),
|
loader: glob({ pattern: '**/*.md', base: './content' }),
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
date: z.coerce.date(),
|
date: z.coerce.date(),
|
||||||
updated: z.coerce.date().optional(),
|
updated: z.coerce.date().optional(),
|
||||||
pinned: z.boolean().optional(),
|
|
||||||
category: z.string().optional(),
|
category: z.string().optional(),
|
||||||
related: z.array(z.string()).optional(),
|
related: z.array(z.string()).optional(),
|
||||||
})
|
})
|
||||||
|
|
@ -29,4 +28,4 @@ const bookmarks = defineCollection({
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
export const collections = { md, bookmarks };
|
export const collections = { posts, bookmarks };
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ interface Props {
|
||||||
urls?: string[];
|
urls?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, description = 'personal website of lewis m.w.', showHeader = true, isHome = false, urls = [] } = Astro.props;
|
const { title, description = 'personal website of ' + title, showHeader = true, isHome = false, urls = [] } = Astro.props;
|
||||||
---
|
---
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
@ -28,7 +28,18 @@ const { title, description = 'personal website of lewis m.w.', showHeader = true
|
||||||
<body>
|
<body>
|
||||||
{showHeader && (
|
{showHeader && (
|
||||||
<header>
|
<header>
|
||||||
<pre>{isHome ? <Fragment><span class="home-name">lewis m.w.</span><a class="home-name-link" href="/">lewis m.w.</a></Fragment> : <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="/?do=random">random</a> <a href="/?do=newest">newest</a></pre>
|
<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>
|
</header>
|
||||||
)}
|
)}
|
||||||
<slot />
|
<slot />
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import { isAdmin } from './auth';
|
|
||||||
|
|
||||||
export function jsonResponse(data: unknown, status = 200): Response {
|
export function jsonResponse(data: unknown, status = 200): Response {
|
||||||
return new Response(JSON.stringify(data), {
|
return new Response(JSON.stringify(data), {
|
||||||
status,
|
status,
|
||||||
|
|
@ -10,10 +8,3 @@ export function jsonResponse(data: unknown, status = 200): Response {
|
||||||
export function errorResponse(message: string, status: number): Response {
|
export function errorResponse(message: string, status: number): Response {
|
||||||
return jsonResponse({ error: message }, status);
|
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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,26 @@
|
||||||
import { getSession } from 'auth-astro/server';
|
import { getSession } from 'auth-astro/server';
|
||||||
|
|
||||||
type Session = { user?: { id?: string; name?: string | null } };
|
export type Session = { user?: { id?: string; name?: string | null } };
|
||||||
|
|
||||||
export function isAdmin(userId: string | undefined): boolean {
|
export type AuthResult =
|
||||||
return userId === import.meta.env.ADMIN_GITHUB_ID;
|
| { status: 'admin'; session: Session }
|
||||||
}
|
| { status: 'unauthenticated' }
|
||||||
|
| { status: 'forbidden' }
|
||||||
|
| { status: 'error' };
|
||||||
|
|
||||||
export async function requireAdminSession(request: Request): Promise<
|
export async function getAdminSession(request: Request): Promise<AuthResult> {
|
||||||
| { session: Session; error: null }
|
|
||||||
| { session: null; error: Response | null }
|
|
||||||
> {
|
|
||||||
let session: Session | null;
|
let session: Session | null;
|
||||||
try {
|
try {
|
||||||
session = await getSession(request);
|
session = await getSession(request);
|
||||||
} catch {
|
} catch {
|
||||||
return { session: null, error: new Response('Auth not configured', { status: 500 }) };
|
return { status: 'error' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session) {
|
if (!session) return { status: 'unauthenticated' };
|
||||||
return { session: null, error: null };
|
|
||||||
|
if (session.user?.id !== import.meta.env.ADMIN_GITHUB_ID) {
|
||||||
|
return { status: 'forbidden' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAdmin(session.user?.id)) {
|
return { status: 'admin', session };
|
||||||
return { session: null, error: new Response('Forbidden', { status: 403 }) };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { session, error: null };
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ export const SUBDOMAINS = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export const SECTIONS = {
|
export const SECTIONS = {
|
||||||
plaintext: 'plaintext',
|
files: 'files',
|
||||||
bookmarks: 'bookmarks',
|
bookmarks: 'bookmarks',
|
||||||
guestbook: 'guestbook',
|
guestbook: 'guestbook',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -26,25 +26,45 @@ export function formatDate(date: Date): string {
|
||||||
return `${d}/${m}/${y}`;
|
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(
|
export function formatListItem(
|
||||||
date: Date,
|
date: Date,
|
||||||
url: string,
|
url: string,
|
||||||
title: string,
|
title: string,
|
||||||
options?: { pinned?: boolean }
|
options?: { suffix?: string }
|
||||||
): string {
|
): string {
|
||||||
const pinnedBadge = options?.pinned ? ' [pinned]' : '';
|
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>${pinnedBadge}</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 {
|
interface Sortable {
|
||||||
date: Date;
|
date: Date;
|
||||||
pinned?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sortByPinnedThenDate<T extends Sortable>(items: T[]): T[] {
|
export function sortEntries<T>(items: T[], key?: (item: T) => Sortable): T[] {
|
||||||
return items.slice().sort((a, b) => {
|
const get = key ?? (item => item as unknown as Sortable);
|
||||||
if (a.pinned && !b.pinned) return -1;
|
return items.slice().sort((a, b) => get(b).date.getTime() - get(a).date.getTime());
|
||||||
if (!a.pinned && b.pinned) return 1;
|
|
||||||
return b.date.getTime() - a.date.getTime();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,14 @@
|
||||||
import type { CollectionEntry } from 'astro:content';
|
import type { CollectionEntry } from 'astro:content';
|
||||||
import { DEFAULT_CATEGORY } from './consts';
|
import { DEFAULT_CATEGORY } from './consts';
|
||||||
|
import { sortEntries } from './format';
|
||||||
|
|
||||||
type Post = CollectionEntry<'md'>;
|
export type Post = CollectionEntry<'posts'> & { body?: string };
|
||||||
|
|
||||||
export function getSlug(postId: string): string {
|
export function getSlug(postId: string): string {
|
||||||
const parts = postId.split('/');
|
const parts = postId.split('/');
|
||||||
return parts[parts.length - 1];
|
return parts[parts.length - 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortPosts(posts: Post[], { alphabetically = false } = {}): 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;
|
|
||||||
if (alphabetically) return a.data.title.localeCompare(b.data.title);
|
|
||||||
return b.data.date.getTime() - a.data.date.getTime();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveRelatedPosts<T extends { id: string }>(
|
export function resolveRelatedPosts<T extends { id: string }>(
|
||||||
slugs: string[],
|
slugs: string[],
|
||||||
allPosts: T[],
|
allPosts: T[],
|
||||||
|
|
@ -25,7 +17,7 @@ export function resolveRelatedPosts<T extends { id: string }>(
|
||||||
return slugs.flatMap(s => bySlug.get(s) ?? []);
|
return slugs.flatMap(s => bySlug.get(s) ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function organizePostsByCategory(posts: Post[], { sortAlphabetically = false } = {}): {
|
export function organizePostsByCategory(posts: Post[]): {
|
||||||
grouped: Record<string, Post[]>;
|
grouped: Record<string, Post[]>;
|
||||||
categories: string[];
|
categories: string[];
|
||||||
} {
|
} {
|
||||||
|
|
@ -43,7 +35,7 @@ export function organizePostsByCategory(posts: Post[], { sortAlphabetically = fa
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const category of categories) {
|
for (const category of categories) {
|
||||||
grouped[category] = sortPosts(grouped[category], { alphabetically: sortAlphabetically });
|
grouped[category] = sortEntries(grouped[category], p => p.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { grouped, categories };
|
return { grouped, categories };
|
||||||
|
|
@ -1,17 +1,15 @@
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import yaml from 'js-yaml';
|
import yaml from 'js-yaml';
|
||||||
import { sortByPinnedThenDate } from './format';
|
import { sortEntries } from './format';
|
||||||
|
|
||||||
export interface TxtFile {
|
export interface TxtFile {
|
||||||
name: string;
|
name: string;
|
||||||
date: Date;
|
date: Date;
|
||||||
pinned: boolean;
|
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TxtConfig {
|
export interface TxtConfig {
|
||||||
pinned?: string[];
|
|
||||||
descriptions?: Record<string, string>;
|
descriptions?: Record<string, string>;
|
||||||
dates?: Record<string, string>;
|
dates?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
@ -32,7 +30,6 @@ export function getTxtFiles(): TxtFile[] {
|
||||||
if (!fs.existsSync(txtDir)) return [];
|
if (!fs.existsSync(txtDir)) return [];
|
||||||
|
|
||||||
const config = loadTxtConfig();
|
const config = loadTxtConfig();
|
||||||
const pinnedSet = new Set(config.pinned || []);
|
|
||||||
const descriptions = config.descriptions || {};
|
const descriptions = config.descriptions || {};
|
||||||
const dates = config.dates || {};
|
const dates = config.dates || {};
|
||||||
|
|
||||||
|
|
@ -41,9 +38,8 @@ export function getTxtFiles(): TxtFile[] {
|
||||||
.map(name => ({
|
.map(name => ({
|
||||||
name,
|
name,
|
||||||
date: dates[name] ? new Date(dates[name]) : new Date(0),
|
date: dates[name] ? new Date(dates[name]) : new Date(0),
|
||||||
pinned: pinnedSet.has(name),
|
|
||||||
description: descriptions[name],
|
description: descriptions[name],
|
||||||
}));
|
}));
|
||||||
return sortByPinnedThenDate(files);
|
return sortEntries(files);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
---
|
---
|
||||||
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, formatListItem, excerpt } from '../lib/format';
|
import { formatDate, formatListItem, excerpt, wordCount } from '../lib/format';
|
||||||
import { getSlug, resolveRelatedPosts } from '../lib/md';
|
import { getSlug, resolveRelatedPosts, type Post } from '../lib/posts';
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const allPosts = await getCollection('md');
|
const allPosts = await getCollection('posts');
|
||||||
return allPosts.map(post => ({
|
return allPosts.map(post => ({
|
||||||
params: { slug: getSlug(post.id) },
|
params: { slug: getSlug(post.id) },
|
||||||
props: { post, allPosts }
|
props: { post, allPosts }
|
||||||
|
|
@ -15,13 +15,13 @@ export async function getStaticPaths() {
|
||||||
const { post, allPosts } = Astro.props;
|
const { post, allPosts } = Astro.props;
|
||||||
const { Content } = await render(post);
|
const { Content } = await render(post);
|
||||||
const related = post.data.related ? resolveRelatedPosts(post.data.related, allPosts) : [];
|
const related = post.data.related ? resolveRelatedPosts(post.data.related, allPosts) : [];
|
||||||
const description = excerpt((post as any).body) || undefined;
|
const description = excerpt((post as Post).body) || undefined;
|
||||||
---
|
---
|
||||||
<Layout title={`${post.data.title} - lewis m.w.`} description={description}>
|
<Layout title={`${post.data.title} - lewis m.w.`} description={description}>
|
||||||
|
|
||||||
<article>
|
<article>
|
||||||
<h1>{post.data.title}</h1>
|
<h1>{post.data.title}</h1>
|
||||||
<p class="muted" style="margin-top: 0;">{formatDate(post.data.date)}{post.data.updated && ` (updated ${formatDate(post.data.updated)})`}</p>
|
<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 />
|
<Content />
|
||||||
</article>
|
</article>
|
||||||
{related.length > 0 && (
|
{related.length > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
import { getPendingEntries, type GuestbookEntry } from '../lib/db';
|
import { getPendingEntries, type GuestbookEntry } from '../lib/db';
|
||||||
import { requireAdminSession } from '../lib/auth';
|
import { getAdminSession } from '../lib/auth';
|
||||||
import Layout from '../layouts/Layout.astro';
|
import Layout from '../layouts/Layout.astro';
|
||||||
import { formatDate } from '../lib/format';
|
import { formatDate } from '../lib/format';
|
||||||
|
|
||||||
const { session, error } = await requireAdminSession(Astro.request);
|
const auth = await getAdminSession(Astro.request);
|
||||||
if (error) return error;
|
if (auth.status === 'error') return new Response('Auth not configured', { status: 500 });
|
||||||
if (!session) return Astro.redirect('/api/auth/signin');
|
if (auth.status === 'unauthenticated') return Astro.redirect('/api/auth/signin');
|
||||||
|
if (auth.status !== 'admin') return new Response('Forbidden', { status: 403 });
|
||||||
|
const { session } = auth;
|
||||||
|
|
||||||
let entries: GuestbookEntry[] = [];
|
let entries: GuestbookEntry[] = [];
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,18 @@
|
||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { getSession } from 'auth-astro/server';
|
import { jsonResponse, errorResponse } from '../../lib/api';
|
||||||
import { jsonResponse, errorResponse, requireAdmin } from '../../lib/api';
|
import { getAdminSession } from '../../lib/auth';
|
||||||
|
|
||||||
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 auth = await getAdminSession(request);
|
||||||
const authError = requireAdmin(session);
|
if (auth.status !== 'admin') return errorResponse('Unauthorized', 403);
|
||||||
if (authError) return authError;
|
|
||||||
|
|
||||||
const hookUrl = import.meta.env.VERCEL_DEPLOY_HOOK;
|
const hookUrl = import.meta.env.VERCEL_DEPLOY_HOOK;
|
||||||
if (!hookUrl) {
|
if (!hookUrl) return errorResponse('Deploy hook not configured', 500);
|
||||||
return errorResponse('Deploy hook not configured', 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(hookUrl, { method: 'POST' });
|
const res = await fetch(hookUrl, { method: 'POST' });
|
||||||
if (!res.ok) {
|
if (!res.ok) return errorResponse('Failed to trigger deploy', 502);
|
||||||
return errorResponse('Failed to trigger deploy', 502);
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse({ success: true });
|
return jsonResponse({ success: true });
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,27 @@
|
||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { getSession } from 'auth-astro/server';
|
|
||||||
import { approveEntry, deleteEntry } from '../../../lib/db';
|
import { approveEntry, deleteEntry } from '../../../lib/db';
|
||||||
import { jsonResponse, errorResponse, requireAdmin } from '../../../lib/api';
|
import { jsonResponse, errorResponse } from '../../../lib/api';
|
||||||
|
import { getAdminSession } from '../../../lib/auth';
|
||||||
|
|
||||||
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 auth = await getAdminSession(request);
|
||||||
const authError = requireAdmin(session);
|
if (auth.status !== 'admin') return errorResponse('Unauthorized', 403);
|
||||||
if (authError) return authError;
|
|
||||||
|
|
||||||
const id = parseInt(params.id!, 10);
|
const id = parseInt(params.id!, 10);
|
||||||
if (isNaN(id)) {
|
if (isNaN(id)) return errorResponse('Invalid ID', 400);
|
||||||
return errorResponse('Invalid ID', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
await approveEntry(id);
|
await approveEntry(id);
|
||||||
return jsonResponse({ success: true });
|
return jsonResponse({ success: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: APIRoute = async ({ params, request }) => {
|
export const DELETE: APIRoute = async ({ params, request }) => {
|
||||||
const session = await getSession(request);
|
const auth = await getAdminSession(request);
|
||||||
const authError = requireAdmin(session);
|
if (auth.status !== 'admin') return errorResponse('Unauthorized', 403);
|
||||||
if (authError) return authError;
|
|
||||||
|
|
||||||
const id = parseInt(params.id!, 10);
|
const id = parseInt(params.id!, 10);
|
||||||
if (isNaN(id)) {
|
if (isNaN(id)) return errorResponse('Invalid ID', 400);
|
||||||
return errorResponse('Invalid ID', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
await deleteEntry(id);
|
await deleteEntry(id);
|
||||||
return jsonResponse({ success: true });
|
return jsonResponse({ success: true });
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import rss from '@astrojs/rss';
|
import rss from '@astrojs/rss';
|
||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
import type { APIContext } from 'astro';
|
import type { APIContext } from 'astro';
|
||||||
import { getSlug } from '../lib/md';
|
import { getSlug, type Post } from '../lib/posts';
|
||||||
import { getTxtFiles } from '../lib/txt';
|
import { getTxtFiles } from '../lib/txt';
|
||||||
import { excerpt } from '../lib/format';
|
import { excerpt } from '../lib/format';
|
||||||
|
|
||||||
export async function GET(context: APIContext) {
|
export async function GET(context: APIContext) {
|
||||||
const posts = await getCollection('md');
|
const posts = await getCollection('posts');
|
||||||
const txtFiles = getTxtFiles();
|
const txtFiles = getTxtFiles();
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
|
|
@ -14,7 +14,7 @@ export async function GET(context: APIContext) {
|
||||||
title: post.data.title,
|
title: post.data.title,
|
||||||
pubDate: post.data.date,
|
pubDate: post.data.date,
|
||||||
link: `/${getSlug(post.id)}`,
|
link: `/${getSlug(post.id)}`,
|
||||||
description: excerpt((post as any).body) || post.data.title,
|
description: excerpt((post as Post).body) || post.data.title,
|
||||||
})),
|
})),
|
||||||
...txtFiles.map(txt => ({
|
...txtFiles.map(txt => ({
|
||||||
title: txt.name,
|
title: txt.name,
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@
|
||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
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, formatListItem, escapeHtml } from '../lib/format';
|
import { formatDate, formatListItem, extractDomain, wordCount, escapeHtml } from '../lib/format';
|
||||||
import { organizePostsByCategory, getSlug } from '../lib/md';
|
import { organizePostsByCategory, getSlug } from '../lib/posts';
|
||||||
import { getTxtFiles } from '../lib/txt';
|
import { getTxtFiles } from '../lib/txt';
|
||||||
import { DEFAULT_CATEGORY, SECTIONS, SUBDOMAINS } from '../lib/consts';
|
import { DEFAULT_CATEGORY, SECTIONS, SUBDOMAINS } from '../lib/consts';
|
||||||
|
|
||||||
const posts = await getCollection('md');
|
const posts = await getCollection('posts');
|
||||||
const { grouped, categories: sortedCategories } = organizePostsByCategory(posts);
|
const { grouped, categories: sortedCategories } = organizePostsByCategory(posts);
|
||||||
|
|
||||||
const bookmarksCollection = await getCollection('bookmarks');
|
const bookmarksCollection = await getCollection('bookmarks');
|
||||||
|
|
@ -37,24 +37,24 @@ const urls = [
|
||||||
<section data-section={category}>
|
<section data-section={category}>
|
||||||
{!isDefault && <a class="section-label" href={`?just=${category}`}>{category}</a>}
|
{!isDefault && <a class="section-label" href={`?just=${category}`}>{category}</a>}
|
||||||
<div class="entry-list" set:html={categoryPosts.map(post =>
|
<div class="entry-list" set:html={categoryPosts.map(post =>
|
||||||
`<span class="entry">${formatListItem(post.data.date, `/${getSlug(post.id)}`, post.data.title, { pinned: post.data.pinned })}</span>`
|
`<span class="entry">${formatListItem(post.data.date, `/${getSlug(post.id)}`, post.data.title, { suffix: wordCount(post.body) })}</span>`
|
||||||
).join('')} />
|
).join('')} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<section data-section={SECTIONS.plaintext}>
|
<section data-section={SECTIONS.files}>
|
||||||
<a class="section-label" href={`?just=${SECTIONS.plaintext}`}>{SECTIONS.plaintext}</a>
|
<a class="section-label" href={`?just=${SECTIONS.files}`}>{SECTIONS.files}</a>
|
||||||
<div class="entry-list" set:html={txtFiles.map(f => {
|
<div class="entry-list" set:html={txtFiles.map(f => {
|
||||||
const name = f.name.replace(/\.txt$/, '');
|
const name = f.name.replace(/\.txt$/, '');
|
||||||
return `<span class="entry">${formatListItem(f.date, `/${f.name}`, name, { pinned: f.pinned })}</span>`;
|
return `<span class="entry">${formatListItem(f.date, `/${f.name}`, name, { suffix: f.description })}</span>`;
|
||||||
}).join('')} />
|
}).join('')} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section data-section={SECTIONS.bookmarks}>
|
<section data-section={SECTIONS.bookmarks}>
|
||||||
<a class="section-label" href={`?just=${SECTIONS.bookmarks}`}>{SECTIONS.bookmarks}</a>
|
<a class="section-label" href={`?just=${SECTIONS.bookmarks}`}>{SECTIONS.bookmarks}</a>
|
||||||
<div class="entry-list" set:html={bookmarks.map(b =>
|
<div class="entry-list" set:html={bookmarks.map(b =>
|
||||||
`<span class="entry">${formatListItem(b.data.date, b.data.url, b.data.title)}</span>`
|
`<span class="entry">${formatListItem(b.data.date, b.data.url, b.data.title, { suffix: extractDomain(b.data.url) })}</span>`
|
||||||
).join('')} />
|
).join('')} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { getCollection } from 'astro:content';
|
import { getCollection } from 'astro:content';
|
||||||
import type { APIContext } from 'astro';
|
import type { APIContext } from 'astro';
|
||||||
import { getSlug } from '../lib/md';
|
import { getSlug } from '../lib/posts';
|
||||||
import { getTxtFiles } from '../lib/txt';
|
import { getTxtFiles } from '../lib/txt';
|
||||||
import { SUBDOMAINS } from '../lib/consts';
|
import { SUBDOMAINS } from '../lib/consts';
|
||||||
|
|
||||||
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('md');
|
const posts = await getCollection('posts');
|
||||||
const txtFiles = getTxtFiles().map(f => f.name);
|
const txtFiles = getTxtFiles().map(f => f.name);
|
||||||
|
|
||||||
const urls = [
|
const urls = [
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
body {
|
body {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
max-width: 48rem;
|
max-width: 34rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
text-align: justify;
|
text-align: justify;
|
||||||
|
font-family: 'Times New Roman', serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
|
|
@ -17,6 +18,7 @@ h1, h2, h3, h4, h5, h6 {
|
||||||
|
|
||||||
.muted {
|
.muted {
|
||||||
color: #888;
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.left, .right {
|
.left, .right {
|
||||||
|
|
@ -24,7 +26,7 @@ h1, h2, h3, h4, h5, h6 {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 63rem) {
|
@media (min-width: 58rem) {
|
||||||
.left, .right {
|
.left, .right {
|
||||||
display: inline;
|
display: inline;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
@ -78,67 +80,77 @@ section {
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
section .section-label {
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-name-link {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-just] .home-name {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-just] .home-name-link {
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-has] .guestbook-form {
|
html[data-has] .guestbook-form {
|
||||||
display: none;
|
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 {
|
section pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-list {
|
.entry-list {
|
||||||
columns: 2 24ch;
|
|
||||||
column-gap: 3ch;
|
|
||||||
font-family: monospace;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry {
|
.entry {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 10ch 1fr;
|
grid-template-columns: 4rem 1fr;
|
||||||
|
align-items: baseline;
|
||||||
break-inside: avoid;
|
break-inside: avoid;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-content {
|
.entry-content {
|
||||||
|
display: flex;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-content > a {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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 {
|
.guestbook-entries {
|
||||||
font-family: monospace;
|
margin: 0;
|
||||||
white-space: pre;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.guestbook-entry {
|
.guestbook-entry {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 10ch 1fr;
|
grid-template-columns: 4rem 1fr;
|
||||||
}
|
align-items: baseline;
|
||||||
|
break-inside: avoid;
|
||||||
.guestbook-entry > span:last-child {
|
|
||||||
white-space: normal;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.guestbook-form {
|
.guestbook-form {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
margin-left: 10ch;
|
margin-left: 4rem;
|
||||||
font-family: monospace;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-compact] .list-meta {
|
html[data-compact] .list-meta {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue