diff --git a/.github/workflows/bluesky.yml b/.github/workflows/bluesky.yml index e1828627..ddc054f1 100644 --- a/.github/workflows/bluesky.yml +++ b/.github/workflows/bluesky.yml @@ -85,15 +85,41 @@ jobs: BLUESKY_APP_PASSWORD: ${{ secrets.BLUESKY_APP_PASSWORD }} run: ./target/release/server publish-bluesky --dir notes - - name: Commit bsky_url updates + - name: Init standard.site publication + # No-op when `publications.toml` already has `at_uri` + `at_cid` + # cached (see `init_publication` in `standard_site.rs`). First run + # after merge creates the publication record on the PDS and writes + # the URI/CID back; later runs short-circuit without touching the + # network. Failure here is fatal — sync depends on it. + # + # `APP_BASE_URL` + `IMGPROXY_URL` are required so init can fetch the + # publication's branded OG card (rasterized PNG) and upload it as the + # publication cover blob. This depends on the Fly Deploy that + # preceded this workflow having the `/og/publication/{key}.svg` + # route live — the `workflow_run` chain enforces that ordering. + id: init_standard + env: + BLUESKY_IDENTIFIER: ${{ secrets.BLUESKY_IDENTIFIER }} + BLUESKY_APP_PASSWORD: ${{ secrets.BLUESKY_APP_PASSWORD }} + IMGPROXY_URL: ${{ secrets.IMGPROXY_URL }} + run: ./target/release/server publish-standard-site init blog + + - name: Sync standard.site + id: sync_standard + env: + BLUESKY_IDENTIFIER: ${{ secrets.BLUESKY_IDENTIFIER }} + BLUESKY_APP_PASSWORD: ${{ secrets.BLUESKY_APP_PASSWORD }} + run: ./target/release/server publish-standard-site sync --key blog + + - name: Commit syndication updates run: | git config user.name "${{ steps.app-token.outputs.app-slug }}[bot]" git config user.email "${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com" - git add notes/ + git add notes/ blog/ publications.toml if git diff --staged --quiet; then echo "No changes to commit" else - git commit -m "Add bsky_url to published notes" + git commit -m "Sync syndication state" # Rebase onto whatever main looks like *now* — covers the case # where main moved during this workflow run. git pull --rebase origin main @@ -103,7 +129,7 @@ jobs: - name: Surface publish failures # Marks the run red so failures are visible. Runs last so partial # successes still got committed/pushed/deployed via the steps above. - if: steps.publish.outcome == 'failure' + if: steps.publish.outcome == 'failure' || steps.sync_standard.outcome == 'failure' run: | - echo "::error::One or more notes failed to publish. See the 'Publish to Bluesky' step. Next run will retry." + echo "::error::One or more publishes failed. See logs. Next run will retry." exit 1 diff --git a/.github/workflows/fly_review.yml b/.github/workflows/fly_review.yml index 4ed8e41e..7417e0ea 100644 --- a/.github/workflows/fly_review.yml +++ b/.github/workflows/fly_review.yml @@ -63,5 +63,5 @@ jobs: uses: superfly/fly-pr-review-apps@1.2.1 with: name: coreyja-com-pr-${{ github.event.number }} - secrets: APP_BASE_URL="https://coreyja-com-pr-${{ github.event.number }}.fly.dev" DATABASE_URL="${{ steps.create-branch.outputs.db_url }}?sslmode=require" TWITCH_CLIENT_ID="FAKE" TWITCH_CLIENT_SECRET="FAKE" TWITCH_BOT_ACCESS_TOKEN="FAKE" TWITCH_BOT_USER_ID="FAKE" TWITCH_CHANNEL_USER_ID="FAKE" GITHUB_APP_ID="123" GITHUB_APP_CLIENT_ID="FAKE" GITHUB_APP_CLIENT_SECRET="FAKE" GITHUB_APP_PRIVATE_KEY="FAKE" GITHUB_PERSONAL_ACCESS_TOKEN="FAKE" OPEN_AI_API_KEY="FAKE" GOOGLE_CLIENT_ID="FAKE" GOOGLE_CLIENT_SECRET="FAKE" ENCRYPTION_SECRET_KEY="FAKE" JOBS_DISABLED="true" CRON_DISABLED="true" DISCORD_TOKEN="FAKE" DISCORD_BOT_DISABLED="true" + secrets: APP_BASE_URL="https://coreyja-com-pr-${{ github.event.number }}.fly.dev" DATABASE_URL="${{ steps.create-branch.outputs.db_url }}?sslmode=require" TWITCH_CLIENT_ID="FAKE" TWITCH_CLIENT_SECRET="FAKE" TWITCH_BOT_ACCESS_TOKEN="FAKE" TWITCH_BOT_USER_ID="FAKE" TWITCH_CHANNEL_USER_ID="FAKE" GITHUB_APP_ID="123" GITHUB_APP_CLIENT_ID="FAKE" GITHUB_APP_CLIENT_SECRET="FAKE" GITHUB_APP_PRIVATE_KEY="FAKE" GITHUB_PERSONAL_ACCESS_TOKEN="FAKE" OPEN_AI_API_KEY="FAKE" GOOGLE_CLIENT_ID="FAKE" GOOGLE_CLIENT_SECRET="FAKE" ENCRYPTION_SECRET_KEY="FAKE" JOBS_DISABLED="true" CRON_DISABLED="true" DISCORD_TOKEN="FAKE" DISCORD_BOT_DISABLED="true" ANTHROPIC_API_KEY="FAKE" LINEAR_CLIENT_ID="FAKE" LINEAR_CLIENT_SECRET="FAKE" LINEAR_WEBHOOK_SECRET="FAKE" memory: 512 diff --git a/Cargo.lock b/Cargo.lock index 2eba5654..58ef3a5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1568,7 +1568,7 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2" dependencies = [ - "toml", + "toml 0.5.11", ] [[package]] @@ -1893,7 +1893,7 @@ dependencies = [ "gix-utils", "itoa", "thiserror 1.0.69", - "winnow", + "winnow 0.6.26", ] [[package]] @@ -1946,7 +1946,7 @@ dependencies = [ "smallvec", "thiserror 1.0.69", "unicode-bom", - "winnow", + "winnow 0.6.26", ] [[package]] @@ -2131,7 +2131,7 @@ dependencies = [ "itoa", "smallvec", "thiserror 1.0.69", - "winnow", + "winnow 0.6.26", ] [[package]] @@ -2216,7 +2216,7 @@ dependencies = [ "gix-validate 0.8.5", "memmap2", "thiserror 1.0.69", - "winnow", + "winnow 0.6.26", ] [[package]] @@ -5248,6 +5248,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_tokenstream" version = "0.2.2" @@ -5418,6 +5427,7 @@ dependencies = [ "tempfile", "thiserror 1.0.69", "tokio", + "toml 0.8.23", "tower 0.4.13", "tower-cookies", "tower-http 0.5.2", @@ -6174,6 +6184,47 @@ dependencies = [ "serde", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.10.0", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tonic" version = "0.11.0" @@ -7308,6 +7359,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.50.0" diff --git a/Cargo.toml b/Cargo.toml index b583506f..618231a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -112,6 +112,7 @@ tower = "0.4.13" tower-cookies = "0.11.0" tower-http = "0.5.0" typify = "0.0.14" +toml = "0.8" url = "2.5.0" urlencoding = "2.1" vergen = "8.0.0" diff --git a/blog/look-ma-no-ai/index.md b/blog/look-ma-no-ai/index.md index 258a2d5d..c9ae8b65 100644 --- a/blog/look-ma-no-ai/index.md +++ b/blog/look-ma-no-ai/index.md @@ -1,5 +1,6 @@ --- title: Look Ma’ no AI +subtitle: An apology, and a recommitment to writing the words myself. author: Corey Alexander date: 2026-05-27 --- diff --git a/posts/src/blog.rs b/posts/src/blog.rs index a84efaa7..02be971b 100644 --- a/posts/src/blog.rs +++ b/posts/src/blog.rs @@ -236,9 +236,25 @@ pub struct BlogFrontMatter { pub buttondown_id: Option, /// Absolute URL of an OG image to use instead of the auto-generated branded card. pub og_image: Option, + /// Optional short subtitle/tagline shown on the OG card in place of the + /// post date. When unset, the OG card renders the post date. + pub subtitle: Option, #[serde(default)] pub tags: Vec, pub author: Option, + /// AT URI of the `site.standard.document` record on the PDS, set after + /// the first successful sync. Acts as the idempotency key — its presence + /// means the document record has been created, so subsequent syncs use + /// `putRecord` against the same rkey rather than creating a new record. + pub atproto_uri: Option, + /// Publication this post belongs to. Defaults to `"blog"` and corresponds + /// to an entry in `publications.toml`. + #[serde(default = "default_publication")] + pub publication: String, +} + +fn default_publication() -> String { + "blog".to_string() } impl PostedOn for BlogFrontMatter { @@ -321,8 +337,11 @@ mod test { newsletter_send_at: None, buttondown_id: None, og_image: None, + subtitle: None, tags: vec![], author: None, + atproto_uri: None, + publication: "blog".to_string(), }; let post = BlogPost { path, @@ -357,8 +376,11 @@ mod test { newsletter_send_at: None, buttondown_id: None, og_image: None, + subtitle: None, tags: vec![], author: None, + atproto_uri: None, + publication: "blog".to_string(), }, } } @@ -380,4 +402,12 @@ mod test { let post = test_post("weekly/20230713/index.md", false); assert_eq!(post.og_slug(), "weekly/20230713"); } + + #[test] + fn frontmatter_defaults_apply_when_fields_absent() { + let yaml = "title: T\ndate: 2026-05-01"; + let fm: BlogFrontMatter = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(fm.publication, "blog"); + assert!(fm.atproto_uri.is_none()); + } } diff --git a/publications.toml b/publications.toml new file mode 100644 index 00000000..da80d155 --- /dev/null +++ b/publications.toml @@ -0,0 +1,7 @@ +[[publication]] +key = "blog" +title = "coreyja.com" +description = "Personal blog: Rust, side projects, Battlesnake, AI agents." +url = "https://coreyja.com/posts" +content_dir = "blog" +collection = "site.standard.document" diff --git a/server/Cargo.toml b/server/Cargo.toml index 521cc1f7..79f2df43 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -54,6 +54,7 @@ arborium = { workspace = true } html-escape = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } +toml = { workspace = true } tower = { workspace = true, features = ["util"] } tower-http = { workspace = true, features = ["trace", "cors"] } tracing = { workspace = true } diff --git a/server/src/bluesky.rs b/server/src/bluesky.rs index f135095e..4fdf30ad 100644 --- a/server/src/bluesky.rs +++ b/server/src/bluesky.rs @@ -80,10 +80,21 @@ struct CreateSessionRequest { #[derive(Serialize, Debug)] #[serde(rename_all = "camelCase")] -struct CreateRecordRequest { +struct CreateRecordRequest { repo: String, collection: String, - record: PostRecord, + #[serde(skip_serializing_if = "Option::is_none")] + rkey: Option, + record: R, +} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "camelCase")] +struct PutRecordRequest { + repo: String, + collection: String, + rkey: String, + record: R, } #[derive(Serialize, Debug)] @@ -99,6 +110,80 @@ struct PostRecord { embed: Option, } +/// `site.standard.publication` record. One per publication (the blog, the +/// podcast, etc.). Created once by `publish-standard-site init `. +#[derive(Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct PublicationRecord { + #[serde(rename = "$type")] + pub record_type: String, + pub title: String, + pub description: String, + pub url: String, + pub created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub cover: Option, +} + +/// `site.standard.document` record. One per blog post. rkey == post slug so +/// re-syncing the same post `putRecord`s the existing record idempotently. +#[derive(Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct DocumentRecord { + #[serde(rename = "$type")] + pub record_type: String, + pub publication: StrongRef, + pub title: String, + pub description: String, + pub url: String, + pub published_at: String, + pub updated_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub cover: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, +} + +/// AT Protocol strong reference — pins both URI and content hash so callers +/// can detect when a referenced record has changed. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct StrongRef { + pub uri: String, + pub cid: String, +} + +/// Blob record returned from `uploadBlob`. The `$type` is always `"blob"`. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Blob { + #[serde(rename = "$type")] + pub r#type: String, + #[serde(rename = "ref")] + pub r#ref: BlobRef, + #[serde(rename = "mimeType")] + pub mime_type: String, + pub size: u64, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct BlobRef { + #[serde(rename = "$link")] + pub link: String, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct WriteRecordResponse { + pub uri: String, + pub cid: String, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct UploadBlobResponse { + pub blob: Blob, +} + #[derive(Serialize, Debug)] #[serde(rename_all = "camelCase")] struct Facet { @@ -135,12 +220,19 @@ struct ExternalEmbed { uri: String, title: String, description: String, + /// `site.standard.*` strong references attached to the embed. Bluesky's + /// `AppView` reads these and renders an enhanced link card sourced from + /// the referenced `site.standard.document` / `site.standard.publication` + /// records instead of scraping OG tags from the URL. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + associated_refs: Vec, } #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct CreateRecordResponse { pub uri: String, + pub cid: String, } impl BlueskyClient { @@ -205,6 +297,7 @@ impl BlueskyClient { let record = CreateRecordRequest { repo: self.session.did.clone(), collection: "app.bsky.feed.post".to_string(), + rkey: None, record: PostRecord { record_type: "app.bsky.feed.post".to_string(), text, @@ -220,6 +313,7 @@ impl BlueskyClient { uri: note_url.to_string(), title: title.to_string(), description: String::new(), + associated_refs: Vec::new(), }, }), }, @@ -249,6 +343,221 @@ impl BlueskyClient { let response: CreateRecordResponse = resp.json().await?; Ok(response) } + + /// DID of the authenticated account. Used as the `repo` field when + /// composing `createRecord` / `putRecord` calls outside this module. + pub fn did(&self) -> &str { + &self.session.did + } + + /// Generic `com.atproto.repo.createRecord` call. Server assigns the rkey + /// (a TID timestamp) unless one is supplied. + async fn create_record( + &self, + collection: &str, + rkey: Option<&str>, + record: &R, + ) -> cja::Result { + let req = CreateRecordRequest { + repo: self.session.did.clone(), + collection: collection.to_string(), + rkey: rkey.map(str::to_string), + record, + }; + + let resp = self + .client + .post(format!( + "{}/xrpc/com.atproto.repo.createRecord", + self.pds_url + )) + .bearer_auth(&self.session.access_jwt) + .json(&req) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(cja::color_eyre::eyre::eyre!( + "Failed to createRecord in {} ({}): {}", + collection, + status, + body + )); + } + + let response: WriteRecordResponse = resp.json().await?; + Ok(response) + } + + /// Generic `com.atproto.repo.putRecord` call. Creates the record if it + /// doesn't exist at the given rkey; otherwise overwrites it. + async fn put_record( + &self, + collection: &str, + rkey: &str, + record: &R, + ) -> cja::Result { + let req = PutRecordRequest { + repo: self.session.did.clone(), + collection: collection.to_string(), + rkey: rkey.to_string(), + record, + }; + + let resp = self + .client + .post(format!("{}/xrpc/com.atproto.repo.putRecord", self.pds_url)) + .bearer_auth(&self.session.access_jwt) + .json(&req) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(cja::color_eyre::eyre::eyre!( + "Failed to putRecord in {} at rkey {} ({}): {}", + collection, + rkey, + status, + body + )); + } + + let response: WriteRecordResponse = resp.json().await?; + Ok(response) + } + + /// Create a `site.standard.publication` record. Server assigns the rkey + /// (a TID timestamp). Use `put_publication` to refresh an existing one. + pub async fn create_publication( + &self, + record: PublicationRecord, + ) -> cja::Result { + self.create_record("site.standard.publication", None, &record) + .await + } + + /// Overwrite a `site.standard.publication` record at the given rkey. + pub async fn put_publication( + &self, + rkey: &str, + record: PublicationRecord, + ) -> cja::Result { + self.put_record("site.standard.publication", rkey, &record) + .await + } + + /// Create-or-update a `site.standard.document` record. The rkey is the + /// post slug so re-syncing a post is idempotent. + pub async fn put_document( + &self, + rkey: &str, + record: DocumentRecord, + ) -> cja::Result { + self.put_record("site.standard.document", rkey, &record) + .await + } + + /// Upload a raw blob (e.g. publication cover image). Returns the `Blob` + /// payload to be embedded into a record under `cover` or similar fields. + pub async fn upload_blob(&self, bytes: Vec, mime_type: &str) -> cja::Result { + let resp = self + .client + .post(format!("{}/xrpc/com.atproto.repo.uploadBlob", self.pds_url)) + .bearer_auth(&self.session.access_jwt) + .header("Content-Type", mime_type) + .body(bytes) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(cja::color_eyre::eyre::eyre!( + "Failed to uploadBlob ({}): {}", + status, + body + )); + } + + let response: UploadBlobResponse = resp.json().await?; + Ok(response.blob) + } + + /// Publish a blog post as an `app.bsky.feed.post` with an external embed. + /// + /// `associatedRefs` points at the post's `site.standard.document` and the + /// parent `site.standard.publication`. Body is just `"{title}\n\n{url}"` + /// since the enhanced card replaces the typical body text. + pub async fn create_blog_post( + &self, + title: &str, + post_url: &str, + description: &str, + associated_refs: Vec, + ) -> cja::Result { + let (text, facets) = compose_blog_post_text(title, post_url); + + let record = CreateRecordRequest { + repo: self.session.did.clone(), + collection: "app.bsky.feed.post".to_string(), + rkey: None, + record: PostRecord { + record_type: "app.bsky.feed.post".to_string(), + text, + created_at: chrono::Utc::now().to_rfc3339(), + facets: if facets.is_empty() { + None + } else { + Some(facets) + }, + embed: Some(EmbedExternal { + embed_type: "app.bsky.embed.external".to_string(), + external: ExternalEmbed { + uri: post_url.to_string(), + title: title.to_string(), + description: description.to_string(), + associated_refs, + }, + }), + }, + }; + + let resp = self + .client + .post(format!( + "{}/xrpc/com.atproto.repo.createRecord", + self.pds_url + )) + .bearer_auth(&self.session.access_jwt) + .json(&record) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(cja::color_eyre::eyre::eyre!( + "Failed to create blog post on Bluesky ({}): {}", + status, + body + )); + } + + let response: CreateRecordResponse = resp.json().await?; + Ok(response) + } +} + +/// Compose blog post body text + facets. Output is `"{title}\n\n{post_url}"` +/// — the `site.standard` card replaces what would normally be the body. +fn compose_blog_post_text(title: &str, post_url: &str) -> (String, Vec) { + let text = format!("{title}\n\n{post_url}"); + let facets = make_url_facet(&text, post_url).into_iter().collect(); + (text, facets) } /// Walk a markdown AST emitting Bluesky-flavored plain text and link facets. @@ -599,6 +908,7 @@ mod tests { let req = CreateRecordRequest { repo: "did:plc:test".to_string(), collection: "app.bsky.feed.post".to_string(), + rkey: None, record: PostRecord { record_type: "app.bsky.feed.post".to_string(), text: "test".to_string(), @@ -668,6 +978,7 @@ mod tests { uri: "https://coreyja.com/notes/test".to_string(), title: "Test Note".to_string(), description: String::new(), + associated_refs: Vec::new(), }, }; @@ -850,4 +1161,168 @@ mod tests { assert!(text.contains(&url)); assert!(!text.contains("Body that won't fit.")); } + + // ==================== standard.site record serialization ==================== + + #[test] + fn publication_record_serializes_with_dollar_type() { + let record = PublicationRecord { + record_type: "site.standard.publication".to_string(), + title: "coreyja".to_string(), + description: "Personal blog".to_string(), + url: "https://coreyja.com/posts".to_string(), + created_at: "2026-05-29T00:00:00Z".to_string(), + cover: None, + }; + + let json = serde_json::to_value(&record).unwrap(); + assert_eq!(json["$type"], "site.standard.publication"); + assert!( + json.get("record_type").is_none(), + "Should not have record_type key" + ); + assert!(json.get("cover").is_none(), "Should skip None cover"); + } + + #[test] + fn document_record_serializes_with_dollar_type() { + let record = DocumentRecord { + record_type: "site.standard.document".to_string(), + publication: StrongRef { + uri: "at://did:plc:abc/site.standard.publication/xyz".to_string(), + cid: "bafy123".to_string(), + }, + title: "A Post".to_string(), + description: "desc".to_string(), + url: "https://coreyja.com/posts/a-post/".to_string(), + published_at: "2026-05-01T00:00:00+00:00".to_string(), + updated_at: "2026-05-29T00:00:00+00:00".to_string(), + cover: None, + tags: vec!["rust".to_string()], + }; + + let json = serde_json::to_value(&record).unwrap(); + assert_eq!(json["$type"], "site.standard.document"); + assert!(json.get("record_type").is_none()); + } + + #[test] + fn document_record_skips_empty_tags() { + let record = DocumentRecord { + record_type: "site.standard.document".to_string(), + publication: StrongRef { + uri: "at://did:plc:abc/site.standard.publication/xyz".to_string(), + cid: "bafy123".to_string(), + }, + title: "A Post".to_string(), + description: "desc".to_string(), + url: "https://coreyja.com/posts/a-post/".to_string(), + published_at: "2026-05-01T00:00:00+00:00".to_string(), + updated_at: "2026-05-29T00:00:00+00:00".to_string(), + cover: None, + tags: vec![], + }; + + let json = serde_json::to_value(&record).unwrap(); + assert!(json.get("tags").is_none(), "empty tags should be omitted"); + } + + #[test] + fn strongref_serializes_with_camelcase_cid_and_uri() { + let r = StrongRef { + uri: "at://did:plc:abc/site.standard.document/post-1".to_string(), + cid: "bafyabc".to_string(), + }; + let json = serde_json::to_value(&r).unwrap(); + assert_eq!( + json["uri"], + "at://did:plc:abc/site.standard.document/post-1" + ); + assert_eq!(json["cid"], "bafyabc"); + + let back: StrongRef = serde_json::from_value(json).unwrap(); + assert_eq!(back.uri, r.uri); + assert_eq!(back.cid, r.cid); + } + + #[test] + fn external_embed_skips_associated_refs_when_empty() { + let embed = ExternalEmbed { + uri: "https://coreyja.com/posts/x/".to_string(), + title: "x".to_string(), + description: String::new(), + associated_refs: Vec::new(), + }; + let json = serde_json::to_value(&embed).unwrap(); + assert!( + json.get("associatedRefs").is_none(), + "empty associated_refs should omit field; got: {json}" + ); + } + + #[test] + fn external_embed_emits_associated_refs_when_present() { + let embed = ExternalEmbed { + uri: "https://coreyja.com/posts/x/".to_string(), + title: "x".to_string(), + description: String::new(), + associated_refs: vec![ + StrongRef { + uri: "at://did:plc:abc/site.standard.publication/pub".to_string(), + cid: "bafy1".to_string(), + }, + StrongRef { + uri: "at://did:plc:abc/site.standard.document/x".to_string(), + cid: "bafy2".to_string(), + }, + ], + }; + let json = serde_json::to_value(&embed).unwrap(); + let refs = json + .get("associatedRefs") + .expect("associatedRefs key present"); + assert_eq!(refs.as_array().unwrap().len(), 2); + } + + #[test] + fn compose_blog_post_text_attaches_url_facet() { + let (text, facets) = compose_blog_post_text("Hello", "https://coreyja.com/posts/hello/"); + assert_eq!(text, "Hello\n\nhttps://coreyja.com/posts/hello/"); + assert_eq!(facets.len(), 1); + let f = &facets[0]; + assert_eq!( + &text[f.index.byte_start..f.index.byte_end], + "https://coreyja.com/posts/hello/" + ); + } + + #[test] + fn compose_blog_post_text_unicode_title() { + let (text, facets) = + compose_blog_post_text("Rust 🦀 ftw", "https://coreyja.com/posts/rust/"); + assert_eq!(facets.len(), 1); + let f = &facets[0]; + // The byte range should round-trip cleanly to the URL even with multi-byte chars. + assert_eq!( + &text[f.index.byte_start..f.index.byte_end], + "https://coreyja.com/posts/rust/" + ); + } + + #[test] + fn upload_blob_response_deserializes_camel_case() { + let json = r#"{ + "blob": { + "$type": "blob", + "ref": { "$link": "bafy123" }, + "mimeType": "image/png", + "size": 1234 + } + }"#; + let resp: UploadBlobResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.blob.r#type, "blob"); + assert_eq!(resp.blob.r#ref.link, "bafy123"); + assert_eq!(resp.blob.mime_type, "image/png"); + assert_eq!(resp.blob.size, 1234); + } } diff --git a/server/src/commands/bluesky.rs b/server/src/commands/bluesky.rs index 2095caeb..190fa039 100644 --- a/server/src/commands/bluesky.rs +++ b/server/src/commands/bluesky.rs @@ -1,6 +1,7 @@ use std::path::{Path, PathBuf}; use crate::bluesky::{at_uri_to_web_url, BlueskyClient, BlueskyConfig}; +use crate::commands::frontmatter; use chrono::NaiveDate; use clap::Args; use posts::notes::FrontMatter; @@ -50,47 +51,15 @@ fn classify_note(path: &Path) -> cja::Result> { /// Parse frontmatter from raw markdown content fn parse_frontmatter(content: &str) -> cja::Result<(FrontMatter, String)> { - let content = content.trim_start(); - if !content.starts_with("---") { - return Err(cja::color_eyre::eyre::eyre!( - "Missing frontmatter delimiter" - )); - } - - let rest = &content[3..]; - let Some(end_idx) = rest.find("\n---") else { - return Err(cja::color_eyre::eyre::eyre!( - "Missing closing frontmatter delimiter" - )); - }; - - let yaml = &rest[..end_idx].trim(); - let body = &rest[end_idx + 4..]; // Skip "\n---" - - let frontmatter: FrontMatter = serde_yaml::from_str(yaml) + let (yaml, body) = frontmatter::split_frontmatter(content)?; + let fm: FrontMatter = serde_yaml::from_str(yaml) .map_err(|e| cja::color_eyre::eyre::eyre!("Invalid YAML: {}", e))?; - - Ok((frontmatter, body.to_string())) + Ok((fm, body.to_string())) } /// Update frontmatter in a markdown file with the `bsky_url` fn update_frontmatter_with_bsky_url(content: &str, url: &str) -> String { - let content = content.trim_start(); - if !content.starts_with("---") { - return content.to_string(); - } - - let rest = &content[3..]; - let Some(end_idx) = rest.find("\n---") else { - return content.to_string(); - }; - - let yaml = &rest[..end_idx]; - let body = &rest[end_idx + 4..]; // Skip "\n---" - - let updated_yaml = format!("{}\nbsky_url: {url}", yaml.trim_end()); - - format!("---\n{updated_yaml}\n---{body}") + frontmatter::append_frontmatter_keys(content, &[("bsky_url", url)]) } /// Publish a single note, given an authenticated client. Idempotent: skips diff --git a/server/src/commands/frontmatter.rs b/server/src/commands/frontmatter.rs new file mode 100644 index 00000000..6c42093c --- /dev/null +++ b/server/src/commands/frontmatter.rs @@ -0,0 +1,139 @@ +//! Generic YAML frontmatter helpers shared across post-type CLI commands. +//! +//! These intentionally operate on raw `&str` so callers can deserialize the +//! YAML into whichever concrete frontmatter type they need (note vs blog). + +/// Split a markdown file into `(yaml, body)` without binding to a concrete type. +/// Returns `Err` if the document is missing `---` delimiters. +pub fn split_frontmatter(content: &str) -> cja::Result<(&str, &str)> { + let content = content.trim_start(); + if !content.starts_with("---") { + return Err(cja::color_eyre::eyre::eyre!( + "Missing frontmatter delimiter" + )); + } + + let rest = &content[3..]; + let Some(end_idx) = rest.find("\n---") else { + return Err(cja::color_eyre::eyre::eyre!( + "Missing closing frontmatter delimiter" + )); + }; + + let yaml = rest[..end_idx].trim(); + let body = &rest[end_idx + 4..]; // Skip "\n---" + + Ok((yaml, body)) +} + +/// Append `key: value` lines to the YAML frontmatter. Values written verbatim — +/// safe for AT URIs and bsky URLs which contain no YAML-special chars. +/// Returns input unchanged if document has no frontmatter. +pub fn append_frontmatter_keys(content: &str, kv: &[(&str, &str)]) -> String { + let trimmed = content.trim_start(); + if !trimmed.starts_with("---") { + return content.to_string(); + } + + let rest = &trimmed[3..]; + let Some(end_idx) = rest.find("\n---") else { + return content.to_string(); + }; + + let yaml = &rest[..end_idx]; + let body = &rest[end_idx + 4..]; // Skip "\n---" + + let mut updated_yaml = yaml.trim_end().to_string(); + for (key, value) in kv { + updated_yaml.push('\n'); + updated_yaml.push_str(key); + updated_yaml.push_str(": "); + updated_yaml.push_str(value); + } + + format!("---\n{updated_yaml}\n---{body}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn split_frontmatter_basic() { + let content = "---\ntitle: T\ndate: 2026-05-01\n---\n\nbody text\n"; + let (yaml, body) = split_frontmatter(content).unwrap(); + assert!(yaml.contains("title: T")); + assert!(yaml.contains("date: 2026-05-01")); + assert!(body.contains("body text")); + } + + #[test] + fn split_frontmatter_missing_opening_delimiter() { + let content = "title: T\n---\n\nbody\n"; + assert!(split_frontmatter(content).is_err()); + } + + #[test] + fn split_frontmatter_missing_closing_delimiter() { + let content = "---\ntitle: T\n\nbody without closing\n"; + assert!(split_frontmatter(content).is_err()); + } + + #[test] + fn split_frontmatter_preserves_body_whitespace() { + let content = "---\ntitle: T\n---\n\n\n# Heading\n\nParagraph.\n"; + let (_, body) = split_frontmatter(content).unwrap(); + // The body starts after "\n---", so two leading newlines + body. + assert!(body.starts_with("\n\n\n# Heading")); + assert!(body.contains("Paragraph.")); + } + + #[test] + fn append_single_key_appends_correctly() { + let content = "---\ntitle: T\ndate: 2026-05-01\n---\n\nbody\n"; + let updated = append_frontmatter_keys(content, &[("bsky_url", "https://bsky.app/x")]); + assert!(updated.contains("title: T")); + assert!(updated.contains("date: 2026-05-01")); + assert!(updated.contains("bsky_url: https://bsky.app/x")); + assert!(updated.contains("body")); + } + + #[test] + fn append_multiple_keys_adds_them_in_order() { + let content = "---\ntitle: T\n---\n\nbody\n"; + let updated = append_frontmatter_keys( + content, + &[ + ("atproto_uri", "at://abc"), + ("bsky_url", "https://bsky.app/x"), + ], + ); + let at_idx = updated.find("atproto_uri:").unwrap(); + let bsky_idx = updated.find("bsky_url:").unwrap(); + assert!(at_idx < bsky_idx); + } + + #[test] + fn append_keys_no_frontmatter_returns_input_unchanged() { + let content = "no frontmatter here\n"; + let updated = append_frontmatter_keys(content, &[("k", "v")]); + assert_eq!(updated, content); + } + + #[test] + fn append_keys_missing_closing_returns_input_unchanged() { + let content = "---\ntitle: T\n\nbody no closing\n"; + let updated = append_frontmatter_keys(content, &[("k", "v")]); + assert_eq!(updated, content); + } + + #[test] + fn append_keys_preserves_body_formatting() { + let content = "---\ntitle: T\n---\n\n# Heading\n\n- list\n- items\n"; + let updated = append_frontmatter_keys(content, &[("k", "v")]); + assert!(updated.contains("# Heading")); + assert!(updated.contains("- list")); + assert!(updated.contains("- items")); + assert!(updated.contains("k: v")); + } +} diff --git a/server/src/commands/mod.rs b/server/src/commands/mod.rs index 799dd8e5..850fa71d 100644 --- a/server/src/commands/mod.rs +++ b/server/src/commands/mod.rs @@ -3,7 +3,9 @@ use clap::Subcommand; pub(crate) mod bluesky; pub(crate) mod buttondown; +pub(crate) mod frontmatter; pub(crate) mod info; +pub(crate) mod standard_site; pub(crate) mod validate; #[derive(Subcommand, Default)] @@ -16,6 +18,9 @@ pub(crate) enum Command { PublishButtondown(buttondown::PublishButtondownArgs), /// Publish a note to Bluesky PublishBluesky(bluesky::PublishBlueskyArgs), + /// Manage standard.site publications and documents on the PDS + #[command(subcommand)] + PublishStandardSite(standard_site::StandardSiteCommand), } impl Command { @@ -26,6 +31,7 @@ impl Command { Command::Validate => validate::validate(), Command::PublishButtondown(args) => buttondown::publish_buttondown(args).await, Command::PublishBluesky(args) => bluesky::publish_bluesky(args).await, + Command::PublishStandardSite(cmd) => standard_site::run(cmd).await, } } } diff --git a/server/src/commands/standard_site.rs b/server/src/commands/standard_site.rs new file mode 100644 index 00000000..688812a9 --- /dev/null +++ b/server/src/commands/standard_site.rs @@ -0,0 +1,826 @@ +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +use cja::color_eyre::eyre::eyre; +use clap::Subcommand; +use serde::{Deserialize, Serialize}; + +use crate::bluesky::{ + at_uri_to_web_url, Blob, BlueskyClient, BlueskyConfig, DocumentRecord, PublicationRecord, + StrongRef, +}; +use crate::commands::frontmatter; +use posts::blog::{BlogFrontMatter, ToCanonicalPath}; +use posts::plain::IntoPlainText; +use posts::MarkdownAst; + +/// Posts dated on or after this cutoff get a Bluesky post in addition to +/// their `site.standard.document` record. Older posts are syndicated to +/// standard.site only — preserves the historical Bluesky feed instead of +/// flooding it with backfill posts on the first deploy. +const BSKY_POST_CUTOFF_DATE: &str = "2026-05-29"; + +fn bsky_post_cutoff() -> chrono::NaiveDate { + chrono::NaiveDate::parse_from_str(BSKY_POST_CUTOFF_DATE, "%Y-%m-%d") + .expect("BSKY_POST_CUTOFF_DATE valid") +} + +#[derive(Subcommand, Debug)] +pub enum StandardSiteCommand { + /// Create the `site.standard.publication` record on the PDS and cache + /// its `at_uri` + `at_cid` back into `publications.toml`. Idempotent: if + /// `at_uri` is already set, refresh the record via `putRecord` using the + /// rkey parsed out of the cached `at_uri`. + Init(InitArgs), + /// Walk the publication's `content_dir`, create/update a + /// `site.standard.document` for every post, then create the bsky post + /// with `associatedRefs`. + Sync(SyncArgs), +} + +#[derive(clap::Args, Debug)] +pub struct InitArgs { + pub key: String, + /// Override the default config path. The config's parent dir is the repo root. + #[arg(long, default_value = "publications.toml")] + pub config: PathBuf, + /// Re-upload the publication record even if `at_uri`/`at_cid` are already + /// cached. Without this, init is a no-op when the publication is already + /// bootstrapped — letting the workflow run it on every deploy. + #[arg(long)] + pub force: bool, +} + +#[derive(clap::Args, Debug)] +#[group(required = true, multiple = false)] +pub struct SyncArgs { + #[arg(long, group = "target")] + pub all: bool, + #[arg(long, group = "target")] + pub key: Option, + #[arg(long, default_value = "publications.toml")] + pub config: PathBuf, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct PublicationsConfig { + #[serde(rename = "publication")] + pub publications: Vec, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct PublicationConfig { + pub key: String, + pub title: String, + pub description: String, + pub url: String, + pub content_dir: String, + pub collection: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub at_uri: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub at_cid: Option, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum SyncOutcome { + /// Both `atproto_uri` and `bsky_url` already set — nothing to do. + Skip, + /// Document already synced but no bsky post yet. + BskyOnly, + /// Neither side synced — create document and bsky post. + Both, +} + +fn classify_blog_post(fm: &BlogFrontMatter) -> SyncOutcome { + match (&fm.atproto_uri, &fm.bsky_url) { + (Some(_), Some(_)) => SyncOutcome::Skip, + (Some(_), None) => SyncOutcome::BskyOnly, + _ => SyncOutcome::Both, + } +} + +fn load_config(path: &Path) -> cja::Result { + let raw = std::fs::read_to_string(path) + .map_err(|e| eyre!("Failed to read config {}: {}", path.display(), e))?; + toml::from_str(&raw).map_err(|e| eyre!("Failed to parse {}: {}", path.display(), e)) +} + +fn save_config(path: &Path, cfg: &PublicationsConfig) -> cja::Result<()> { + let serialized = + toml::to_string_pretty(cfg).map_err(|e| eyre!("Failed to serialize config: {}", e))?; + std::fs::write(path, serialized) + .map_err(|e| eyre!("Failed to write {}: {}", path.display(), e))?; + Ok(()) +} + +fn repo_root_for(config_path: &Path) -> PathBuf { + config_path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .map_or_else(|| PathBuf::from("."), Path::to_path_buf) +} + +fn rkey_from_at_uri(at_uri: &str) -> cja::Result { + let stripped = at_uri + .strip_prefix("at://") + .ok_or_else(|| eyre!("Invalid AT URI: missing at:// prefix: {at_uri}"))?; + let (_did, rest) = stripped + .split_once('/') + .ok_or_else(|| eyre!("Invalid AT URI: missing collection: {at_uri}"))?; + let (_collection, rkey) = rest + .split_once('/') + .ok_or_else(|| eyre!("Invalid AT URI: missing record key: {at_uri}"))?; + if rkey.is_empty() { + return Err(eyre!("Invalid AT URI: empty rkey: {at_uri}")); + } + Ok(rkey.to_string()) +} + +fn rkey_from_blog_path(relative_path: &Path) -> String { + // Each blog post lives at `/index.md`. Strip `index.md` and use + // the parent directory path as the rkey, replacing `/` with `-` so + // nested paths (`weekly/20230713/index.md`) collapse to a single segment. + let parent = relative_path + .parent() + .map(Path::to_path_buf) + .unwrap_or_default(); + if parent.as_os_str().is_empty() { + return relative_path + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + } + parent + .to_string_lossy() + .replace([std::path::MAIN_SEPARATOR, '/'], "-") +} + +fn relative_under(root: &Path, content_dir: &str, post_path: &Path) -> cja::Result { + let prefix = root.join(content_dir); + post_path + .strip_prefix(&prefix) + .map(Path::to_path_buf) + .map_err(|_| { + eyre!( + "Post path {} is not under {}", + post_path.display(), + prefix.display() + ) + }) +} + +/// Recursively walk `root` collecting any file named exactly `index.md`. +fn collect_index_md_files(root: &Path) -> cja::Result> { + let mut out = Vec::new(); + walk_index_md(root, &mut out)?; + out.sort(); + Ok(out) +} + +fn walk_index_md(dir: &Path, out: &mut Vec) -> cja::Result<()> { + let entries = + std::fs::read_dir(dir).map_err(|e| eyre!("Failed to read dir {}: {}", dir.display(), e))?; + for entry in entries { + let entry = entry.map_err(|e| eyre!("Failed to read dir entry: {}", e))?; + let path = entry.path(); + let file_type = entry + .file_type() + .map_err(|e| eyre!("Failed to stat {}: {}", path.display(), e))?; + if file_type.is_dir() { + walk_index_md(&path, out)?; + } else if file_type.is_file() + && path.file_name().and_then(|s| s.to_str()) == Some("index.md") + { + out.push(path); + } + } + Ok(()) +} + +/// Build the imgproxy URL that rasterizes the publication's SVG OG card to a +/// 1200×630 PNG. Mirrors the format used by +/// `templates::og::og_image_url` so cover images match the per-post cards. +fn publication_cover_imgproxy_url(app_base_url: &str, imgproxy_url: &str, key: &str) -> String { + let svg_url = format!( + "{}/og/publication/{}.svg", + app_base_url.trim_end_matches('/'), + key + ); + format!( + "{}/unsafe/rs:fill:1200:630/format:png/plain/{}", + imgproxy_url.trim_end_matches('/'), + urlencoding::encode(&svg_url) + ) +} + +/// Fetch the publication's branded OG card as a PNG via imgproxy. +/// +/// Requires `APP_BASE_URL` (so we know where the deployed SVG endpoint lives) +/// and `IMGPROXY_URL` (so the SVG gets rasterized). Returns `(bytes, mime)` +/// suitable for `upload_blob`. +async fn fetch_publication_cover_png(key: &str) -> cja::Result<(Vec, String)> { + let app_base_url = + std::env::var("APP_BASE_URL").map_err(|_| eyre!("APP_BASE_URL must be set"))?; + let imgproxy_url = + std::env::var("IMGPROXY_URL").map_err(|_| eyre!("IMGPROXY_URL must be set"))?; + let png_url = publication_cover_imgproxy_url(&app_base_url, &imgproxy_url, key); + + let resp = reqwest::get(&png_url) + .await + .map_err(|e| eyre!("Failed to fetch cover from {png_url}: {e}"))?; + let status = resp.status(); + if !status.is_success() { + return Err(eyre!("imgproxy returned {status} for {png_url}")); + } + let content_type = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("image/png") + .to_string(); + let bytes = resp + .bytes() + .await + .map_err(|e| eyre!("Failed to read cover body: {e}"))? + .to_vec(); + Ok((bytes, content_type)) +} + +pub async fn run(cmd: &StandardSiteCommand) -> cja::Result<()> { + let config = BlueskyConfig::from_env()?; + let client = BlueskyClient::login(&config).await?; + + match cmd { + StandardSiteCommand::Init(args) => { + init_publication(&client, &args.config, &args.key, args.force).await + } + StandardSiteCommand::Sync(args) => { + let cfg = load_config(&args.config)?; + let repo_root = repo_root_for(&args.config); + let to_sync: Vec = if args.all { + cfg.publications.clone() + } else { + let key = args.key.as_ref().ok_or_else(|| { + eyre!("Either --all or --key must be provided to publish-standard-site sync") + })?; + cfg.publications + .iter() + .find(|p| &p.key == key) + .cloned() + .map(|p| vec![p]) + .ok_or_else(|| eyre!("Publication '{}' not found in config", key))? + }; + + let mut total_failed = 0usize; + for pub_cfg in &to_sync { + let summary = sync_publication(&client, pub_cfg, &repo_root).await?; + println!( + "Publication '{}': {} synced, {} skipped, {} failed", + pub_cfg.key, summary.synced, summary.skipped, summary.failed + ); + total_failed += summary.failed; + } + + if total_failed > 0 { + return Err(eyre!( + "{total_failed} post(s) failed to sync; next run will retry" + )); + } + Ok(()) + } + } +} + +#[derive(Debug, Default)] +struct SyncSummary { + synced: usize, + skipped: usize, + failed: usize, +} + +async fn init_publication( + client: &BlueskyClient, + config_path: &Path, + key: &str, + force: bool, +) -> cja::Result<()> { + let mut cfg = load_config(config_path)?; + let pub_cfg = cfg + .publications + .iter_mut() + .find(|p| p.key == key) + .ok_or_else(|| eyre!("Publication '{key}' not found in {}", config_path.display()))?; + + // Idempotent fast path: when both fields are cached and the caller didn't + // ask for a refresh, do nothing. This lets the workflow run `init` on + // every deploy without thrashing the PDS or producing churn commits + // (`put_publication` bumps `createdAt`/`cid` on every call). + if !force && pub_cfg.at_uri.is_some() && pub_cfg.at_cid.is_some() { + println!( + "Publication '{key}' already initialized (uri={}); skipping. Use --force to refresh.", + pub_cfg.at_uri.as_deref().unwrap_or("") + ); + return Ok(()); + } + + // Fetch the publication's branded OG card as a PNG via imgproxy. The SVG + // itself is served by the deployed app at `/og/publication/{key}.svg`; + // imgproxy rasterizes and caches the 1200×630 PNG that the cover blob + // points at. Best-effort: if the fetch fails we proceed without a cover + // rather than aborting init. + let cover: Option = match fetch_publication_cover_png(&pub_cfg.key).await { + Ok((bytes, mime)) => match client.upload_blob(bytes, &mime).await { + Ok(blob) => Some(blob), + Err(e) => { + eprintln!( + "Warning: cover upload failed for '{}': {e}. Proceeding without cover.", + pub_cfg.key + ); + None + } + }, + Err(e) => { + eprintln!( + "Warning: could not fetch generated cover for '{}': {e}. Proceeding without cover.", + pub_cfg.key + ); + None + } + }; + + let record = PublicationRecord { + record_type: "site.standard.publication".to_string(), + title: pub_cfg.title.clone(), + description: pub_cfg.description.clone(), + url: pub_cfg.url.clone(), + created_at: chrono::Utc::now().to_rfc3339(), + cover, + }; + + let response = if pub_cfg.at_uri.is_none() || pub_cfg.at_cid.is_none() { + client.create_publication(record).await? + } else { + let cached = pub_cfg.at_uri.as_deref().unwrap(); + let rkey = rkey_from_at_uri(cached)?; + client.put_publication(&rkey, record).await? + }; + + pub_cfg.at_uri = Some(response.uri.clone()); + pub_cfg.at_cid = Some(response.cid.clone()); + save_config(config_path, &cfg)?; + + println!( + "Publication '{key}' synced: uri={} cid={}", + response.uri, response.cid + ); + Ok(()) +} + +async fn sync_publication( + client: &BlueskyClient, + pub_cfg: &PublicationConfig, + repo_root: &Path, +) -> cja::Result { + match (&pub_cfg.at_uri, &pub_cfg.at_cid) { + (Some(_), Some(_)) => {} + (Some(_), None) => { + return Err(eyre!( + "publication '{}' is partially bootstrapped — re-run `publish-standard-site init {}`", + pub_cfg.key, + pub_cfg.key + )); + } + _ => { + return Err(eyre!( + "publication '{}' is not bootstrapped — run `publish-standard-site init {}` first", + pub_cfg.key, + pub_cfg.key + )); + } + } + + let content_root = repo_root.join(&pub_cfg.content_dir); + let post_paths = collect_index_md_files(&content_root)?; + + let mut summary = SyncSummary::default(); + let mut failures: Vec<(PathBuf, cja::color_eyre::Report)> = Vec::new(); + + for post_path in &post_paths { + match sync_one(client, pub_cfg, post_path, repo_root).await { + Ok(SyncOutcome::Skip) => summary.skipped += 1, + Ok(_) => summary.synced += 1, + Err(e) => { + eprintln!("Failed to sync {}: {e}", post_path.display()); + failures.push((post_path.clone(), e)); + } + } + } + + summary.failed = failures.len(); + Ok(summary) +} + +async fn sync_one( + client: &BlueskyClient, + pub_cfg: &PublicationConfig, + post_path: &Path, + repo_root: &Path, +) -> cja::Result { + let content = std::fs::read_to_string(post_path) + .map_err(|e| eyre!("Failed to read {}: {}", post_path.display(), e))?; + let (yaml, _body) = frontmatter::split_frontmatter(&content)?; + let fm: BlogFrontMatter = + serde_yaml::from_str(yaml).map_err(|e| eyre!("Invalid frontmatter: {e}"))?; + + // Document syncing has no date cutoff — every historical post gets a + // `site.standard.document` on first deploy. The cutoff below only gates + // the Bluesky post: historical posts publish to standard.site only, + // recent posts also get a Bluesky post. + let outcome = classify_blog_post(&fm); + if matches!(outcome, SyncOutcome::Skip) { + return Ok(SyncOutcome::Skip); + } + + // Filter by `publication` field — only sync posts that belong to this publication. + if fm.publication != pub_cfg.key { + return Ok(SyncOutcome::Skip); + } + + let is_historical = fm.date < bsky_post_cutoff(); + + // Historical post that already has its document synced — terminal state. + // (`BskyOnly` means atproto_uri set + bsky_url unset; for historical posts + // we never want to write a bsky_url, so this IS the desired final state.) + if is_historical && matches!(outcome, SyncOutcome::BskyOnly) { + return Ok(SyncOutcome::Skip); + } + + let rel = relative_under(repo_root, &pub_cfg.content_dir, post_path)?; + let blog_rkey = rkey_from_blog_path(&rel); + let canonical = rel.canonical_path(); + let post_url = format!("https://coreyja.com/posts/{canonical}"); + + let ast = MarkdownAst::from_str(&content)?; + let description: String = ast.0.plain_text().chars().take(100).collect(); + + let pub_ref = StrongRef { + uri: pub_cfg + .at_uri + .clone() + .ok_or_else(|| eyre!("publication at_uri missing"))?, + cid: pub_cfg + .at_cid + .clone() + .ok_or_else(|| eyre!("publication at_cid missing"))?, + }; + + let record = build_document_record(&fm, &pub_ref, post_url.clone(), description.clone()); + let doc_response = client.put_document(&blog_rkey, record).await?; + let doc_ref = StrongRef { + uri: doc_response.uri.clone(), + cid: doc_response.cid.clone(), + }; + + match outcome { + SyncOutcome::Both if is_historical => { + // Document-only for historical posts. Write atproto_uri so future + // runs short-circuit via the BskyOnly + is_historical check above. + let updated = + frontmatter::append_frontmatter_keys(&content, &[("atproto_uri", &doc_ref.uri)]); + write_back(post_path, &updated)?; + Ok(SyncOutcome::Both) + } + SyncOutcome::Both => { + let bsky_response = client + .create_blog_post( + &fm.title, + &post_url, + &description, + vec![pub_ref.clone(), doc_ref.clone()], + ) + .await?; + let bsky_web_url = at_uri_to_web_url(&bsky_response.uri)?; + let updated = frontmatter::append_frontmatter_keys( + &content, + &[("atproto_uri", &doc_ref.uri), ("bsky_url", &bsky_web_url)], + ); + write_back(post_path, &updated)?; + Ok(SyncOutcome::Both) + } + SyncOutcome::BskyOnly => { + let bsky_response = client + .create_blog_post( + &fm.title, + &post_url, + &description, + vec![pub_ref.clone(), doc_ref.clone()], + ) + .await?; + let bsky_web_url = at_uri_to_web_url(&bsky_response.uri)?; + let updated = + frontmatter::append_frontmatter_keys(&content, &[("bsky_url", &bsky_web_url)]); + write_back(post_path, &updated)?; + Ok(SyncOutcome::BskyOnly) + } + SyncOutcome::Skip => Ok(SyncOutcome::Skip), + } +} + +fn build_document_record( + fm: &BlogFrontMatter, + pub_ref: &StrongRef, + post_url: String, + description: String, +) -> DocumentRecord { + let published_at = fm + .date + .and_hms_opt(0, 0, 0) + .expect("midnight valid") + .and_utc() + .to_rfc3339(); + DocumentRecord { + record_type: "site.standard.document".to_string(), + publication: pub_ref.clone(), + title: fm.title.clone(), + description, + url: post_url, + published_at, + updated_at: chrono::Utc::now().to_rfc3339(), + cover: None, + tags: fm.tags.clone(), + } +} + +fn write_back(post_path: &Path, content: &str) -> cja::Result<()> { + std::fs::write(post_path, content) + .map_err(|e| eyre!("Failed to write {}: {}", post_path.display(), e))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + use std::io::Write; + + fn sample_publication() -> PublicationConfig { + PublicationConfig { + key: "blog".to_string(), + title: "coreyja".to_string(), + description: "Personal blog".to_string(), + url: "https://coreyja.com/posts".to_string(), + content_dir: "blog".to_string(), + collection: "site.standard.document".to_string(), + at_uri: None, + at_cid: None, + } + } + + fn sample_blog_fm() -> BlogFrontMatter { + BlogFrontMatter { + title: "T".to_string(), + date: NaiveDate::default(), + is_newsletter: false, + bsky_url: None, + newsletter_send_at: None, + buttondown_id: None, + og_image: None, + subtitle: None, + tags: vec![], + author: None, + atproto_uri: None, + publication: "blog".to_string(), + } + } + + #[test] + fn load_config_parses_blog_entry() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("publications.toml"); + let mut f = std::fs::File::create(&path).unwrap(); + writeln!( + f, + "[[publication]]\nkey = \"blog\"\ntitle = \"coreyja\"\ndescription = \"d\"\nurl = \"https://coreyja.com/posts\"\ncontent_dir = \"blog\"\ncollection = \"site.standard.document\"" + ) + .unwrap(); + let cfg = load_config(&path).unwrap(); + assert_eq!(cfg.publications.len(), 1); + let p = &cfg.publications[0]; + assert_eq!(p.key, "blog"); + assert_eq!(p.title, "coreyja"); + assert_eq!(p.content_dir, "blog"); + assert!(p.at_uri.is_none()); + assert!(p.at_cid.is_none()); + } + + #[test] + fn publication_cover_imgproxy_url_format() { + let out = publication_cover_imgproxy_url( + "https://coreyja.com", + "https://img.coreyja.com", + "blog", + ); + assert!( + out.starts_with("https://img.coreyja.com/unsafe/rs:fill:1200:630/format:png/plain/") + ); + assert!(out.ends_with( + &urlencoding::encode("https://coreyja.com/og/publication/blog.svg").into_owned() + )); + } + + #[test] + fn publication_cover_imgproxy_url_strips_trailing_slashes() { + let out = publication_cover_imgproxy_url( + "https://coreyja.com/", + "https://img.coreyja.com/", + "blog", + ); + assert!(!out.contains("com//og/")); + assert!(!out.contains("com//unsafe/")); + } + + #[test] + fn save_config_roundtrips() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("publications.toml"); + let cfg = PublicationsConfig { + publications: vec![sample_publication()], + }; + save_config(&path, &cfg).unwrap(); + let loaded = load_config(&path).unwrap(); + assert_eq!(loaded.publications.len(), 1); + assert_eq!(loaded.publications[0].key, "blog"); + assert_eq!(loaded.publications[0].title, "coreyja"); + } + + #[test] + fn save_config_preserves_at_uri_and_cid_after_init() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("publications.toml"); + let mut pubc = sample_publication(); + pubc.at_uri = Some("at://did:plc:abc/site.standard.publication/3xyz".to_string()); + pubc.at_cid = Some("bafy123".to_string()); + let cfg = PublicationsConfig { + publications: vec![pubc], + }; + save_config(&path, &cfg).unwrap(); + let loaded = load_config(&path).unwrap(); + assert_eq!( + loaded.publications[0].at_uri.as_deref(), + Some("at://did:plc:abc/site.standard.publication/3xyz") + ); + assert_eq!(loaded.publications[0].at_cid.as_deref(), Some("bafy123")); + } + + #[test] + fn classify_blog_post_skip_when_both_set() { + let mut fm = sample_blog_fm(); + fm.atproto_uri = Some("at://abc".to_string()); + fm.bsky_url = Some("https://bsky.app/x".to_string()); + assert_eq!(classify_blog_post(&fm), SyncOutcome::Skip); + } + + #[test] + fn classify_blog_post_bsky_only_when_doc_set() { + let mut fm = sample_blog_fm(); + fm.atproto_uri = Some("at://abc".to_string()); + fm.bsky_url = None; + assert_eq!(classify_blog_post(&fm), SyncOutcome::BskyOnly); + } + + #[test] + fn classify_blog_post_both_when_neither_set() { + let fm = sample_blog_fm(); + assert_eq!(classify_blog_post(&fm), SyncOutcome::Both); + } + + #[test] + fn rkey_from_blog_path_strips_index_md() { + let p = Path::new("look-ma-no-ai/index.md"); + assert_eq!(rkey_from_blog_path(p), "look-ma-no-ai"); + } + + #[test] + fn rkey_from_blog_path_handles_newsletter_paths() { + let p = Path::new("weekly/20230713/index.md"); + assert_eq!(rkey_from_blog_path(p), "weekly-20230713"); + } + + #[test] + fn rkey_from_at_uri_parses_collection_and_rkey() { + let rkey = rkey_from_at_uri("at://did:plc:abc/site.standard.publication/3labc").unwrap(); + assert_eq!(rkey, "3labc"); + } + + #[test] + fn rkey_from_at_uri_rejects_malformed_uri() { + assert!(rkey_from_at_uri("not-an-at-uri").is_err()); + assert!(rkey_from_at_uri("at://did:plc:abc").is_err()); + assert!(rkey_from_at_uri("at://did:plc:abc/collection").is_err()); + } + + #[test] + fn repo_root_for_default_path_returns_dot() { + assert_eq!( + repo_root_for(Path::new("publications.toml")), + PathBuf::from(".") + ); + } + + #[test] + fn repo_root_for_nested_path_returns_parent() { + assert_eq!( + repo_root_for(Path::new("/tmp/repo/publications.toml")), + PathBuf::from("/tmp/repo") + ); + } + + #[test] + fn relative_under_strips_repo_and_content_dir() { + let rel = relative_under( + Path::new("/r"), + "blog", + Path::new("/r/blog/look-ma-no-ai/index.md"), + ) + .unwrap(); + assert_eq!(rel, PathBuf::from("look-ma-no-ai/index.md")); + } + + /// Walks `blog/**/index.md` to ensure every publishable post fits within + /// Bluesky's 300-character post limit (title + url + separators only, + /// since the body is replaced by the standard.site card). Historical + /// posts are exempt — they syndicate to standard.site only, no bsky post. + #[test] + fn all_publishable_blog_posts_fit_within_bsky_post_limit() { + let blog_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("server has a parent dir") + .join("blog"); + let cutoff = bsky_post_cutoff(); + + let posts = collect_index_md_files(&blog_dir).expect("walk blog/"); + let mut failures = Vec::new(); + + for path in &posts { + let Ok(content) = std::fs::read_to_string(path) else { + continue; + }; + let Ok((yaml, _body)) = frontmatter::split_frontmatter(&content) else { + continue; + }; + let Ok(fm): Result = serde_yaml::from_str(yaml) else { + continue; + }; + if fm.date < cutoff { + continue; + } + let rel = path.strip_prefix(&blog_dir).unwrap_or(path); + let canonical = rel.to_path_buf().canonical_path(); + let url = format!("https://coreyja.com/posts/{canonical}"); + let chars = fm.title.chars().count() + 2 + url.chars().count(); + if chars > 300 { + failures.push(format!( + "{}: {chars} chars (over by {})", + path.display(), + chars - 300 + )); + } + } + + assert!( + failures.is_empty(), + "These blog posts would exceed Bluesky's 300-character post limit:\n {}", + failures.join("\n ") + ); + } + + /// Every blog post must produce a unique rkey (since the document record + /// is keyed by it). Duplicates would silently overwrite each other. + #[test] + fn all_blog_posts_have_unique_rkeys() { + let blog_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("server has a parent dir") + .join("blog"); + let posts = collect_index_md_files(&blog_dir).expect("walk blog/"); + let mut seen: std::collections::HashMap = std::collections::HashMap::new(); + let mut conflicts = Vec::new(); + for path in &posts { + let rel = path.strip_prefix(&blog_dir).unwrap_or(path).to_path_buf(); + let rkey = rkey_from_blog_path(&rel); + if let Some(existing) = seen.insert(rkey.clone(), path.clone()) { + conflicts.push(format!( + "rkey '{rkey}' shared by {} and {}", + existing.display(), + path.display() + )); + } + } + assert!( + conflicts.is_empty(), + "Duplicate rkeys would overwrite each other on the PDS:\n {}", + conflicts.join("\n ") + ); + } +} diff --git a/server/src/http_server/pages/blog/mod.rs b/server/src/http_server/pages/blog/mod.rs index 07eee783..b1ac5895 100644 --- a/server/src/http_server/pages/blog/mod.rs +++ b/server/src/http_server/pages/blog/mod.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use axum::{ extract::{Path, State}, @@ -6,6 +6,45 @@ use axum::{ response::{IntoResponse, Redirect, Response}, }; +static PUBLICATIONS_TOML: &str = + include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../publications.toml")); + +#[derive(serde::Deserialize)] +struct PublicationsFile { + publication: Vec, +} + +#[derive(serde::Deserialize)] +struct PublicationStub { + key: String, + at_uri: Option, +} + +static PUBLICATIONS: LazyLock> = LazyLock::new(|| { + toml::from_str::(PUBLICATIONS_TOML) + .expect("publications.toml must parse — check syntax") + .publication +}); + +/// Build `` head links for verification. +fn standard_site_head_links( + atproto_uri: Option<&str>, + publication_key: &str, +) -> Vec<(String, String)> { + let mut head_links: Vec<(String, String)> = Vec::new(); + if let Some(doc_uri) = atproto_uri { + head_links.push(("site.standard.document".to_string(), doc_uri.to_string())); + } + if let Some(pub_uri) = PUBLICATIONS + .iter() + .find(|p| p.key == publication_key) + .and_then(|p| p.at_uri.as_deref()) + { + head_links.push(("site.standard.publication".to_string(), pub_uri.to_string())); + } + head_links +} + use maud::{html, Markup}; use posts::{ blog::{BlogPostPath, BlogPosts, MatchesPath, ToCanonicalPath}, @@ -145,6 +184,7 @@ pub(crate) async fn posts_index( )) } +#[allow(clippy::too_many_lines)] #[instrument(skip(state, posts))] pub(crate) async fn post_get( State(state): State, @@ -208,7 +248,13 @@ pub(crate) async fn post_get( .app_url(&format!("/posts/{}", post.path.canonical_path())); let bsky_thread = if let Some(bsky_post_url) = &post.frontmatter.bsky_url { - Some((bsky_post_url, fetch_thread(bsky_post_url).await.unwrap())) + match fetch_thread(bsky_post_url).await { + Ok(thread) => Some((bsky_post_url, thread)), + Err(e) => { + tracing::warn!(?e, "Failed to fetch Bluesky thread for blog post"); + None + } + } } else { None }; @@ -222,6 +268,11 @@ pub(crate) async fn post_get( .and_utc() .to_rfc3339(); + let head_links = standard_site_head_links( + post.frontmatter.atproto_uri.as_deref(), + &post.frontmatter.publication, + ); + Ok(base_constrained( html! { h1 class="text-2xl" { (post.markdown().title) } @@ -254,6 +305,7 @@ pub(crate) async fn post_get( published_time: Some(published_time), author: post.frontmatter.author.clone(), tags: post.frontmatter.tags.clone(), + head_links, ..OpenGraph::default() }, ) diff --git a/server/src/http_server/pages/og.rs b/server/src/http_server/pages/og.rs index ad4c0d2d..ae58a556 100644 --- a/server/src/http_server/pages/og.rs +++ b/server/src/http_server/pages/og.rs @@ -1,7 +1,7 @@ //! OG card SVG endpoints. These return raw SVG; `imgproxy` is responsible for rasterizing //! and caching the PNG version that social scrapers actually consume. -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use axum::{ extract::{Path, State}, @@ -11,9 +11,43 @@ use axum::{ use posts::{blog::BlogPosts, notes::NotePosts, podcast::PodcastEpisodes}; use crate::http_server::templates::og::{ - fetch_youtube_thumbnail_b64, render_card_svg, CardData, CardTag, + fetch_youtube_thumbnail_b64, render_card_svg, render_publication_card_svg, CardData, CardTag, }; +static PUBLICATIONS_TOML: &str = + include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../publications.toml")); + +#[derive(serde::Deserialize)] +struct PublicationsFile { + publication: Vec, +} + +#[derive(serde::Deserialize)] +struct PublicationCardStub { + key: String, + title: String, + description: String, +} + +static PUBLICATION_CARD_STUBS: LazyLock> = LazyLock::new(|| { + toml::from_str::(PUBLICATIONS_TOML) + .expect("publications.toml must parse") + .publication +}); + +/// Map a publication key to the `CardTag` that styles its OG card. New +/// publications need an entry here so their cover card matches the per-post +/// card style for that content type. Defaults to `Posts` for unknown keys — +/// only "blog" exists today. +fn publication_card_tag(key: &str) -> CardTag { + match key { + "podcast" => CardTag::Podcast, + "notes" => CardTag::Notes, + "newsletter" | "weekly" => CardTag::Newsletter, + _ => CardTag::Posts, + } +} + const SVG_CACHE_CONTROL: &str = "public, max-age=86400, stale-while-revalidate=604800"; fn svg_response(svg: String) -> Response { @@ -37,6 +71,7 @@ pub async fn og_post_svg( title: &post.frontmatter.title, date: post.frontmatter.date, tag: CardTag::Posts, + subtitle: post.frontmatter.subtitle.as_deref(), youtube_thumbnail_b64: None, }; Ok(svg_response(render_card_svg(&data))) @@ -56,6 +91,7 @@ pub async fn og_weekly_svg( title: &post.frontmatter.title, date: post.frontmatter.date, tag: CardTag::Newsletter, + subtitle: post.frontmatter.subtitle.as_deref(), youtube_thumbnail_b64: None, }; Ok(svg_response(render_card_svg(&data))) @@ -75,11 +111,29 @@ pub async fn og_note_svg( title: ¬e.frontmatter.title, date: note.frontmatter.date, tag: CardTag::Notes, + subtitle: None, youtube_thumbnail_b64: None, }; Ok(svg_response(render_card_svg(&data))) } +/// Publication-level OG card. Looks up the publication by `key` in +/// `publications.toml` (baked at compile time) and renders a date-less +/// branded card with the publication title and description. +pub async fn og_publication_svg(Path(key): Path) -> Result { + let key = key.strip_suffix(".svg").unwrap_or(&key); + let publication = PUBLICATION_CARD_STUBS + .iter() + .find(|p| p.key == key) + .ok_or(StatusCode::NOT_FOUND)?; + let tag = publication_card_tag(&publication.key); + Ok(svg_response(render_publication_card_svg( + &publication.title, + tag, + &publication.description, + ))) +} + pub async fn og_podcast_svg( State(episodes): State>, Path(slug): Path, @@ -95,6 +149,7 @@ pub async fn og_podcast_svg( title: &ep.frontmatter.title, date: ep.frontmatter.date, tag: CardTag::Podcast, + subtitle: None, youtube_thumbnail_b64: thumbnail, }; Ok(svg_response(render_card_svg(&data))) @@ -286,6 +341,53 @@ mod tests { assert_eq!(resp.status(), StatusCode::NOT_FOUND); } + #[tokio::test] + async fn og_publication_svg_returns_svg_for_known_key() { + let app = create_test_app().await; + let resp = app + .oneshot( + Request::builder() + .uri("/og/publication/blog.svg") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let ct = resp + .headers() + .get("content-type") + .map(|v| v.to_str().unwrap().to_string()) + .unwrap_or_default(); + assert!( + ct.starts_with("image/svg+xml"), + "unexpected content-type: {ct}" + ); + let body = body_string(resp).await; + assert!(body.contains(" Router { .route("/og/podcast/{slug}", get(pages::og::og_podcast_svg)) .route("/og/weekly/{slug}", get(pages::og::og_weekly_svg)) .route("/og/notes/{slug}", get(pages::og::og_note_svg)) + .route("/og/publication/{key}", get(pages::og::og_publication_svg)) .route("/projects", get(pages::projects::projects_index)) .route("/projects/{slug}", get(pages::projects::projects_get)) .route("/videos", get(pages::videos::video_index)) diff --git a/server/src/http_server/templates/header.rs b/server/src/http_server/templates/header.rs index fb1e1ea9..47877d99 100644 --- a/server/src/http_server/templates/header.rs +++ b/server/src/http_server/templates/header.rs @@ -24,6 +24,10 @@ pub struct OpenGraph { pub author: Option, /// Emits `article:tag` once per entry. pub tags: Vec, + /// `(rel, href)` pairs emitted as `` in ``. + /// Used to point at `site.standard.document` / `site.standard.publication` + /// records on the PDS for verification. + pub head_links: Vec<(String, String)>, } impl Default for OpenGraph { @@ -47,6 +51,7 @@ impl Default for OpenGraph { published_time: None, author: None, tags: Vec::new(), + head_links: Vec::new(), } } } @@ -130,6 +135,9 @@ impl Render for OpenGraph { meta name="twitter:image" content=(image) {} } } + @for (rel, href) in &self.head_links { + link rel=(rel) href=(href) {} + } } } } @@ -336,6 +344,36 @@ mod tests { assert!(!out.contains(r#"property="article:tag""#)); } + #[test] + fn head_links_emit_link_tags() { + let og = OpenGraph { + head_links: vec![ + ( + "site.standard.document".to_string(), + "at://did:plc:abc/site.standard.document/post-1".to_string(), + ), + ( + "site.standard.publication".to_string(), + "at://did:plc:abc/site.standard.publication/3xyz".to_string(), + ), + ], + ..OpenGraph::default() + }; + let out = rendered(&og); + assert!(out.contains(r#"rel="site.standard.document""#)); + assert!(out.contains(r#"href="at://did:plc:abc/site.standard.document/post-1""#)); + assert!(out.contains(r#"rel="site.standard.publication""#)); + assert!(out.contains(r#"href="at://did:plc:abc/site.standard.publication/3xyz""#)); + } + + #[test] + fn empty_head_links_emit_nothing() { + let og = OpenGraph::default(); + let out = rendered(&og); + assert!(!out.contains("site.standard.document")); + assert!(!out.contains("site.standard.publication")); + } + #[test] fn new_optional_fields_emit_when_populated() { let og = OpenGraph { diff --git a/server/src/http_server/templates/og.rs b/server/src/http_server/templates/og.rs index 42e631ab..ab92af12 100644 --- a/server/src/http_server/templates/og.rs +++ b/server/src/http_server/templates/og.rs @@ -67,6 +67,10 @@ pub struct CardData<'a> { pub title: &'a str, pub date: chrono::NaiveDate, pub tag: CardTag, + /// Optional subtitle/tagline. When `Some`, replaces the rendered date + /// on the card. Matches the publication card pattern where the bottom + /// slot is used for a description rather than a date. + pub subtitle: Option<&'a str>, /// Base64-encoded JPEG (no `data:` prefix). When `Some`, the `YouTube` `` block is kept. pub youtube_thumbnail_b64: Option, } @@ -171,7 +175,27 @@ pub fn render_card_svg(data: &CardData<'_>) -> String { let svg = svg.replace("{{font_face}}", QUICKSAND_FONT_CSS.as_str()); let svg = svg.replace("{{logo_svg_contents}}", super::LOGO_DARK_FLAT_SVG); let svg = substitute_title(&svg, data.title); - let svg = svg.replace("{{date}}", &data.date.format("%B %-d, %Y").to_string()); + let formatted_date = data.date.format("%B %-d, %Y").to_string(); + let date_escaped = html_escape::encode_text(&formatted_date).into_owned(); + let svg = if let Some(s) = data.subtitle { + // Subtitle present: subtitle replaces the date in the bottom-left, + // date moves to the bottom-right slot. + let truncated = truncate_title(s, BOTTOM_LEFT_MAX_CHARS); + let subtitle_escaped = html_escape::encode_text(&truncated).into_owned(); + let svg = svg.replace("{{bottom_left}}", &subtitle_escaped); + let svg = svg.replace("", ""); + let svg = svg.replace("", ""); + svg.replace("{{bottom_right}}", &date_escaped) + } else { + // No subtitle: date occupies the bottom-left slot (legacy layout); + // strip the bottom-right slot entirely. + let svg = svg.replace("{{bottom_left}}", &date_escaped); + strip_block( + &svg, + "", + "", + ) + }; let svg = svg.replace("{{tag}}", data.tag.label()); match &data.youtube_thumbnail_b64 { @@ -191,6 +215,44 @@ pub fn render_card_svg(data: &CardData<'_>) -> String { } } +/// Maximum chars rendered in the bottom-left text slot (publication +/// description, per-post subtitle). Quicksand 400 at 28px fits roughly +/// this many chars across the card width before risking overflow on +/// the right edge. +const BOTTOM_LEFT_MAX_CHARS: usize = 75; + +/// Render a publication-level OG card (publication name + tag + description, +/// no date). +/// +/// Used as the cover blob attached to `site.standard.publication` records via +/// the standard.site sync CLI. Re-uses the same SVG template as per-post cards +/// so the publication's visual identity matches. The description is rendered +/// in the slot where per-post cards show the date. +pub fn render_publication_card_svg(title: &str, tag: CardTag, description: &str) -> String { + let svg = OG_TEMPLATE_SVG.to_string(); + let svg = svg.replace("{{font_face}}", QUICKSAND_FONT_CSS.as_str()); + let svg = svg.replace("{{logo_svg_contents}}", super::LOGO_DARK_FLAT_SVG); + let svg = substitute_title(&svg, title); + let description = truncate_title(description, BOTTOM_LEFT_MAX_CHARS); + let description_escaped = html_escape::encode_text(&description).into_owned(); + let svg = svg.replace("{{bottom_left}}", &description_escaped); + let svg = svg.replace("{{tag}}", tag.label()); + + // Publication cards have no date, so strip the bottom-right slot. + let svg = strip_block( + &svg, + "", + "", + ); + + // Publication cards never embed a YouTube thumbnail; strip the block. + strip_block( + &svg, + "", + "", + ) +} + /// Absolute URL to the SVG route — uses `AppConfig.base_url`. pub fn og_svg_url(config: &AppConfig, route_path: &str) -> String { config.app_url(route_path) @@ -277,6 +339,7 @@ mod tests { title: "Sample Title", date: chrono::NaiveDate::from_ymd_opt(2026, 1, 10).unwrap(), tag: CardTag::Posts, + subtitle: None, youtube_thumbnail_b64: None, } } @@ -342,6 +405,45 @@ mod tests { assert!(!b.is_empty()); } + #[test] + fn render_card_svg_with_subtitle_emits_both_subtitle_and_date() { + let data = CardData { + title: "Hello", + date: chrono::NaiveDate::from_ymd_opt(2026, 1, 10).unwrap(), + tag: CardTag::Posts, + subtitle: Some("My great tagline"), + youtube_thumbnail_b64: None, + }; + let svg = render_card_svg(&data); + assert!( + svg.contains("My great tagline"), + "subtitle should be rendered" + ); + assert!( + svg.contains("January 10, 2026"), + "date should still render in the bottom-right slot" + ); + // Bottom-right slot is `text-anchor="end"`, so its presence is a + // direct signal the date got moved out of the bottom-left. + assert!( + svg.contains(r#"text-anchor="end""#), + "bottom-right slot should be present when subtitle is set" + ); + } + + #[test] + fn render_card_svg_without_subtitle_omits_bottom_right_slot() { + let svg = render_card_svg(&sample_data()); + assert!( + svg.contains("January 10, 2026"), + "date should render in the bottom-left slot" + ); + assert!( + !svg.contains(r#"text-anchor="end""#), + "bottom-right slot should be stripped when no subtitle is set" + ); + } + #[test] fn render_card_svg_substitutes_all_placeholders() { let svg = render_card_svg(&sample_data()); @@ -372,6 +474,7 @@ mod tests { title: "This is a long enough blog post title to force two-line wrapping", date: chrono::NaiveDate::from_ymd_opt(2026, 1, 10).unwrap(), tag: CardTag::Posts, + subtitle: None, youtube_thumbnail_b64: None, }; let svg = render_card_svg(&data); @@ -398,6 +501,7 @@ mod tests { title: "Sample", date: chrono::NaiveDate::from_ymd_opt(2026, 1, 10).unwrap(), tag: CardTag::Podcast, + subtitle: None, youtube_thumbnail_b64: Some("ZmFrZWltYWdl".to_string()), }; let svg = render_card_svg(&data); @@ -411,6 +515,7 @@ mod tests { title: "Title with & \"quotes\"", date: chrono::NaiveDate::from_ymd_opt(2026, 1, 10).unwrap(), tag: CardTag::Posts, + subtitle: None, youtube_thumbnail_b64: None, }; let svg = render_card_svg(&data); diff --git a/server/static/og-template.svg b/server/static/og-template.svg index c3406382..4f26b1df 100644 --- a/server/static/og-template.svg +++ b/server/static/og-template.svg @@ -23,17 +23,24 @@ - + {{tag}} - {{title_line_1}} + {{title_line_1}} - {{title_line_2}} + {{title_line_2}} - - {{date}} + + {{bottom_left}} + + + {{bottom_right}} +