- Directory Layout
- Core Components
- Namespace Organization
- File Placement Guidelines
- Architectural Patterns
- Class Design Patterns
wordpress-atmosphere/
├── atmosphere.php # Main plugin file (header, bootstrap, autoloader registration).
├── uninstall.php # WordPress uninstall hook — removes meta, options, scheduled events.
│
├── includes/ # Core plugin code.
│ ├── class-atmosphere.php # Plugin orchestration; rewrite rules + well-known handlers; cron registration.
│ ├── class-api.php # DPoP-authenticated PDS request layer with nonce retry.
│ ├── class-autoloader.php # Custom WordPress-style autoloader.
│ ├── class-backfill.php # Bulk re-sync of existing posts.
│ ├── class-block-editor.php # Enqueues the block-editor panels (share toggle + pre-publish).
│ ├── class-handle.php # Domain-handle setup helper (writes /.well-known/atproto-did).
│ ├── class-post-types.php # Supported post-type discovery and option storage.
│ ├── class-publisher.php # Atomic applyWrites for both Bluesky post + standard.site document.
│ ├── class-reaction-sync.php # Mirrors Bluesky reactions back to WordPress comments.
│ ├── functions.php # Helper functions (loaded directly from atmosphere.php).
│ │
│ ├── content-parser/ # Pluggable content formats for site.standard.document.
│ │ ├── class-html.php
│ │ ├── class-leaflet.php
│ │ ├── class-markpub.php
│ │ ├── class-parser-base.php
│ │ ├── class-pckt.php
│ │ ├── class-registry.php
│ │ └── interface-content-parser.php
│ │
│ ├── oauth/ # Native OAuth flow (PKCE + DPoP + PAR).
│ │ ├── class-client.php # OAuth lifecycle (authorize, callback, refresh, disconnect).
│ │ ├── class-dpop.php # ES256 DPoP proof generation.
│ │ ├── class-encryption.php # libsodium token / key encryption at rest.
│ │ ├── class-nonce-storage.php # DPoP nonce persistence.
│ │ └── class-resolver.php # handle → DID → PDS → auth server resolution chain.
│ │
│ ├── rest/ # REST API controllers (WP_REST_Controller subclasses).
│ │ ├── class-client-metadata-controller.php # Public OAuth client-metadata endpoint.
│ │ └── admin/ # Authenticated, editor-only controllers.
│ │ └── class-pre-publish-controller.php # Pre-publish projection for the editor panel.
│ │
│ ├── transformer/ # WordPress → AT Protocol record transformers.
│ │ ├── class-base.php # Abstract base.
│ │ ├── class-comment.php # Comment → app.bsky.feed.post (reply).
│ │ ├── class-document.php # Post → site.standard.document.
│ │ ├── class-facet.php # Detects links, mentions, hashtags in post text.
│ │ ├── class-post.php # Post → app.bsky.feed.post.
│ │ ├── class-publication.php # Site → site.standard.publication.
│ │ └── class-tid.php # AT Protocol Timestamp ID generation.
│ │
│ └── wp-admin/ # Admin screens.
│ ├── class-admin.php # Settings page, OAuth callback, menu wiring.
│ └── class-settings-fields.php # Settings API field rendering.
│
├── integrations/ # Third-party plugin integrations.
│ └── class-load.php # Integration loader stub.
│
├── templates/ # PHP template files.
├── assets/ # CSS and JS.
├── bin/ # Build/release scripts (release.js, install-wp-tests.sh).
├── docs/ # This directory.
└── tests/
└── phpunit/ # PHPUnit tests, mirroring `includes/`.
The main file registers the autoloader, defines ATMOSPHERE_VERSION and path constants, instantiates Atmosphere\Atmosphere, and wires plugin activation / deactivation / uninstall.
Plugin orchestration class:
- Registers rewrite rules +
template_redirecthandlers for/.well-known/atproto-didand/.well-known/site.standard.publication. All share theatmosphere_wellknownquery var. - Registers async cron hooks (
register_async_hooks()) that delegate to the Publisher / Reaction_Sync. - Listens on
transition_post_statusand comment-status transitions to schedule cross-post / update / delete jobs.
DPoP-authenticated PDS request layer:
apply_writes( array $writes )— the only PDS write path. Filters throughatmosphere_pre_apply_writesfirst for test interception.- Handles DPoP nonce retry transparently: on
use_dpop_nonceserver hint, recompute the proof with the new nonce and retry once. - Returns
WP_Errorfor non-2xx PDS responses, withdata.statusset to the HTTP code so callers can branch on transient vs permanent failures.
Atomic batch publish path:
publish_post()— initial publish of a WordPress post (single record + document, or a teaser thread).update_post()— in-place update or destructive rewrite based on whether the new record count matches the stored count.delete_post()/delete_post_by_tids()— removes both Bluesky and document records.publish_comment()— cross-posts a WordPress comment as a Bluesky reply.
Convert WordPress objects to AT Protocol records. Extend Atmosphere\Transformer\Base:
namespace Atmosphere\Transformer;
abstract class Base {
/**
* Build the AT Protocol record array.
*
* @return array
*/
abstract public function transform(): array;
/**
* The collection (NSID) this transformer writes to.
*/
abstract public function get_collection(): string;
/**
* Reserve or return the rkey (TID) for this record.
*
* Writes Post::META_TID (or the equivalent) on first call so
* the rkey is reused across retries.
*/
abstract public function get_rkey(): string;
}Concrete transformers:
| Class | Produces |
|---|---|
Atmosphere\Transformer\Post |
app.bsky.feed.post (short-form + teaser-thread variants). |
Atmosphere\Transformer\Comment |
app.bsky.feed.post reply under a cross-posted record. |
Atmosphere\Transformer\Document |
site.standard.document. |
Atmosphere\Transformer\Publication |
site.standard.publication. |
Atmosphere\Transformer\Facet is a helper, not a record producer — it detects links, mentions, and hashtags in post text. Atmosphere\Transformer\TID generates Timestamp IDs.
Full PKCE + DPoP + PAR native OAuth flow. The handle → DID → PDS → Auth Server resolution chain is implemented across class-resolver.php (resolution) and class-client.php (OAuth lifecycle). DPoP proofs are generated in class-dpop.php (ES256). Tokens and the DPoP private key are encrypted at rest via class-encryption.php (libsodium).
Periodically polls the PDS for notifications and self-collections (app.bsky.feed.like, app.bsky.feed.repost, app.bsky.feed.post) and stores them as WordPress comments. Replies become regular comments; likes and reposts become dedicated comment types so they show up as engagement counts.
Provides the parser registry for the singular content field of site.standard.document records (see docs/content-formats.md). Built-in parsers live in this directory (Html, Markpub, Leaflet, Pckt) and integrations register additional Content_Parser instances with Atmosphere\Content_Parser\Registry::register().
Content_Parser stays intentionally small: get_type() and parse(). Parsers that need WordPress helpers should extend Parser_Base, which provides block-tree access, rendered HTML, image blob helpers, grapheme truncation, and an optional applies_to() method the registry understands. Parsers without applies_to() are treated as applicable for third-party compatibility.
// Root namespace.
namespace Atmosphere;
// Feature namespaces.
namespace Atmosphere\OAuth;
namespace Atmosphere\Transformer;
namespace Atmosphere\Content_Parser;
namespace Atmosphere\Integrations;
namespace Atmosphere\Rest; // Public REST controllers.
namespace Atmosphere\Rest\Admin; // Authenticated, editor-only REST controllers.
namespace Atmosphere\WP_Admin;
namespace Atmosphere\Tests;<?php
namespace Atmosphere;
use Atmosphere\OAuth\Client;
use Atmosphere\Transformer\Post;
use Atmosphere\Transformer\Document;
use function Atmosphere\is_connected;
use function Atmosphere\get_did;
class Publisher {
public static function publish_post( \WP_Post $post ) {
// Imported classes used unqualified.
$bsky = new Post( $post );
$doc = new Document( $post );
// WordPress and PHP globals are backslash-prefixed.
if ( ! is_connected() ) {
return new \WP_Error( 'atmosphere_not_connected', \__( 'Not connected.', 'atmosphere' ) );
}
}
}| Type | Pattern | Example |
|---|---|---|
| Class | class-{name}.php |
class-publisher.php |
| Trait | trait-{name}.php |
trait-singleton.php |
| Interface | interface-{name}.php |
interface-content-parser.php |
| Functions | functions.php |
includes/functions.php |
| Templates | {name}.php |
templates/admin-settings.php |
| Class Type | Location | Namespace |
|---|---|---|
| Core functionality | includes/ |
Atmosphere |
| AT Protocol record transformers | includes/transformer/ |
Atmosphere\Transformer |
| OAuth flow components | includes/oauth/ |
Atmosphere\OAuth |
Content parsers (NSID-typed content producers) |
includes/content-parser/ |
Atmosphere\Content_Parser |
Public REST controllers (WP_REST_Controller) |
includes/rest/ |
Atmosphere\Rest |
| Authenticated/editor-only REST controllers | includes/rest/admin/ |
Atmosphere\Rest\Admin |
| Admin screens | includes/wp-admin/ |
Atmosphere\WP_Admin |
| Third-party plugin integrations | integrations/ |
Atmosphere\Integrations |
Add one when you have:
- Multiple related classes (3+) that form a cohesive subsystem.
- A clear domain boundary (e.g. all OAuth-flow concerns live in
oauth/). - A reason to keep the concerns from leaking into surrounding files.
After adding or renaming a class file under the Atmosphere namespace, no Composer autoload step is needed. The runtime autoloader in includes/class-autoloader.php maps namespace segments to WordPress-style filenames such as class-parser-base.php and interface-content-parser.php.
Every REST endpoint is a WP_REST_Controller subclass under includes/rest/
(public) or includes/rest/admin/ (authenticated / editor-only), mirroring
the wordpress-activitypub plugin's layout. Each controller declares its route
via register_routes(); they are all instantiated together in
Atmosphere::register_rest_controllers() on rest_api_init.
Route namespaces are versioned deliberately:
atmosphere/v1— the public OAuthclient-metadataendpoint. Its URL is the OAuthclient_id, an external contract, so the version string is frozen and must not change.atmosphere/1.0— admin/editor routes (e.g. the pre-publish preview).
New admin routes should use atmosphere/1.0, set show_in_index => false, and
gate access with a permission_callback.
The transformer pattern (includes/transformer/) is the canonical way to add a new AT Protocol record type:
namespace Atmosphere\Transformer;
class Custom_Record extends Base {
public function transform(): array {
return array(
'$type' => 'app.example.record',
'createdAt' => to_iso8601( $this->object->post_date_gmt ),
// ...
);
}
public function get_collection(): string {
return 'app.example.record';
}
public function get_rkey(): string {
$rkey = \get_post_meta( $this->object->ID, self::META_TID, true );
if ( ! $rkey ) {
$rkey = TID::generate();
\update_post_meta( $this->object->ID, self::META_TID, $rkey );
}
return $rkey;
}
}Always reserve the rkey at the start of get_rkey() and persist it via meta. The reserved rkey survives a failed publish and is reused on retry — this is the marker that distinguishes "pristine post" from "failed prior attempt" in Publisher::update_post().
class Feature {
public static function init(): void {
\add_action( 'init', array( self::class, 'register' ) );
\add_filter( 'the_content', array( self::class, 'filter' ) );
}
public static function register(): void {
// Registration logic.
}
}Most plugin classes are static — there's no per-request state worth carrying in instances. Use self::class for callback strings rather than hardcoding the FQCN.
class Manager {
private static ?self $instance = null;
private function __construct() {}
public static function get_instance(): self {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
}Singletons make tests harder; prefer static helper classes unless you genuinely need lazy initialisation.
namespace Atmosphere\Transformer;
class Factory {
public static function for_object( $object ): Base {
if ( $object instanceof \WP_Post ) {
return new Post( $object );
}
if ( $object instanceof \WP_Comment ) {
return new Comment( $object );
}
throw new \InvalidArgumentException( 'Unsupported object type.' );
}
}integrations/class-load.php is the canonical entry point for plugin-specific integrations. Conditional registration based on plugin detection:
namespace Atmosphere\Integrations;
class Load {
public static function init(): void {
\add_action( 'plugins_loaded', array( self::class, 'register' ), 20 );
}
public static function register(): void {
if ( \defined( 'JETPACK__VERSION' ) ) {
Jetpack::init();
}
}
}See integrations/README.md for the full registry pattern and the remaining atmosphere_document_content filter.