/** * Journal & Storytelling Types, Parser and Serializer. * Client-safe pure TypeScript module (no Node.js fs dependencies). */ export interface JournalFrontmatter { title?: string; subtitle?: string; /** SEO description. Content pages use it (#722); journal entries ignore it. */ description?: string; date?: string; author?: string; coverAssetId?: string; layout?: string; password?: string; draft?: boolean; } export type JournalBlock = | { type: 'heading'; level: number; text: string } | { type: 'paragraph'; html: string } | { type: 'quote'; text: string; author?: string } | { type: 'photo'; assetId: string; caption?: string; layout: 'fullbleed' | 'wide' | 'contained' } | { type: 'photo-pair'; assetIds: [string, string]; caption?: string } | { type: 'photo-grid'; assetIds: string[]; caption?: string } | { type: 'facts'; items: Array<{ label: string; value: string }> } | { type: 'map'; caption?: string; line: boolean; items: MapItem[]; pins?: MapPin[] } | { /** * "x photos from an album". Expanded on the server into photo blocks * (lib/journalAlbum.ts) before the page renders, so the client never * sees this type; the studio expands it through the admin assets route. */ type: 'album'; albumId: string; count?: number; skip?: number; layout: AlbumBlockLayout; caption?: string; }; export type AlbumBlockLayout = 'grid' | 'pairs' | 'wide'; /** * What an author puts on a journal map, in the order the line is drawn. * A typed point is published as typed. A photo item is placed by its EXIF * position under the album's `location:` precision; `all-photos` expands to * every geotagged photo of the entry not already listed. */ export type MapItem = | { kind: 'point'; label?: string; lat: number; lng: number } | { kind: 'photo'; assetId: string } | { kind: 'all-photos' }; export function isValidCoordinate(lat: number, lng: number): boolean { return ( Number.isFinite(lat) && Number.isFinite(lng) && Math.abs(lat) <= 90 && Math.abs(lng) <= 180 ); } /** * A position on a journal map. Never authored and never serialized: the * server derives pins from the entry's own geotagged photos at render time * (lib/journalMap.ts), already quantised to the album's `location:` setting. */ export interface MapPin { lat: number; lng: number; label?: string; } export interface ParsedJournal { frontmatter: JournalFrontmatter; blocks: JournalBlock[]; referencedAssetIds: string[]; } /** * Every asset id a block refers to, in block order, without duplicates. * An empty id is an unfilled placeholder (the block editor's "+ Pick" tile), * not a reference — it is skipped so nothing probes or renders it. */ export function collectAssetIds(blocks: readonly JournalBlock[]): string[] { const ids = new Set(); for (const block of blocks) { if (block.type === 'photo') { if (block.assetId) ids.add(block.assetId); } else if (block.type === 'photo-pair' || block.type === 'photo-grid') { for (const id of block.assetIds) if (id) ids.add(id); } else if (block.type === 'map') { for (const item of block.items) if (item.kind === 'photo' && item.assetId) ids.add(item.assetId); } } return Array.from(ids); } /** * The same block with every asset id passed through `fn` — the one place that * knows which block types carry ids, so the page that swaps raw UUIDs for * tokens does not grow a branch per type. */ export function mapBlockAssetIds(block: JournalBlock, fn: (id: string) => string): JournalBlock { switch (block.type) { case 'photo': return { ...block, assetId: fn(block.assetId) }; case 'photo-pair': return { ...block, assetIds: [fn(block.assetIds[0]), fn(block.assetIds[1])] }; case 'photo-grid': return { ...block, assetIds: block.assetIds.map(fn) }; case 'map': return { ...block, items: block.items.map((item) => item.kind === 'photo' ? { ...item, assetId: fn(item.assetId) } : item, ), }; default: return block; } } export interface JournalEntrySummary { slug: string; filename: string; frontmatter: JournalFrontmatter; excerpt: string; wordCount: number; readingTimeMinutes: number; } const HTML_ESCAPES: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }; /** * Make an author's text inert as HTML. * * This escapes rather than filters. The previous implementation stripped * `