diff --git a/README.md b/README.md index 197e5d3..97d08aa 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ For each Ghost blog you configure: - Creates a `site.standard.publication` record on your PDS (you pick the handle / app password / publication metadata) - Listens for Ghost webhooks (auto-installed during setup) -- On `post.published` / `post.published.edited`: fetches the post, uploads its cover image as a blob, writes a `site.standard.document` record, and updates the post's per-post `codeinjection_head` so its `` carries the link tag — all without touching Ghost's editor UI +- On `post.published` / `post.published.edited`: fetches the post, uploads its cover image as a blob, writes a `site.standard.document` record (the post body travels as [`at.markpub.markdown`](https://github.com/AramZS/markpub.at) content, converted from Ghost's HTML), and updates the post's per-post `codeinjection_head` so its `` carries the link tag — all without touching Ghost's editor UI - On `post.unpublished` / `post.deleted`: deletes the document record from the PDS - Tracks every post's `ghost_post_id` ↔ AT-URI mapping in a local SQLite (so deletes always know which record to remove) diff --git a/src/lib/transform.test.ts b/src/lib/transform.test.ts new file mode 100644 index 0000000..5929611 --- /dev/null +++ b/src/lib/transform.test.ts @@ -0,0 +1,30 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { ghostPostToRecord, type GhostPostLike } from './transform.js'; +import { MARKPUB_MARKDOWN_TYPE } from './content-shape.js'; + +const PUBLICATION_URI = 'at://did:plc:example/site.standard.publication/self'; + +const basePost: GhostPostLike = { + id: '64f0000000000000000000ab', + title: 'Hello', + slug: 'hello', + status: 'published', + published_at: '2026-07-06T10:00:00.000Z', + html: '

Hi

Body text.

', +}; + +test('record carries the markpub content member when the post has HTML', () => { + const record = ghostPostToRecord(basePost, PUBLICATION_URI, null); + const content = record.content as { $type: string; text: { markdown: string } }; + assert.equal(content.$type, MARKPUB_MARKDOWN_TYPE); + assert.match(content.text.markdown, /## Hi/); + // textContent is untouched by this feature — both representations coexist. + assert.match(String(record.textContent), /Body text\./); +}); + +test('record omits content when the post has no HTML', () => { + const record = ghostPostToRecord({ ...basePost, html: null }, PUBLICATION_URI, null); + assert.ok(!('content' in record)); +}); diff --git a/src/lib/transform.ts b/src/lib/transform.ts index 1ac8110..9136148 100644 --- a/src/lib/transform.ts +++ b/src/lib/transform.ts @@ -1,11 +1,10 @@ // Pure transform: Ghost post object → site.standard.document payload. // No I/O, no client state — caller passes the publication AT-URI and (optionally) // an already-uploaded cover-image blob ref. -// -// Real implementation TBD — porting from the parent bridge. import { convert as htmlToText } from 'html-to-text'; import type { BlobRef } from '@atproto/api'; +import { buildMarkpubContent } from './content-shape.js'; export const DOCUMENT_COLLECTION = 'site.standard.document'; @@ -61,5 +60,11 @@ export function ghostPostToRecord( } if (coverImageBlob) record.coverImage = coverImageBlob; + // Post body as at.markpub.markdown — the document content union is open + // (`closed: false`), so no lexicon registration is needed; the shared $type + // is what markpub-aware readers key on. + const content = buildMarkpubContent(post.html ?? ''); + if (content) record.content = content; + return record; }