refactor(core): one list of media extensions and showcase ids
The validator rejects unknown showcase ids and lets a featured project with an image go without a showcase; the start page reuses featuredOf. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3b2771815d
commit
b0a3d28cfb
12 files changed
+56
-21
No files matched your search
@@ -12,7 +12,7 @@ content format: `docs/CONTENT-CONTRACT.md`; deployment: `docs/DEPLOY.md`.
|
||||
## Map
|
||||
| Path | Responsibility |
|
||||
|---|---|
|
||||
| `src/core/` | Pure, dependency-free logic shared by the build and the browser: AI levels and visibility (`ai.ts`), durations, issue numbering (`issues.ts`), `safePostHtml` (`post-html.ts`) |
|
||||
| `src/core/` | Pure, dependency-free logic shared by the build and the browser: AI levels and visibility (`ai.ts`), durations, issue numbering (`issues.ts`), `safePostHtml` (`post-html.ts`), media extensions (`media.ts`), showcase ids (`showcases.ts`) |
|
||||
| `src/library/` | Reads and validates the content repo (`load.ts`, `frontmatter.ts`, `types.ts`, `errors.ts`); `RESERVED_PERSONAL_IDS` guards ids that would shadow a page |
|
||||
| `src/sources/` | Ghost, Trakt and Hardcover clients plus a JSON cache (`cache.ts`); `loadSources` honours `SOURCES_FIXTURES` |
|
||||
| `src/site/` | The Astro site: `pages/` (gate, `work/`, `off-the-clock/` with `issues/`, `everything`, `simple`, 404), `components/` (Astro parts, React islands, `showcases/`), `layouts/` (`Base`, `Work`, `Personal`), `styles/` (per-space CSS and tokens), `scripts/` (`preference.ts`), `data.ts` (`getSiteData`: content plus cached sources, never fetches) |
|
||||
|
||||
@@ -7,7 +7,7 @@ years: 2025
|
||||
stack: [React]
|
||||
summary: A fixture dashboard.
|
||||
featured: 2
|
||||
showcase: dashboard
|
||||
showcase: config-editor
|
||||
ai: some-ai
|
||||
---
|
||||
Body of the fixture dashboard.
|
||||
@@ -7,7 +7,7 @@ years: 2025
|
||||
stack: [React]
|
||||
summary: A fixture dashboard.
|
||||
featured: 2
|
||||
showcase: dashboard
|
||||
showcase: config-editor
|
||||
ai: some-ai
|
||||
---
|
||||
Body of the fixture dashboard.
|
||||
@@ -0,0 +1,3 @@
|
||||
// Which media files are pictures and which are sound, by extension. Shared by the validator and the pages.
|
||||
export const IMAGE_EXT = /\.(png|jpe?g|gif|webp|avif|svg)$/i;
|
||||
export const AUDIO_EXT = /\.(mp3|ogg|oga|opus|wav|m4a|flac)$/i;
|
||||
@@ -0,0 +1,5 @@
|
||||
// The drawn showcase panels a project's `showcase` may name. The validator checks against this list;
|
||||
// src/site/components/showcases/index.ts maps each id to its component.
|
||||
export const SHOWCASE_IDS = ['compute-queue', 'config-editor', 'peer-network', 'rowing-stroke'] as const;
|
||||
export type ShowcaseId = (typeof SHOWCASE_IDS)[number];
|
||||
export const isShowcaseId = (x: unknown): x is ShowcaseId => typeof x === 'string' && (SHOWCASE_IDS as readonly string[]).includes(x);
|
||||
+4
-4
@@ -3,6 +3,8 @@ import { readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { marked } from 'marked';
|
||||
import { isAiLevel, type AiLevel } from '../core/ai.ts';
|
||||
import { IMAGE_EXT } from '../core/media.ts';
|
||||
import { isShowcaseId, SHOWCASE_IDS } from '../core/showcases.ts';
|
||||
import type { ValidationError } from './errors.ts';
|
||||
import { splitFrontmatter } from './frontmatter.ts';
|
||||
import type {
|
||||
@@ -228,8 +230,6 @@ async function readDocs(
|
||||
return docs;
|
||||
}
|
||||
|
||||
const IMAGE_EXT = /\.(png|jpe?g|gif|webp|avif|svg)$/i;
|
||||
|
||||
function imageField(v: unknown, mediaDir: string, report: Report): Project['image'] {
|
||||
if (v === undefined) return undefined;
|
||||
if (!isObj(v)) {
|
||||
@@ -277,8 +277,8 @@ function loadProject(doc: Doc, mediaDir: string, report: Report): Project {
|
||||
} else featured = f.featured;
|
||||
}
|
||||
const showcase = f.showcase === undefined ? undefined : str(f.showcase);
|
||||
if (f.showcase !== undefined && !showcase) report('showcase', 'must be a component id');
|
||||
if (featured !== undefined && !showcase) report('showcase', 'required when featured');
|
||||
if (f.showcase !== undefined && !isShowcaseId(showcase)) report('showcase', `must be one of ${SHOWCASE_IDS.join(', ')}`);
|
||||
if (featured !== undefined && !showcase && f.image === undefined) report('showcase', 'required when featured (or give the project an image)');
|
||||
if (f.related !== undefined && !(Array.isArray(f.related) && f.related.every((r) => typeof r === 'string'))) {
|
||||
report('related', 'must be a list of project ids');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// View model for the personal wall: items from the content repo and Ghost posts tagged
|
||||
// `off-the-clock`, normalised into one Entry shape so every card and page reads the same fields.
|
||||
import type { AiLevel } from '../../../core/ai.ts';
|
||||
import { AUDIO_EXT, IMAGE_EXT } from '../../../core/media.ts';
|
||||
import type { Interest, Item, ItemKind, Media } from '../../../library/types.ts';
|
||||
import type { Post } from '../../../sources/types.ts';
|
||||
|
||||
@@ -46,8 +47,8 @@ export function toEntries(personal: Array<Item | Post>, interests: Interest[]):
|
||||
}
|
||||
|
||||
/* ---------- media ---------- */
|
||||
export const isImage = (m: Media) => !!m.file && /\.(svg|png|jpe?g|webp|gif|avif)$/i.test(m.file);
|
||||
export const isAudio = (m: Media) => !!m.file && /\.(mp3|ogg|m4a)$/i.test(m.file);
|
||||
export const isImage = (m: Media) => !!m.file && IMAGE_EXT.test(m.file);
|
||||
export const isAudio = (m: Media) => !!m.file && AUDIO_EXT.test(m.file);
|
||||
export const images = (e: Entry) => e.media.filter(isImage);
|
||||
export const audioOf = (e: Entry) => e.media.find(isAudio);
|
||||
export const mediaUrl = (m: Media) => `/media/${m.file}`;
|
||||
|
||||
@@ -5,10 +5,12 @@ import ComputeQueue from './ComputeQueue.tsx';
|
||||
import ConfigEditor from './ConfigEditor.tsx';
|
||||
import PeerNetwork from './PeerNetwork.tsx';
|
||||
import RowingStroke from './RowingStroke.tsx';
|
||||
import type { ShowcaseId } from '../../../core/showcases.ts';
|
||||
|
||||
export type ShowcaseProps = { interactive: boolean };
|
||||
|
||||
export const SHOWCASES: Record<string, ComponentType<ShowcaseProps>> = {
|
||||
// Keyed by every id in src/core/showcases.ts (the validator's list), so the two cannot drift.
|
||||
export const SHOWCASES: Record<ShowcaseId, ComponentType<ShowcaseProps>> = {
|
||||
'compute-queue': ComputeQueue,
|
||||
'config-editor': ConfigEditor,
|
||||
'peer-network': PeerNetwork,
|
||||
|
||||
@@ -6,6 +6,8 @@ import GateSide from '../components/gate/GateSide.astro';
|
||||
import GateFooter from '../components/gate/GateFooter.astro';
|
||||
import FilterNote from '../components/FilterNote.astro';
|
||||
import { getSiteData } from '../data';
|
||||
import { featuredOf } from '../components/work/sections.ts';
|
||||
import { AUDIO_EXT, IMAGE_EXT } from '../../core/media.ts';
|
||||
import type { Item } from '../../library/types';
|
||||
import type { Post } from '../../sources/types';
|
||||
import '../styles/gate.css';
|
||||
@@ -17,18 +19,13 @@ const byDateDesc = (a: { date: string }, b: { date: string }) => b.date.localeCo
|
||||
const newest = [...projects].sort(byDateDesc);
|
||||
const latestProject = newest[0];
|
||||
const latestPost = workPosts[0];
|
||||
const moreFeatured = projects
|
||||
.filter((p) => p.featured !== undefined && p !== latestProject)
|
||||
.sort((a, b) => a.featured! - b.featured!)
|
||||
.slice(0, 3);
|
||||
const moreFeatured = featuredOf(projects).filter((p) => p !== latestProject).slice(0, 3);
|
||||
|
||||
// ---- personal side
|
||||
const isPost = (e: Item | Post): e is Post => 'slug' in e;
|
||||
const urlOf = (e: Item | Post) => `/off-the-clock/${isPost(e) ? e.slug : e.id}/`;
|
||||
const AUDIO = /\.(mp3|ogg|oga|opus|wav|m4a|flac)$/i;
|
||||
const IMAGE = /\.(avif|webp|png|jpe?g|gif|svg)$/i;
|
||||
const imageOf = (e: Item | Post) => (isPost(e) ? undefined : e.media.find((m) => m.file && IMAGE.test(m.file)));
|
||||
const audioOf = (e: Item) => e.media.find((m) => m.file && AUDIO.test(m.file));
|
||||
const imageOf = (e: Item | Post) => (isPost(e) ? undefined : e.media.find((m) => m.file && IMAGE_EXT.test(m.file)));
|
||||
const audioOf = (e: Item) => e.media.find((m) => m.file && AUDIO_EXT.test(m.file));
|
||||
|
||||
const items = personal.filter((e): e is Item => !isPost(e));
|
||||
const song = items.find((i) => i.kind === 'song');
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { AUDIO_EXT, IMAGE_EXT } from '../../src/core/media.ts';
|
||||
import { SHOWCASE_IDS } from '../../src/core/showcases.ts';
|
||||
|
||||
test('media extensions', () => {
|
||||
for (const f of ['a.png', 'a.JPG', 'a.jpeg', 'a.gif', 'a.webp', 'a.avif', 'a.svg']) expect(IMAGE_EXT.test(f)).toBe(true);
|
||||
for (const f of ['a.mp3', 'a.ogg', 'a.oga', 'a.opus', 'a.wav', 'a.m4a', 'a.FLAC']) expect(AUDIO_EXT.test(f)).toBe(true);
|
||||
expect(IMAGE_EXT.test('a.pdf')).toBe(false);
|
||||
expect(AUDIO_EXT.test('a.mp4')).toBe(false);
|
||||
expect(IMAGE_EXT.test('png')).toBe(false);
|
||||
});
|
||||
|
||||
test('showcase ids', () => {
|
||||
expect([...SHOWCASE_IDS]).toEqual(['compute-queue', 'config-editor', 'peer-network', 'rowing-stroke']);
|
||||
});
|
||||
@@ -174,6 +174,18 @@ describe('loadContent', () => {
|
||||
expect(bad.errors).toContainEqual(expect.objectContaining({ file: 'site.yml', field: 'personal_intro' }));
|
||||
});
|
||||
|
||||
test('showcase ids are checked; featured needs a showcase or an image', async () => {
|
||||
const { errors } = await load({
|
||||
'projects/unknown.md': project({ showcase: 'dashboard' }),
|
||||
'projects/bare.md': project({ featured: 1 }),
|
||||
'projects/known.md': project({ featured: 2, showcase: 'config-editor' }),
|
||||
'projects/pictured.md': project({ featured: 3, image: { file: 'shot.png', alt: 'Screenshot' } }),
|
||||
'media/shot.png': 'x',
|
||||
});
|
||||
expect(errors.map((e) => `${e.file}:${e.field}`).sort()).toEqual(['projects/bare.md:showcase', 'projects/unknown.md:showcase']);
|
||||
expect(errors.find((e) => e.file === 'projects/unknown.md')!.message).toContain('compute-queue, config-editor, peer-network, rowing-stroke');
|
||||
});
|
||||
|
||||
test('rejects an item id reserved for the issues page', async () => {
|
||||
const { content, errors } = await load({ 'items/issues.md': item() });
|
||||
expect(content).toBeNull();
|
||||
@@ -188,7 +200,7 @@ describe('loadContent', () => {
|
||||
const { content } = await load({
|
||||
'projects/old.md': project({ date: '2020-01-01' }),
|
||||
'projects/new.md': project({ date: '2024-01-01' }),
|
||||
'projects/feat.md': project({ date: '2019-01-01', featured: 1, showcase: 'x' }),
|
||||
'projects/feat.md': project({ date: '2019-01-01', featured: 1, showcase: 'compute-queue' }),
|
||||
}).then((r) => r);
|
||||
expect(content!.projects.map((p) => p.id)).toEqual(['feat', 'new', 'old']);
|
||||
const r = await load({ 'items/m.md': item({}, '**bold** and a table\n\n| a |\n|---|\n| b |') });
|
||||
|
||||
@@ -63,10 +63,10 @@ describe('work space', () => {
|
||||
|
||||
test('a project without a showcase gets the generic frame', () => {
|
||||
expect(page(out, 'work/placeholder-notes/index.html')).toContain('data-showcase="generic"');
|
||||
// An unknown showcase id falls back to the frame too; a known one renders its drawing.
|
||||
// A known showcase id renders its drawing (unknown ids are a validation error).
|
||||
const html = page(out, 'work/index.html');
|
||||
const dash = html.slice(html.indexOf('data-id="fixture-dashboard"'));
|
||||
expect(dash.slice(0, dash.indexOf('</article>'))).toContain('data-showcase="generic"');
|
||||
expect(dash.slice(0, dash.indexOf('</article>'))).toContain('data-showcase="config-editor"');
|
||||
expect(html).toContain('data-showcase="compute-queue"');
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user