From 2a2331e79f314a7c49e49cb270f7052971f24a36 Mon Sep 17 00:00:00 2001 From: lew Date: Fri, 27 Mar 2026 18:10:40 +0000 Subject: [PATCH] feat: removes git dates, enforces manual dates, and adds some validation to ensure presence --- .github/workflows/validate.yml | 25 ++++++++++++++++ package.json | 3 +- www/package.json | 3 +- www/public/config.yaml | 6 ++++ www/scripts/validate-content.js | 43 +++++++++++++++++++++++++++ www/src/content.config.ts | 3 +- www/src/lib/format.ts | 2 +- www/src/lib/git.ts | 51 --------------------------------- www/src/lib/md.ts | 38 ++++-------------------- www/src/lib/txt.ts | 7 +++-- www/src/pages/[slug].astro | 9 +++--- www/src/pages/feed.xml.ts | 7 ++--- www/src/pages/index.astro | 9 +++--- 13 files changed, 101 insertions(+), 105 deletions(-) create mode 100644 .github/workflows/validate.yml create mode 100644 www/scripts/validate-content.js delete mode 100644 www/src/lib/git.ts diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..3535d88 --- /dev/null +++ b/.github/workflows/validate.yml @@ -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 diff --git a/package.json b/package.json index 6ebe20f..c870fd5 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/www/package.json b/www/package.json index cfa3cc7..5c7ad4d 100644 --- a/www/package.json +++ b/www/package.json @@ -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", diff --git a/www/public/config.yaml b/www/public/config.yaml index 7f7d9ec..2b7aa3a 100644 --- a/www/public/config.yaml +++ b/www/public/config.yaml @@ -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 diff --git a/www/scripts/validate-content.js b/www/scripts/validate-content.js new file mode 100644 index 0000000..3cce1f2 --- /dev/null +++ b/www/scripts/validate-content.js @@ -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.`); +} diff --git a/www/src/content.config.ts b/www/src/content.config.ts index 0f7bc9d..6499a0a 100644 --- a/www/src/content.config.ts +++ b/www/src/content.config.ts @@ -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(), diff --git a/www/src/lib/format.ts b/www/src/lib/format.ts index eb3b83e..8308e9e 100644 --- a/www/src/lib/format.ts +++ b/www/src/lib/format.ts @@ -35,7 +35,7 @@ export function formatListItem( const pinnedBadge = options?.pinned ? ' [pinned]' : ''; const suffix = options?.suffix ? ` ${options.suffix}` : ''; const prefix = options?.prefix ?? ''; - return `${prefix}${formatDate(date)}${title}${pinnedBadge}${suffix}`; + return `${prefix}${formatDate(date)}${title}${pinnedBadge}${suffix}`; } interface Sortable { diff --git a/www/src/lib/git.ts b/www/src/lib/git.ts deleted file mode 100644 index 6b702e3..0000000 --- a/www/src/lib/git.ts +++ /dev/null @@ -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, - }; -} diff --git a/www/src/lib/md.ts b/www/src/lib/md.ts index 4a5482e..378c2c6 100644 --- a/www/src/lib/md.ts +++ b/www/src/lib/md.ts @@ -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( return slugs.flatMap(s => bySlug.get(s) ?? []); } -export function organizePostsByCategory(posts: PostWithDates[], { sortAlphabetically = false } = {}): { - grouped: Record; +export function organizePostsByCategory(posts: Post[], { sortAlphabetically = false } = {}): { + grouped: Record; 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); + }, {} as Record); const categories = Object.keys(grouped).sort((a, b) => { if (a === DEFAULT_CATEGORY) return -1; diff --git a/www/src/lib/txt.ts b/www/src/lib/txt.ts index d38129f..8bca741 100644 --- a/www/src/lib/txt.ts +++ b/www/src/lib/txt.ts @@ -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; + dates?: Record; } 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], })); diff --git a/www/src/pages/[slug].astro b/www/src/pages/[slug].astro index 7974b9d..c2a81a8 100644 --- a/www/src/pages/[slug].astro +++ b/www/src/pages/[slug].astro @@ -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;

{post.data.title}

-

{formatDate(post.dates.created)}{post.dates.updated && ` (updated ${formatDate(post.dates.updated)})`}

+

{formatDate(post.data.date)}{post.data.updated && ` (updated ${formatDate(post.data.updated)})`}

{related.length > 0 && (
-
 formatListItem(p.dates.created, `/${getSlug(p.id)}`, p.data.title)).join('\n')} />
+    
 formatListItem(p.data.date, `/${getSlug(p.id)}`, p.data.title)).join('\n')} />
   
)} diff --git a/www/src/pages/feed.xml.ts b/www/src/pages/feed.xml.ts index 55c43cc..d350661 100644 --- a/www/src/pages/feed.xml.ts +++ b/www/src/pages/feed.xml.ts @@ -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, })), diff --git a/www/src/pages/index.astro b/www/src/pages/index.astro index 26a62d1..75f592f 100644 --- a/www/src/pages/index.astro +++ b/www/src/pages/index.astro @@ -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 = [
{!isDefault && }
- `${formatListItem(post.dates.created, `/${getSlug(post.id)}`, post.data.title, { pinned: post.data.pinned })}` + `${formatListItem(post.data.date, `/${getSlug(post.id)}`, post.data.title, { pinned: post.data.pinned })}` ).join('')} />
);