feat: removes git dates, enforces manual dates, and adds some validation to ensure presence
This commit is contained in:
parent
d65342fd73
commit
2a2331e79f
13 changed files with 101 additions and 105 deletions
25
.github/workflows/validate.yml
vendored
Normal file
25
.github/workflows/validate.yml
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
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,6 +6,7 @@
|
|||
"dev:penfield": "pnpm --filter @ily/penfield dev",
|
||||
"dev:www": "pnpm --filter @ily/www dev",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
43
www/scripts/validate-content.js
Normal file
43
www/scripts/validate-content.js
Normal 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.`);
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue