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
12 changes: 7 additions & 5 deletions STANDARD-SITE.md
Original file line number Diff line number Diff line change
@@ -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/<slug>`) and,
where the site has them, material chapters (`/material/<slug>`).

Records are written to a Bluesky/PDS account during `npm run build`
(`standard-site/publish.ts`). The consuming website only serves the verification
Expand Down
12 changes: 8 additions & 4 deletions build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,10 @@ async function buildBlog(): Promise<BlogEntryFull[]> {
return entryList;
}

async function buildMaterial(): Promise<void> {
async function buildMaterial(): Promise<MaterialEntry[]> {
if (!existsSync(MATERIAL_FOLDER)) {
console.log('No material folder found, skipping...');
return;
return [];
}

console.log('Building material...');
Expand All @@ -60,6 +60,7 @@ async function buildMaterial(): Promise<void> {
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<void> {
Expand All @@ -68,14 +69,17 @@ async function build(): Promise<void> {
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!');
}
Expand Down
57 changes: 43 additions & 14 deletions standard-site/publish.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -64,15 +74,16 @@ function toIsoDateTime(value: string): string {
}

function buildDocumentRecord(
entry: BlogEntryFull,
entry: EntryBase,
contentType: string,
publicationUri: string,
): Record<string, unknown> {
const description = stripHtmlTags(extractFirstBigParagraph(entry.html)).trim();
const record: Record<string, unknown> = {
$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),
};
Expand All @@ -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;
Expand Down Expand Up @@ -141,13 +154,30 @@ async function pruneDocuments(
return pruned;
}

export async function publishStandardSite(entries: BlogEntryFull[]): Promise<void> {
export async function publishStandardSite(sources: DocumentSource[]): Promise<void> {
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<string>();
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);

Expand All @@ -159,24 +189,23 @@ export async function publishStandardSite(entries: BlogEntryFull[]): Promise<voi

const publicationUri = await upsertPublication(config, session);

const visibleEntries = entries.filter((entry) => !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`,
);
}
Loading