Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions STANDARD-SITE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<contentType>/<slug>/` 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
Expand Down
2 changes: 1 addition & 1 deletion build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ async function build(): Promise<void> {
// 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) {
Expand Down
84 changes: 79 additions & 5 deletions standard-site/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -98,6 +113,59 @@ async function loadIconBytes(source: string): Promise<Uint8Array> {
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<BlobRef | undefined> {
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();
Expand Down Expand Up @@ -208,7 +276,7 @@ export async function publishStandardSite(sources: DocumentSource[]): Promise<vo

// Flatten all non-hidden entries across sources. rkey is the slug; blog and
// material slugs do not overlap, but guard against a collision to be safe.
const documents: { entry: EntryBase; contentType: string }[] = [];
const documents: { entry: EntryBase; source: DocumentSource }[] = [];
const seen = new Set<string>();
for (const source of sources) {
for (const entry of source.entries) {
Expand All @@ -219,7 +287,7 @@ export async function publishStandardSite(sources: DocumentSource[]): Promise<vo
throw new Error(`standard.site: duplicate document rkey "${entry.slug}" across content types`);
}
seen.add(entry.slug);
documents.push({ entry, contentType: source.contentType });
documents.push({ entry, source });
}
}

Expand All @@ -234,14 +302,20 @@ export async function publishStandardSite(sources: DocumentSource[]): Promise<vo

const publicationUri = await upsertPublication(config, session);

for (const { entry, contentType } of documents) {
for (const { entry, source } of documents) {
const record = buildDocumentRecord(entry, source.contentType, publicationUri);
const cover = await loadCoverBlob(config, session, source, entry);
if (cover) {
record.coverImage = cover;
}

if (config.dryRun) {
console.log(` [dry-run] would write document ${entry.slug} (${contentType})`);
console.log(` [dry-run] would write document ${entry.slug} (${source.contentType})`);
} else {
await putRecord(config.pds, session, {
collection: DOCUMENT_COLLECTION,
rkey: entry.slug,
record: buildDocumentRecord(entry, contentType, publicationUri),
record,
});
}
}
Expand Down
Loading