diff --git a/STANDARD-SITE.md b/STANDARD-SITE.md index e8d915f..1cf4acb 100644 --- a/STANDARD-SITE.md +++ b/STANDARD-SITE.md @@ -72,6 +72,10 @@ article-data build runs so the URL resolves. place — never duplicates. - Prunes: document records whose slug is no longer a live (non-hidden) post are deleted. +- Cover images: an entry's optimized header image is uploaded as the document + `coverImage` blob (from the built `dist///` folder). Sources + without headers (e.g. material chapters) simply omit it. A missing, oversized + (>1MB), or unsupported header is non-fatal (warns and continues). - `validate: false` is used on `putRecord` because a PDS does not natively know third-party lexicons like `site.standard.*`. - Resilient: transient PDS responses (429, 5xx) are retried with backoff, and a diff --git a/build.ts b/build.ts index 6930e22..134092a 100644 --- a/build.ts +++ b/build.ts @@ -80,7 +80,7 @@ async function build(): Promise { // build/deploy, so it is caught and logged rather than thrown. try { await publishStandardSite([ - { contentType: 'blog', entries: blogEntries }, + { contentType: 'blog', entries: blogEntries, distDir: path.join(DIST_FOLDER, 'blog') }, { contentType: 'material', entries: materialEntries }, ]); } catch (error) { diff --git a/standard-site/publish.ts b/standard-site/publish.ts index 565a479..b98ce00 100644 --- a/standard-site/publish.ts +++ b/standard-site/publish.ts @@ -11,12 +11,14 @@ * required config is absent the whole step is a no-op, keeping it opt-in. */ import { readFile } from 'fs/promises'; +import * as path from 'path'; import { EntryBase } from '../shared/base.types'; import { extractFirstBigParagraph } from '../shared/list.utils'; import { stripHtmlTags } from '../shared/html.utils'; import { AtpSession, + BlobRef, createSession, deleteRecord, listRecords, @@ -29,11 +31,24 @@ const PUBLICATION_COLLECTION = 'site.standard.publication'; const DOCUMENT_COLLECTION = 'site.standard.document'; const PUBLICATION_RKEY = 'self'; +/** + * standard.site recommends the document coverImage blob stays below 1MB. The + * optimized WebP headers are well under this; guard so an outlier is skipped + * rather than rejected by the PDS. + */ +const MAX_COVER_BYTES = 1_000_000; + /** A group of content entries published under a common URL path prefix. */ export interface DocumentSource { /** URL path segment, e.g. 'blog' or 'material'. */ contentType: string; entries: EntryBase[]; + /** + * Directory holding the built per-slug folders (e.g. dist/blog), used to + * locate an entry's optimized header image for the coverImage blob. Omit + * when a source has no header images (e.g. material chapters). + */ + distDir?: string; } interface StandardSiteConfig { @@ -98,6 +113,59 @@ async function loadIconBytes(source: string): Promise { return new Uint8Array(await readFile(source)); } +/** Image MIME type for a header file, or null for an unsupported extension. */ +function coverMimeType(filename: string): string | null { + switch (filename.split('.').pop()?.toLowerCase()) { + case 'png': return 'image/png'; + case 'jpg': + case 'jpeg': return 'image/jpeg'; + case 'webp': return 'image/webp'; + case 'gif': return 'image/gif'; + default: return null; // e.g. video headers (.webm) — no cover image + } +} + +/** + * Upload an entry's optimized header image as the document coverImage blob. + * Returns the blob ref, or undefined when there is no usable header. Non-fatal: + * a missing/oversized/broken image is logged and skipped, never thrown. + */ +async function loadCoverBlob( + config: StandardSiteConfig, + session: AtpSession, + source: DocumentSource, + entry: EntryBase, +): Promise { + const header = (entry.meta as { header?: { url?: string } }).header; + if (!source.distDir || !header?.url) { + return undefined; + } + const mimeType = coverMimeType(header.url); + if (!mimeType) { + return undefined; + } + + const filePath = path.join(source.distDir, entry.slug, header.url); + try { + const bytes = new Uint8Array(await readFile(filePath)); + if (bytes.byteLength > MAX_COVER_BYTES) { + console.warn(`standard.site: skipping oversized cover for ${entry.slug} (${bytes.byteLength} bytes)`); + return undefined; + } + if (config.dryRun) { + console.log(` [dry-run] would upload cover image for ${entry.slug} (${header.url})`); + return undefined; + } + return await uploadBlob(config.pds, session, bytes, mimeType); + } catch (error) { + console.warn( + `standard.site: skipping cover for ${entry.slug} (${filePath}):`, + error instanceof Error ? error.message : error, + ); + return undefined; + } +} + /** Normalise a YAML date (ISO string or date-only) to an ISO 8601 datetime. */ function toIsoDateTime(value: string): string { return new Date(value).toISOString(); @@ -208,7 +276,7 @@ export async function publishStandardSite(sources: DocumentSource[]): Promise(); for (const source of sources) { for (const entry of source.entries) { @@ -219,7 +287,7 @@ export async function publishStandardSite(sources: DocumentSource[]): Promise