diff --git a/STANDARD-SITE.md b/STANDARD-SITE.md index 0128543..c81a2b0 100644 --- a/STANDARD-SITE.md +++ b/STANDARD-SITE.md @@ -1,11 +1,13 @@ # standard.site publishing -This build publishes the blog to the [standard.site](https://standard.site) AT -Protocol lexicons so posts become discoverable on the decentralized social web: +This build publishes the site to the [standard.site](https://standard.site) AT +Protocol lexicons so content becomes discoverable on the decentralized social +web: -- one `site.standard.publication` record (rkey `self`) for the blog, and -- one `site.standard.document` record per non-hidden post (rkey = the post - slug). +- one `site.standard.publication` record (rkey `self`) for the site, and +- one `site.standard.document` record per non-hidden content entry (rkey = the + slug), across every content type present — blog posts (`/blog/`) and, + where the site has them, material chapters (`/material/`). Records are written to a Bluesky/PDS account during `npm run build` (`standard-site/publish.ts`). The consuming website only serves the verification diff --git a/build.ts b/build.ts index d1d0ff2..538eaa7 100644 --- a/build.ts +++ b/build.ts @@ -45,10 +45,10 @@ async function buildBlog(): Promise { return entryList; } -async function buildMaterial(): Promise { +async function buildMaterial(): Promise { if (!existsSync(MATERIAL_FOLDER)) { console.log('No material folder found, skipping...'); - return; + return []; } console.log('Building material...'); @@ -60,6 +60,7 @@ async function buildMaterial(): Promise { const materialListLight = makeLightList(materialList); await writeJson(path.join(materialDist, LIST_FILE), materialListLight); console.log(`Material: ${materialList.length} entries processed`); + return materialList; } async function build(): Promise { @@ -68,14 +69,17 @@ async function build(): Promise { await mkdirp(DIST_FOLDER); const blogEntries = await buildBlog(); - await buildMaterial(); + const materialEntries = await buildMaterial(); // Validate all anchor links (warnings only, does not fail build) console.log('\nValidating anchor links...'); printValidationResults(); // Publish standard.site records (no-op unless configured via env) - await publishStandardSite(blogEntries); + await publishStandardSite([ + { contentType: 'blog', entries: blogEntries }, + { contentType: 'material', entries: materialEntries }, + ]); console.log('\nBuild complete!'); } diff --git a/standard-site/publish.ts b/standard-site/publish.ts index c9af96b..f720a57 100644 --- a/standard-site/publish.ts +++ b/standard-site/publish.ts @@ -1,13 +1,16 @@ /** - * Publishes the blog to the standard.site AT Protocol lexicons: one + * Publishes a site to the standard.site AT Protocol lexicons: one * site.standard.publication record and one site.standard.document record per - * (non-hidden) blog post, written to the configured Bluesky/PDS account. + * (non-hidden) content entry, written to the configured Bluesky/PDS account. + * + * Content sources are generic (e.g. blog posts and material chapters); each is + * published under its own URL path prefix while sharing the document collection. * * Publication-agnostic: every account-specific value comes from the environment * (see readConfig), so the same shared build serves multiple websites. When the * required config is absent the whole step is a no-op, keeping it opt-in. */ -import { BlogEntryFull } from '../blog/blog.types'; +import { EntryBase } from '../shared/base.types'; import { extractFirstBigParagraph } from '../shared/list.utils'; import { stripHtmlTags } from '../shared/html.utils'; import { @@ -23,6 +26,13 @@ const PUBLICATION_COLLECTION = 'site.standard.publication'; const DOCUMENT_COLLECTION = 'site.standard.document'; const PUBLICATION_RKEY = 'self'; +/** 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[]; +} + interface StandardSiteConfig { pds: string; handle: string; @@ -64,7 +74,8 @@ function toIsoDateTime(value: string): string { } function buildDocumentRecord( - entry: BlogEntryFull, + entry: EntryBase, + contentType: string, publicationUri: string, ): Record { const description = stripHtmlTags(extractFirstBigParagraph(entry.html)).trim(); @@ -72,7 +83,7 @@ function buildDocumentRecord( $type: DOCUMENT_COLLECTION, site: publicationUri, title: entry.meta.title, - path: `/blog/${entry.slug}`, + path: `/${contentType}/${entry.slug}`, publishedAt: toIsoDateTime(entry.meta.published), textContent: stripHtmlTags(entry.html), }; @@ -83,8 +94,10 @@ function buildDocumentRecord( if (entry.meta.lastModified) { record.updatedAt = toIsoDateTime(entry.meta.lastModified); } - if (entry.meta.keywords?.length) { - record.tags = entry.meta.keywords; + // keywords are blog-specific; present them as tags when available + const keywords = (entry.meta as { keywords?: string[] }).keywords; + if (keywords?.length) { + record.tags = keywords; } return record; @@ -141,13 +154,30 @@ async function pruneDocuments( return pruned; } -export async function publishStandardSite(entries: BlogEntryFull[]): Promise { +export async function publishStandardSite(sources: DocumentSource[]): Promise { const config = readConfig(); if (!config) { console.log('standard.site: not configured (BSKY_APP_PASSWORD/BSKY_HANDLE/STANDARD_SITE_URL), skipping'); return; } + // 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 seen = new Set(); + for (const source of sources) { + for (const entry of source.entries) { + if (entry.meta.hidden) { + continue; + } + if (seen.has(entry.slug)) { + throw new Error(`standard.site: duplicate document rkey "${entry.slug}" across content types`); + } + seen.add(entry.slug); + documents.push({ entry, contentType: source.contentType }); + } + } + console.log(`standard.site: ${config.dryRun ? 'DRY RUN — ' : ''}publishing to ${config.url}`); const session = await createSession(config.pds, config.handle, config.password); @@ -159,24 +189,23 @@ export async function publishStandardSite(entries: BlogEntryFull[]): Promise !entry.meta.hidden); - for (const entry of visibleEntries) { + for (const { entry, contentType } of documents) { if (config.dryRun) { - console.log(` [dry-run] would write document ${entry.slug}`); + console.log(` [dry-run] would write document ${entry.slug} (${contentType})`); } else { await putRecord(config.pds, session, { collection: DOCUMENT_COLLECTION, rkey: entry.slug, - record: buildDocumentRecord(entry, publicationUri), + record: buildDocumentRecord(entry, contentType, publicationUri), }); } } - const liveSlugs = new Set(visibleEntries.map((entry) => entry.slug)); + const liveSlugs = new Set(documents.map((doc) => doc.entry.slug)); const pruned = await pruneDocuments(config, session, liveSlugs); const verb = config.dryRun ? 'would publish' : 'published'; console.log( - `standard.site: ${verb} 1 publication + ${visibleEntries.length} documents, ${config.dryRun ? 'would prune' : 'pruned'} ${pruned} stale`, + `standard.site: ${verb} 1 publication + ${documents.length} documents, ${config.dryRun ? 'would prune' : 'pruned'} ${pruned} stale`, ); }