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: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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -5,3 +5,9 @@ descriptions:
|
||||||
man.txt: manual page
|
man.txt: manual page
|
||||||
changelog.txt: site changelog
|
changelog.txt: site changelog
|
||||||
stats.txt: site statistics
|
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' }),
|
loader: glob({ pattern: '**/*.md', base: './content' }),
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
date: z.coerce.date().optional(),
|
date: z.coerce.date(),
|
||||||
|
updated: z.coerce.date().optional(),
|
||||||
pinned: z.boolean().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(),
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ export function formatListItem(
|
||||||
const pinnedBadge = options?.pinned ? ' [pinned]' : '';
|
const pinnedBadge = options?.pinned ? ' [pinned]' : '';
|
||||||
const suffix = options?.suffix ? ` ${options.suffix}` : '';
|
const suffix = options?.suffix ? ` ${options.suffix}` : '';
|
||||||
const prefix = options?.prefix ?? '';
|
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 {
|
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 type { CollectionEntry } from 'astro:content';
|
||||||
import { getGitDates, type GitDates } from './git';
|
|
||||||
import { DEFAULT_CATEGORY } from './consts';
|
import { DEFAULT_CATEGORY } from './consts';
|
||||||
|
|
||||||
type Post = CollectionEntry<'md'>;
|
type Post = CollectionEntry<'md'>;
|
||||||
|
|
||||||
export interface PostWithDates extends Post {
|
|
||||||
dates: GitDates;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 getPostFilePath(post: Post): string {
|
function sortPosts(posts: Post[], { alphabetically = false } = {}): Post[] {
|
||||||
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[] {
|
|
||||||
return posts.slice().sort((a, b) => {
|
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 (!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);
|
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) ?? []);
|
return slugs.flatMap(s => bySlug.get(s) ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function organizePostsByCategory(posts: PostWithDates[], { sortAlphabetically = false } = {}): {
|
export function organizePostsByCategory(posts: Post[], { sortAlphabetically = false } = {}): {
|
||||||
grouped: Record<string, PostWithDates[]>;
|
grouped: Record<string, Post[]>;
|
||||||
categories: string[];
|
categories: string[];
|
||||||
} {
|
} {
|
||||||
const grouped = posts.reduce((acc, post) => {
|
const grouped = posts.reduce((acc, post) => {
|
||||||
|
|
@ -62,7 +34,7 @@ export function organizePostsByCategory(posts: PostWithDates[], { sortAlphabetic
|
||||||
if (!acc[category]) acc[category] = [];
|
if (!acc[category]) acc[category] = [];
|
||||||
acc[category].push(post);
|
acc[category].push(post);
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, PostWithDates[]>);
|
}, {} as Record<string, Post[]>);
|
||||||
|
|
||||||
const categories = Object.keys(grouped).sort((a, b) => {
|
const categories = Object.keys(grouped).sort((a, b) => {
|
||||||
if (a === DEFAULT_CATEGORY) return -1;
|
if (a === DEFAULT_CATEGORY) return -1;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ 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 { sortByPinnedThenDate } from './format';
|
||||||
import { getGitCreationDate } from './git';
|
|
||||||
|
|
||||||
export interface TxtFile {
|
export interface TxtFile {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -14,6 +13,7 @@ export interface TxtFile {
|
||||||
export interface TxtConfig {
|
export interface TxtConfig {
|
||||||
pinned?: string[];
|
pinned?: string[];
|
||||||
descriptions?: Record<string, string>;
|
descriptions?: Record<string, string>;
|
||||||
|
dates?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTxtDir(): string {
|
export function getTxtDir(): string {
|
||||||
|
|
@ -33,13 +33,14 @@ export function getTxtFiles(): TxtFile[] {
|
||||||
|
|
||||||
const config = loadTxtConfig();
|
const config = loadTxtConfig();
|
||||||
const pinnedSet = new Set(config.pinned || []);
|
const pinnedSet = new Set(config.pinned || []);
|
||||||
|
|
||||||
const descriptions = config.descriptions || {};
|
const descriptions = config.descriptions || {};
|
||||||
|
const dates = config.dates || {};
|
||||||
|
|
||||||
const files = fs.readdirSync(txtDir)
|
const files = fs.readdirSync(txtDir)
|
||||||
.filter(file => file.endsWith('.txt'))
|
.filter(file => file.endsWith('.txt'))
|
||||||
.map(name => ({
|
.map(name => ({
|
||||||
name,
|
name,
|
||||||
date: getGitCreationDate(path.join(txtDir, name)),
|
date: dates[name] ? new Date(dates[name]) : new Date(0),
|
||||||
pinned: pinnedSet.has(name),
|
pinned: pinnedSet.has(name),
|
||||||
description: descriptions[name],
|
description: descriptions[name],
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,10 @@
|
||||||
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 } from '../lib/format';
|
||||||
import { getSlug, enrichPostWithDates, enrichPostsWithDates, resolveRelatedPosts } from '../lib/md';
|
import { getSlug, resolveRelatedPosts } from '../lib/md';
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const rawPosts = await getCollection('md');
|
const allPosts = await getCollection('md');
|
||||||
const allPosts = enrichPostsWithDates(rawPosts);
|
|
||||||
return allPosts.map(post => ({
|
return allPosts.map(post => ({
|
||||||
params: { slug: getSlug(post.id) },
|
params: { slug: getSlug(post.id) },
|
||||||
props: { post, allPosts }
|
props: { post, allPosts }
|
||||||
|
|
@ -22,13 +21,13 @@ const description = excerpt((post as any).body) || undefined;
|
||||||
|
|
||||||
<article>
|
<article>
|
||||||
<h1>{post.data.title}</h1>
|
<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 />
|
<Content />
|
||||||
</article>
|
</article>
|
||||||
{related.length > 0 && (
|
{related.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<span class="section-label">related</span>
|
<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>
|
</section>
|
||||||
)}
|
)}
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,18 @@
|
||||||
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, enrichPostsWithDates } from '../lib/md';
|
import { getSlug } from '../lib/md';
|
||||||
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 rawPosts = await getCollection('md');
|
const posts = await getCollection('md');
|
||||||
const posts = enrichPostsWithDates(rawPosts);
|
|
||||||
const txtFiles = getTxtFiles();
|
const txtFiles = getTxtFiles();
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
...posts.map(post => ({
|
...posts.map(post => ({
|
||||||
title: post.data.title,
|
title: post.data.title,
|
||||||
pubDate: post.dates.created,
|
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 any).body) || post.data.title,
|
||||||
})),
|
})),
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,11 @@ 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, escapeHtml } from '../lib/format';
|
||||||
import { organizePostsByCategory, getSlug, enrichPostsWithDates } from '../lib/md';
|
import { organizePostsByCategory, getSlug } from '../lib/md';
|
||||||
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 rawPosts = await getCollection('md');
|
const posts = await getCollection('md');
|
||||||
const posts = enrichPostsWithDates(rawPosts);
|
|
||||||
const { grouped, categories: sortedCategories } = organizePostsByCategory(posts);
|
const { grouped, categories: sortedCategories } = organizePostsByCategory(posts);
|
||||||
|
|
||||||
const bookmarksCollection = await getCollection('bookmarks');
|
const bookmarksCollection = await getCollection('bookmarks');
|
||||||
|
|
@ -25,7 +24,7 @@ try {
|
||||||
}
|
}
|
||||||
|
|
||||||
const urls = [
|
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() })),
|
...txtFiles.map(f => ({ url: `/${f.name}`, date: f.date.getTime() })),
|
||||||
].sort((a, b) => b.date - a.date).map(e => e.url).concat(SUBDOMAINS);
|
].sort((a, b) => b.date - a.date).map(e => e.url).concat(SUBDOMAINS);
|
||||||
---
|
---
|
||||||
|
|
@ -38,7 +37,7 @@ 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.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('')} />
|
).join('')} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue