feat: removes git dates, enforces manual dates, and adds some validation to ensure presence

This commit is contained in:
Lewis Wynne 2026-03-27 18:10:40 +00:00
parent d65342fd73
commit 2a2331e79f
13 changed files with 101 additions and 105 deletions

View file

@ -5,7 +5,8 @@
"dev": "astro dev --port 4322",
"build": "astro build --remote && node scripts/generate-stats.js",
"preview": "astro preview",
"serve": "pnpm build && npx serve .vercel/output/static -l 4322"
"serve": "pnpm build && npx serve .vercel/output/static -l 4322",
"validate": "node scripts/validate-content.js"
},
"dependencies": {
"@astrojs/db": "^0.19.0",

View file

@ -5,3 +5,9 @@ descriptions:
man.txt: manual page
changelog.txt: site changelog
stats.txt: site statistics
dates:
changelog.txt: 2026-01-23
cv.txt: 2026-01-23
man.txt: 2026-03-26
now.txt: 2026-01-23
stats.txt: 2026-01-23

View file

@ -0,0 +1,43 @@
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.`);
}

View file

@ -7,7 +7,8 @@ const md = defineCollection({
loader: glob({ pattern: '**/*.md', base: './content' }),
schema: z.object({
title: z.string(),
date: z.coerce.date().optional(),
date: z.coerce.date(),
updated: z.coerce.date().optional(),
pinned: z.boolean().optional(),
category: z.string().optional(),
related: z.array(z.string()).optional(),

View file

@ -35,7 +35,7 @@ export function formatListItem(
const pinnedBadge = options?.pinned ? ' [pinned]' : '';
const suffix = options?.suffix ? ` ${options.suffix}` : '';
const prefix = options?.prefix ?? '';
return `<span class="list-meta">${prefix}<span class="muted">${formatDate(date)}</span></span><span class="entry-content"><a href="${url}">${title}</a>${pinnedBadge}${suffix}</span>`;
return `<span class="list-meta">${prefix}<span class="muted">${formatDate(date)}</span></span><span class="entry-content"><a href="${url}" title="${title}">${title}</a>${pinnedBadge}${suffix}</span>`;
}
interface Sortable {

View file

@ -1,51 +0,0 @@
import { execSync } from 'node:child_process';
import path from 'node:path';
export function getGitCreationDate(filePath: string): Date {
try {
// Run git from the file's directory to handle submodules
const dir = path.dirname(filePath);
const file = path.basename(filePath);
// Get the oldest commit for this file (first commit that added it)
const timestamp = execSync(
`git log --follow --diff-filter=A --format=%cI -- "${file}"`,
{ encoding: 'utf8', cwd: dir }
).trim();
return timestamp ? new Date(timestamp) : new Date(0);
} catch {
return new Date(0);
}
}
export function getGitLastModifiedDate(filePath: string): Date {
try {
// Run git from the file's directory to handle submodules
const dir = path.dirname(filePath);
const file = path.basename(filePath);
const timestamp = execSync(
`git log -1 --format=%cI -- "${file}"`,
{ encoding: 'utf8', cwd: dir }
).trim();
return timestamp ? new Date(timestamp) : new Date(0);
} catch {
return new Date(0);
}
}
export interface GitDates {
created: Date;
updated: Date | null; // null if never updated (created === lastModified)
}
export function getGitDates(filePath: string): GitDates {
const created = getGitCreationDate(filePath);
const lastModified = getGitLastModifiedDate(filePath);
// If dates are the same (same commit), there's no update
const hasUpdate = created.getTime() !== lastModified.getTime();
return {
created,
updated: hasUpdate ? lastModified : null,
};
}

View file

@ -1,47 +1,19 @@
import path from 'node:path';
import type { CollectionEntry } from 'astro:content';
import { getGitDates, type GitDates } from './git';
import { DEFAULT_CATEGORY } from './consts';
type Post = CollectionEntry<'md'>;
export interface PostWithDates extends Post {
dates: GitDates;
}
export function getSlug(postId: string): string {
const parts = postId.split('/');
return parts[parts.length - 1];
}
function getPostFilePath(post: Post): string {
return path.join(process.cwd(), 'content', `${post.id}.md`);
}
export function enrichPostWithDates(post: Post): PostWithDates {
const filePath = getPostFilePath(post);
const gitDates = getGitDates(filePath);
const created = post.data.date ?? gitDates.created;
const updated = post.data.updated ?? gitDates.updated;
return {
...post,
dates: {
created,
updated: updated && updated.getTime() !== created.getTime() ? updated : null,
},
};
}
export function enrichPostsWithDates(posts: Post[]): PostWithDates[] {
return posts.map(enrichPostWithDates);
}
function sortPosts(posts: PostWithDates[], { alphabetically = false } = {}): PostWithDates[] {
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.dates.created.getTime() - a.dates.created.getTime();
return b.data.date.getTime() - a.data.date.getTime();
});
}
@ -53,8 +25,8 @@ export function resolveRelatedPosts<T extends { id: string }>(
return slugs.flatMap(s => bySlug.get(s) ?? []);
}
export function organizePostsByCategory(posts: PostWithDates[], { sortAlphabetically = false } = {}): {
grouped: Record<string, PostWithDates[]>;
export function organizePostsByCategory(posts: Post[], { sortAlphabetically = false } = {}): {
grouped: Record<string, Post[]>;
categories: string[];
} {
const grouped = posts.reduce((acc, post) => {
@ -62,7 +34,7 @@ export function organizePostsByCategory(posts: PostWithDates[], { sortAlphabetic
if (!acc[category]) acc[category] = [];
acc[category].push(post);
return acc;
}, {} as Record<string, PostWithDates[]>);
}, {} as Record<string, Post[]>);
const categories = Object.keys(grouped).sort((a, b) => {
if (a === DEFAULT_CATEGORY) return -1;

View file

@ -2,7 +2,6 @@ import fs from 'node:fs';
import path from 'node:path';
import yaml from 'js-yaml';
import { sortByPinnedThenDate } from './format';
import { getGitCreationDate } from './git';
export interface TxtFile {
name: string;
@ -14,6 +13,7 @@ export interface TxtFile {
export interface TxtConfig {
pinned?: string[];
descriptions?: Record<string, string>;
dates?: Record<string, string>;
}
export function getTxtDir(): string {
@ -33,13 +33,14 @@ export function getTxtFiles(): TxtFile[] {
const config = loadTxtConfig();
const pinnedSet = new Set(config.pinned || []);
const descriptions = config.descriptions || {};
const dates = config.dates || {};
const files = fs.readdirSync(txtDir)
.filter(file => file.endsWith('.txt'))
.map(name => ({
name,
date: getGitCreationDate(path.join(txtDir, name)),
date: dates[name] ? new Date(dates[name]) : new Date(0),
pinned: pinnedSet.has(name),
description: descriptions[name],
}));

View file

@ -2,11 +2,10 @@
import { getCollection, render } from 'astro:content';
import Layout from '../layouts/Layout.astro';
import { formatDate, formatListItem, excerpt } from '../lib/format';
import { getSlug, enrichPostWithDates, enrichPostsWithDates, resolveRelatedPosts } from '../lib/md';
import { getSlug, resolveRelatedPosts } from '../lib/md';
export async function getStaticPaths() {
const rawPosts = await getCollection('md');
const allPosts = enrichPostsWithDates(rawPosts);
const allPosts = await getCollection('md');
return allPosts.map(post => ({
params: { slug: getSlug(post.id) },
props: { post, allPosts }
@ -22,13 +21,13 @@ const description = excerpt((post as any).body) || undefined;
<article>
<h1>{post.data.title}</h1>
<p class="muted" style="margin-top: 0;">{formatDate(post.dates.created)}{post.dates.updated && ` (updated ${formatDate(post.dates.updated)})`}</p>
<p class="muted" style="margin-top: 0;">{formatDate(post.data.date)}{post.data.updated && ` (updated ${formatDate(post.data.updated)})`}</p>
<Content />
</article>
{related.length > 0 && (
<section>
<span class="section-label">related</span>
<pre set:html={related.map(p => formatListItem(p.dates.created, `/${getSlug(p.id)}`, p.data.title)).join('\n')} />
<pre set:html={related.map(p => formatListItem(p.data.date, `/${getSlug(p.id)}`, p.data.title)).join('\n')} />
</section>
)}
</Layout>

View file

@ -1,19 +1,18 @@
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
import type { APIContext } from 'astro';
import { getSlug, enrichPostsWithDates } from '../lib/md';
import { getSlug } from '../lib/md';
import { getTxtFiles } from '../lib/txt';
import { excerpt } from '../lib/format';
export async function GET(context: APIContext) {
const rawPosts = await getCollection('md');
const posts = enrichPostsWithDates(rawPosts);
const posts = await getCollection('md');
const txtFiles = getTxtFiles();
const items = [
...posts.map(post => ({
title: post.data.title,
pubDate: post.dates.created,
pubDate: post.data.date,
link: `/${getSlug(post.id)}`,
description: excerpt((post as any).body) || post.data.title,
})),

View file

@ -3,12 +3,11 @@ import { getCollection } from 'astro:content';
import Layout from '../layouts/Layout.astro';
import { getApprovedEntries, type GuestbookEntry } from '../lib/db';
import { formatDate, formatListItem, escapeHtml } from '../lib/format';
import { organizePostsByCategory, getSlug, enrichPostsWithDates } from '../lib/md';
import { organizePostsByCategory, getSlug } from '../lib/md';
import { getTxtFiles } from '../lib/txt';
import { DEFAULT_CATEGORY, SECTIONS, SUBDOMAINS } from '../lib/consts';
const rawPosts = await getCollection('md');
const posts = enrichPostsWithDates(rawPosts);
const posts = await getCollection('md');
const { grouped, categories: sortedCategories } = organizePostsByCategory(posts);
const bookmarksCollection = await getCollection('bookmarks');
@ -25,7 +24,7 @@ try {
}
const urls = [
...posts.map(post => ({ url: `/${getSlug(post.id)}`, date: post.dates.created.getTime() })),
...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);
---
@ -38,7 +37,7 @@ const urls = [
<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.dates.created, `/${getSlug(post.id)}`, post.data.title, { pinned: post.data.pinned })}</span>`
`<span class="entry">${formatListItem(post.data.date, `/${getSlug(post.id)}`, post.data.title, { pinned: post.data.pinned })}</span>`
).join('')} />
</section>
);