Skip to content

feat(standard-site): publish posts as site.standard.document records - #47

Merged
danielpost merged 32 commits into
mainfrom
feat/standard-site
May 31, 2026
Merged

danielpost merged 32 commits into
mainfrom
feat/standard-site

Conversation

@danielpost

@danielpost danielpost commented May 31, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #46 (feat/cpt-support). Merge that one first; this PR's base is set to its branch so the diff stays focused on the standard.site work.

Summary

standard.site is a set of AT Protocol lexicons (site.standard.*) for long-form publishing — your full article lives as a typed record on your PDS where any AT-aware reader/aggregator can discover it. Bluesky stays the social engagement layer; standard.site is the content layer.

This PR adds the publishing half. When Autoblue shares a post to Bluesky, it now also writes a site.standard.document for the full article (with bskyPostRef back to the Bluesky post) and ensures a site.standard.publication exists for the site itself. Verification endpoints are served so AT clients can confirm the records belong to this domain.

The work intentionally stays narrow — no OAuth (app-password sessions already authorize writes to any collection), no subscriptions/recommends/threading/edit syncing/reaction sync. The plan file under /Users/danielpost/.claude/plans/ documents the design decisions and explicit non-goals.

Backend

  • Utils::is_root_install() / is_standard_site_enabled() — guard helpers.
  • Admin — registers autoblue_publish_documents_enabled (boolean) and autoblue_publication_overrides (object) settings.
  • Meta — registers autoblue_publish_document per-post meta, looped over enabled post types (the pattern from feat(post-types): support sharing from additional public post types #46).
  • Bluesky\API::put_record — for in-place publication updates via com.atproto.repo.putRecord.
  • Standard\Publication — build, create, sync, and cache the publication record on the PDS. Reactive hooks update on blogname / blogdescription / site_icon / override changes; fingerprint short-circuits no-op updates.
  • Standard\Document — build and write document records per shared post (title, path, description, textContent, tags, coverImage, bskyPostRef). Reuses the blob ref from the Bluesky share for coverImage to avoid a duplicate upload.
  • Standard\Verification — serves /.well-known/site.standard.publication as plain-text AT-URI, injects <link rel="site.standard.document"> into singular post heads.
  • Bluesky::share_to_bluesky — after a successful bsky post, hands off to Document::publish when the feature is on for that post. Document failures are logged but do not roll back the bsky post.
  • Subdir installs fully disable the feature (no way to serve /.well-known/ at the domain root).

Frontend

  • Settings page — new "standard.site" section with a global toggle, per-field override inputs (name, description) showing live WP defaults as placeholders, read-only publication URL + AT-URI once written. Info notice on subdir installs.
  • Editor sidebar — "Also publish as a standard.site document" toggle below "Share to Bluesky"; disabled with a contextual hint when the feature isn't available (subdir, global off, broken connection).
  • useSettings — useEntityProp for both new settings, with the same loading-spinner pattern landed in feat(post-types): support sharing from additional public post types #46.

Test plan

  • vendor/bin/codecept run wpunit — 63 tests / 136 assertions, all green (includes new Standard\PublicationTest, Standard\DocumentTest, Standard\VerificationTest, plus extended UtilsTest).
  • npm run build — clean.
  • vendor/bin/phpcs — clean.
  • End-to-end on the real install: enable global toggle, set both per-post toggles, publish a post. Log shows the full chain in order:
    • Scheduling share for post …
    • Processing share for post …
    • [Success] Shared post … to Bluesky: https://bsky.app/…
    • [Success] Created standard.site publication record at at://…/site.standard.publication/3mn5fekab4e2f (one-time on first publish)
    • [Success] Published standard.site document for post … at at://…/site.standard.document/3mn5feky6ws24
  • GET /.well-known/site.standard.publication returns the plain-text publication AT-URI.
  • Singular post head contains <link rel="site.standard.document" href="at://…">.
  • Changing site title fires the reactive sync (fingerprint changes; restoring title restores fingerprint).
  • Manual check on a subdir install (skipped — covered by the unit guard).

Base automatically changed from feat/cpt-support to main May 31, 2026 18:27
danielpost added 29 commits May 31, 2026 20:29
Adds minimum-viable standard.site support: every shared post can now
also produce a site.standard.document record on the user's PDS, with
a back-reference to the bsky post we already write. The site itself
gets a site.standard.publication record (kept in sync with WP
settings) so other AT Protocol readers can discover and follow it.

Backend
- Utils: is_root_install() + is_standard_site_enabled() guard helpers.
- Admin: register autoblue_publish_documents_enabled (boolean) and
  autoblue_publication_overrides (object) settings.
- Meta: register autoblue_publish_document per-post meta on every
  enabled post type, defaulting from the global toggle.
- Bluesky/API: add put_record (for in-place publication updates).
- Standard\Publication: build, create, sync, and cache the
  site.standard.publication record on the connected PDS. Reactive
  hooks update on blogname/blogdescription/site_icon/override changes;
  fingerprint short-circuits no-op updates.
- Standard\Document: build and write site.standard.document records
  per shared post, carrying title/path/description/textContent/tags/
  coverImage/bskyPostRef. Reuses the blob ref from the Bluesky share
  for coverImage to avoid a duplicate upload.
- Standard\Verification: serve /.well-known/site.standard.publication
  as plain-text AT-URI, inject <link rel="site.standard.document">
  into singular post heads.
- Bluesky::share_to_bluesky: after a successful bsky post, conditionally
  hand off to Standard\Document::publish. Document failures are logged
  but never roll back the bsky post.
- Subdir installs (home_url() or site_url() not at root) fully disable
  the feature.

Frontend
- Settings: new 'standard.site' section with global toggle, per-field
  override inputs (name, description) showing live WP defaults as
  placeholders, read-only publication URL + AT-URI once published. Info
  notice on subdir installs.
- Editor sidebar: 'Also publish as a standard.site document' toggle
  below 'Share to Bluesky', disabled with a contextual hint when the
  feature isn't available (subdir, global off, broken connection).
- use-settings: useEntityProp for both new settings, with the same
  loading-spinner pattern we landed for post types.

Tests
- New Standard\PublicationTest, Standard\DocumentTest,
  Standard\VerificationTest (build_record shapes, override fallback,
  uri caching, head-link rendering).
- UtilsTest extended with is_root_install + is_standard_site_enabled.
- 63 tests / 136 assertions, all green; phpcs clean.

Non-goals (deferred to future PRs)
- OAuth + DPoP migration.
- Subscriptions, recommends, threading, reaction sync, comment
  publishing.
- Edit syncing of existing documents on post edits.
- Cleanup of records on toggle-off.
Per-keystroke insta-save was jarring while typing. Local draft state
in the Settings component holds the input values; an 'Update publication'
button enables when the drafts differ from the saved overrides and
saves once on click. After save, drafts re-sync from the entity prop
so the button auto-disables.

In the hook, replace setPublicationOverride(key, value) with
savePublicationOverrides(overrides) which replaces the whole object
in one save and shows a snackbar success.
When standard.site publishing is on for a post, write the document
FIRST and then embed it as the bsky post's record (instead of the
usual app.bsky.embed.external link card). Lets standard.site-aware
clients render the document inline; lets us see how Bluesky itself
renders a non-app.bsky.* record embed.

- Bluesky::share_to_bluesky: when should_publish_document_for is
  true, publish the document up front (with bskyPostRef=null so we
  don't reference a post that doesn't exist yet), then swap the bsky
  post's embed for { $type: app.bsky.embed.record, record: { uri,
  cid } } pointing at the freshly-written document. Falls back to
  the existing link-card path if the document write returns a
  WP_Error.
- Standard\Document::publish + build_record: accept null for
  bsky_post_ref. The bskyPostRef field is omitted when null so the
  record passes lexicon validation.

The link-card path remains for posts where standard.site is off.

Note: bskyPostRef on the document is intentionally not populated in
this experiment (would require a second putRecord after the bsky
post lands). If we keep this approach, we can add the back-fill.
Bluesky clients render non-app.bsky.* embedded records as 'Deleted'
because they have no way to display the embedded record's content.
The result is a worse UX than the plain link card.

Restore the original publish order: bsky post first with the external
link card, then Document::publish with the bsky strongRef. The
Document::publish API keeps its optional bsky_post_ref parameter for
future use (won't change anyone's existing callers).
Restore the document-first / record-embed path so we can keep poking
at it. Revert this if we settle on the link-card design.
Match Atmosphere's design: bsky post stays a plain link card, document
lives independently on PDS, and standard.site readers discover the
document via the HTML <link> tag + the document's bskyPostRef field.

Tried app.bsky.embed.record pointing to the document; Bluesky clients
render non-app.bsky.* embedded records as "Deleted" so the UX is
worse than the link card. The Document::publish API keeps its
optional bsky_post_ref parameter in case it's useful later.
Four hex color overrides (background/foreground/accent/accentForeground)
in the publication settings card. Each gets parsed to {r,g,b} ints and
slotted into a site.standard.theme.basic structure on the publication
record. Empty values are omitted, so users can ship only the colors
they care about (or none). Atmosphere doesn't have this; Wireservice
does it the same way.
When a fresh Bluesky connection is added, search the user's PDS for a
site.standard.publication record whose 'url' field matches our
home_url(). If one's there, store its { uri, cid, rkey } so the next
publish reuses it instead of writing a duplicate.

- Bluesky/API: list_records helper (com.atproto.repo.listRecords).
- ConnectionsManager: fires autoblue/connection_added action after a
  successful add or upsert. Carries the connection + is_reconnect flag.
- Standard/Publication: hooks the action; no-ops when we're already
  linked to this DID. Match by exact untrailingslashit URL.
New 'Records' tab on the Autoblue settings page (shown only when
standard.site publishing is enabled) that fetches the publication
record + paginated document records from the user's PDS via
com.atproto.repo.listRecords.

- includes/Endpoints/StandardRecordsController.php: new REST endpoint
  /autoblue/v1/standard/records returning { publication, documents,
  cursor }. Cursor-paginated.
- includes/Setup.php: register the new route.
- src/js/components/records/: React component that renders the
  publication card + a list of document cards, with Load more for
  pagination.
- src/js/components/admin-page: conditional third tab driven by the
  global toggle from useSettings.
Restructure the Records tab to use BaseControl with the section label
outside the Card (consistent with the Settings tab). Render the
publication icon and document coverImage thumbnails using Bluesky's
public blob CDN (https://cdn.bsky.app/img/feed_thumbnail/plain/
{did}/{cid}@jpeg) so users can visually scan their PDS records.
Featured images were already being written into the document record
as coverImage; the browser just wasn't displaying them.
Backend already accepted icon_id in the overrides and Publication
already resolved a blob from the override-or-site-icon attachment;
the Settings UI just wasn't exposing a picker.

- Assets: enqueue WP media library on the settings page; ship
  siteIconUrl (fallback) and iconOverrideUrl (current override) in
  initial state.
- Settings: render the current icon (override > site icon >
  placeholder), MediaUpload button to pick a custom one, Remove
  button to fall back to the site icon. Wires through draftIconId
  + draftIconUrl alongside the other draft fields so the existing
  'Update publication' button picks it up.
- Adds wp-media-utils as an auto-discovered admin bundle dep.
@wordpress/media-utils' MediaUpload / MediaUploadCheck were undefined
at runtime on this WP version, crashing the Settings tab. Replace
with a small inline openIconPicker() that calls window.wp.media()
directly (wp_enqueue_media() is already being called server-side).
Drops the wp-media-utils dep from the admin bundle.
- Each record now has a 'View raw' link to pdsls.dev/{at-uri} so users
  can inspect the underlying record.
- Documents with a bskyPostRef get a 'View on Bluesky' link that opens
  the matching post in the Bluesky app.
- Refresh button at the top re-fetches publication + first page from
  the PDS (useful right after publishing).
- Dates render relative via humanTimeDiff() from @wordpress/date with
  the absolute timestamp in the title attribute on hover.
- Records tab gets max-width: 600px to match the Settings tab.
- Each document renders in its own Card instead of all sharing one
  card with VStack spacing — easier to scan, more consistent with the
  rest of the admin UI.
- Replace the title attribute on the relative date with Gutenberg's
  Tooltip component for a styled hover; <time> stays focusable for
  keyboard users.
Compact icon-only button using RefreshIcon from the local icons module,
with the same .refreshing spin animation the Logs page uses.
Replaces the four hex TextControls with Gutenberg's
__experimentalPaletteEdit component (with canOnlyChangeValues so the
four named slots stay fixed — users can change colors but not add or
remove slots). Each slot opens the standard WP color picker popover
on click. Drafts still flow through the existing isPublicationDirty +
Update publication button machinery.
Empty theme slots render as transparent in PaletteEdit (clean visual
'unset' state). On change, map transparent back to '' so the existing
empty-string semantics in the backend keep working — transparent
never gets persisted as an override.
New 'Customize publication colors' toggle replaces the always-visible
palette. When the toggle is off, the four theme_* override keys are
omitted from the saved overrides so the publication record has no
basicTheme. When the toggle flips on, sensible defaults seed any
empty slot (#ffffff / #111111 / #0073aa / #ffffff) and the PaletteEdit
appears below.

Saved drafts persist across toggle flips so a user can quickly toggle
off, save, and toggle back on without losing their color picks
until the next save. isDirty tracks the toggle state too so saving
'off' actually clears the theme on the next request.
… toggle

Records: render publication.basicTheme as a row of ColorIndicators
(background / foreground / accent / accent foreground) with a Tooltip
on each showing the slot name and hex code. RGB → hex helper handles
the byte clamping.

Settings: wrap the Customize publication colors toggle in a
BaseControl labelled 'Publication colors' so the section reads with
the same hierarchy as the rest of the publication card.
Redundant with the 'Publication colors' BaseControl label above it.
- Move Publication URL (and AT-URI if present) up to sit right under
  the description, since URL is a derived identity field rather than
  an editable detail.
- Wrap Publication icon and Publication colors in a .split flex
  container with flex: 1 1 240px on each child. Side-by-side on a
  600px settings page; wraps to stacked when the container gets
  narrower than ~500px.
- Wrap PaletteEdit in a small div + scoped CSS that hides its built-in
  header HStack (an empty <h2> plus a 3-dot menu that's inert with
  canOnlyChangeValues). Cleaner visual.
- Remove the read-only Publication AT-URI field from the Settings
  page. It's debug info that belongs to the Records tab, not the
  editing form.
… post

Match the pattern used by other standard.site publishers (e.g. Jake
Lazaroff): the bsky post stays a normal app.bsky.embed.external link
card so Bluesky users see the usual UX, but external.associatedRefs
carries com.atproto.repo.strongRef entries pointing at the
site.standard.document and site.standard.publication records on the
PDS. Standard.site-aware clients can follow the refs to discover the
long-form content; Bluesky-only users see no difference.

- Publication: new get_strongref() returns { uri, cid } from the
  cached publication record without a network call.
- Bluesky::share_to_bluesky: when standard.site is enabled for the
  post, publish the document up front (with bskyPostRef=null since we
  don't have the bsky URI yet) and use it + the publication strongref
  to populate associatedRefs on the embed. Falls back gracefully to a
  plain link card if either record write fails.
Bluesky's standard.site renderer validates the publication's
basicTheme strictly. We were:

1. Putting $type 'site.standard.theme.basic' at the top of basicTheme
   — wrong: that level shouldn't have a $type since the parent record
   declares the field type via ref.
2. Omitting $type on each individual color — wrong: each color needs
   '$type: site.standard.theme.color#rgb' as the union discriminator,
   because the color lexicon is an open union (could also be #hsl,
   etc.).

With the bad shape, the publication record failed validation in
Bluesky's appview, so posts that referenced it via associatedRefs
rendered as a plain link card without the publication footer.

Matches the actual shape used by other standard.site publishers
(e.g. jakelazaroff.com).
…t lands

After the bsky post is written we now follow up with a putRecord on
the document, rebuilding the same record with bskyPostRef pointing at
the freshly-created bsky strongRef. Lets standard.site-aware readers
navigate from the document back to the bsky thread (comments, likes,
reposts).

- Standard\Document::update_bsky_ref: rebuilds the record (same
  publication_uri + cover blob + everything else) plus bskyPostRef
  and calls put_record at the existing rkey. Updates the cached CID
  on the post meta so future reads stay in sync.
- Bluesky::share_to_bluesky: after a successful bsky post, when
  associated_refs were attached (i.e. document was published up
  front), call update_bsky_ref. Failures only log; the bsky share
  is already in the bag.
The back-fill writes a new document revision (new CID) AFTER the
bsky post is already on PDS with the OLD doc CID in its
associatedRefs[0]. Bluesky's appview is strict about the document
strongRef CID matching the current record and refuses to render the
publication footer when it's stale.

Jake Lazaroff's working setup skips bskyPostRef entirely — it's
optional per the lexicon. The Document::update_bsky_ref method stays
in place (unused) in case we ever find a way to update both records
atomically without invalidating the CID match.
…bskyPostRef back-fill

The publication failed validation in Bluesky's renderer because we
dropped the top-level '$type: site.standard.theme.basic' on the
basicTheme object earlier. The spec shows both discriminators are
required: $type on basicTheme itself AND $type on each color value.

With the corrected basicTheme, the publication record validates, the
publication footer renders, and the bskyPostRef back-fill on the
document is safe to re-enable — Bluesky was rejecting the WHOLE
publication, not the stale doc CID, so the back-fill wasn't actually
the cause of the failure.

Now matches the official site.standard.publication example exactly.
@danielpost
danielpost force-pushed the feat/standard-site branch from bde5e20 to c15364a Compare May 31, 2026 18:29
@danielpost
danielpost merged commit b9e9d14 into main May 31, 2026
2 checks passed
@danielpost
danielpost deleted the feat/standard-site branch May 31, 2026 19:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant