syntaxai/tdd.md · main · content / blog / sama-v2-wordpress-plugin-rebuilt.md

sama-v2-wordpress-plugin-rebuilt.md 329 lines · 18775 bytes raw · source

The Open Graph plugin, rebuilt under SAMA v2 — a thought experiment

Yesterday's audit walked through what Open Graph and Twitter Card Tags (slug wonderm00ns-simple-facebook-open-graph-tags, 200k+ installs) looks like through the v2 lens: three god-classes totaling 3,012 lines, 43 raw superglobal accesses, $wpdb scattered across layers, zero tests, 0 of 7 §4 checks passing. The post argued — and I still believe — that this score isn't a failure; it's the expected baseline for a plugin built under standard WordPress idioms with no external layer discipline.

Bas asked the follow-up the audit didn't answer: what would the same plugin look like if it had been built under v2 from day one?

This post is a parallel-architecture sketch. Same scope. Same features. Same user-facing behavior. Same plugin metadata. Just laid out so that a PHP-aware v2 verifier would report 7/7 ✓ instead of 0/7. The point isn't to argue the original maintainers should refactor — they shouldn't, on the basis of one blog post. The point is to make "100% v2 compliant" concrete enough that readers can map it onto their own code.

What stays exactly the same

Before the architecture, the contract that doesn't move:

  • The plugin still emits og:*, twitter:*, and Schema.org JSON-LD on the public side.
  • Admin UI still has tabs for General / Facebook / Twitter / Schema / SEO / 3rd-party / Tools.
  • All current settings keys (fb_image_use_specific, fb_twitter_card_type, etc.) stay valid; existing installs upgrade without losing their config.
  • WooCommerce product handling, Yoast/AIOSEO compatibility, Facebook locale lookup, FB cache-clear on save — all still there.
  • The plugin header in wonderm00n-open-graph.php is unchanged; WordPress still recognizes it.

What changes is where each piece of work lives in the source tree, not what the user gets.

The profile

sama_version = "2.0"
profile = "og-plugin"

[layers.0]
prefixes = ["a_"]

[layers.1]
sublayers = [
  { name = "policy",  prefix = "b1_" },
  { name = "service", prefix = "b2_" },
]

[layers.2]
sublayers = [
  { name = "repository", prefix = "c1_" },   # WP options + post meta + transients
  { name = "gateway",    prefix = "c2_" },   # outbound HTTP (Facebook API, locale XML, image probe)
  { name = "controller", prefix = "c3_" },   # parses admin form posts + extracts PageContext from WP request
]

[layers.3]
prefixes = ["d_"]

Same shape as the generic wordpress-plugin profile on this site — confirming that one profile actually does generalize to this domain.

The directory

wonderm00ns-simple-facebook-open-graph-tags/
├── wonderm00n-open-graph.php              # 5 lines: WP header + require __DIR__.'/src/d_main.php'
├── src/
│   ├── a_meta_tag.php                     # MetaTag value type (name|property + content + emitter)
│   ├── a_page_context.php                 # PageContext: title, desc, image, locale, type, url, dates
│   ├── a_settings.php                     # typed Settings (replaces the current mixed-array option)
│   ├── a_image_descriptor.php             # ImageDescriptor: url + width + height + mime
│   ├── a_locale.php                       # Locale value type (WP code + FB code mapping)
│   ├── a_post_target.php                  # PostTarget: post_id, post_type, is_home, is_archive
│   ├── a_ports.php                        # interfaces: SettingsRepo, PostMetaRepo, ImageCacheRepo,
│   │                                      #             FacebookGateway, LocaleXmlGateway,
│   │                                      #             ImageProbeGateway, Clock
│   │
│   ├── b1_image_selection_policy.php      # which image: specific > featured > content > media > default
│   ├── b1_description_policy.php          # char limit + fallback chain (excerpt > content > default)
│   ├── b1_locale_policy.php               # WP locale → FB locale code
│   ├── b1_tag_visibility_policy.php       # settings-driven: which tags to emit on which page types
│   ├── b1_url_policy.php                  # canonical URL + trailing-slash handling
│   │
│   ├── b2_og_tag_service.php              # build OpenGraphTags from (PageContext, Settings)
│   ├── b2_twitter_tag_service.php         # build TwitterCardTags
│   ├── b2_schema_tag_service.php          # build SchemaOrgTags JSON-LD
│   ├── b2_woocommerce_service.php         # WC product → PageContext enrichment
│   ├── b2_settings_service.php            # read/write settings with validation
│   ├── b2_facebook_cache_service.php      # clear FB cache after post save (orchestrates the gateway call)
│   │
│   ├── c1_settings_repo.php               # wraps get_option/update_option for 'wonderm00n_open_graph_settings'
│   ├── c1_post_meta_repo.php              # wraps get/update_post_meta for per-post overrides
│   ├── c1_image_cache_repo.php            # wraps transients for image-size cache
│   │
│   ├── c2_facebook_gateway.php            # FB Graph debug API call via wp_remote_post
│   ├── c2_locale_xml_gateway.php          # bundled FacebookLocales.xml + optional refresh from facebook.com
│   ├── c2_image_probe_gateway.php         # wp_remote_get HEAD to determine image dimensions
│   │
│   ├── c3_admin_form_controller.php       # parses settings save $_POST into a typed Settings update
│   ├── c3_page_context_controller.php     # extracts PageContext from the current WP request
│   │                                      #   (get_queried_object, $post, is_singular, ...)
│   │
│   ├── d_main.php                         # boots autoloader, constructs adapters, wires deps
│   ├── d_frontend_hooks.php               # wp_head: emit OG/Twitter/Schema tags
│   ├── d_admin_hooks.php                  # admin_menu, admin_init, settings page registration
│   ├── d_admin_pages_view.php             # the admin tab markup (was 2,067 LOC across 11 files)
│   ├── d_post_save_hooks.php              # save_post → b2_facebook_cache_service.clearForUrl()
│   ├── d_woocommerce_hooks.php            # WC-specific filters
│   └── d_third_party_hooks.php            # Yoast/AIOSEO/etc. compatibility filters
│
├── lang/                                  # unchanged: .mo/.po translation files
├── assets/                                # unchanged: admin CSS + JS bundles
└── tests/                                 # NEW: PHPUnit, one *.test.php per b*_ and c*_ file
    ├── b1_image_selection_policy.test.php
    ├── b2_og_tag_service.test.php
    ├── ... (one per source file in layers 1 and 2)

Approximately 31 source files in src/ (vs the original 17 non-vendor files), plus ~20 sibling test files. Roughly the same total LOC as today (6,000-7,000), redistributed: no file over the 700-LOC cap, most files in the 100-300 range.

Layer 0 — what stops being implicit

The original plugin carries its domain types as untyped arrays. The settings option is a mixed array of ints and strings keyed by fb_* prefixes; what an emitted tag looks like is buried in string concatenations inside the public class.

Under v2, those become explicit types:

// a_meta_tag.php
final class MetaTag {
    public function __construct(
        public readonly string $kind,          // 'property' | 'name' | 'itemprop'
        public readonly string $key,           // 'og:title', 'twitter:card', ...
        public readonly string $content,
    ) {}
}

// a_page_context.php — everything the tag services need to know about
// the page being rendered, in one immutable struct
final class PageContext {
    public function __construct(
        public readonly PostTarget $target,
        public readonly string $title,
        public readonly string $description,
        public readonly string $url,
        public readonly ?ImageDescriptor $image,
        public readonly Locale $locale,
        public readonly string $type,
        public readonly ?string $publishedAtIso,
        public readonly ?string $modifiedAtIso,
        public readonly ?string $authorName,
        public readonly array $sections,
    ) {}
}

// a_ports.php — interfaces Layer 1 calls through
interface SettingsRepository {
    public function load(): Settings;
    public function save(Settings $settings): void;
}

interface FacebookGateway {
    public function clearCacheForUrl(string $url): FacebookResult;
}

interface ImageProbeGateway {
    public function probe(string $imageUrl): ?ImageDescriptor;
}

interface Clock { public function nowIso(): string; }

Compare to the original: class-webdados-fb-open-graph.php lines 141-195 declare the default options as a 60-key associative array with implicit typing ('fb_image_use_specific' => 1 could mean a boolean, could mean a count, the reader has to infer). Under v2, Settings is a class with typed properties; the verifier has something to check imports against.

No file in Layer 0 touches WordPress. No $wpdb, no get_option, no current_time — the Clock port exists exactly so Layer 0/1 stay pure.

Layer 1 — what the policy/service split buys

The original public/class-webdados-fb-open-graph-public.php (1,554 lines) does seven things at once: image URL resolution, OG-tag emission, Twitter-Card emission, Schema.org JSON-LD emission, WooCommerce product handling, third-party SEO compatibility (Yoast, AIOSEO), and WP filter callbacks that wire it all up. Reading that file requires holding all seven contexts in mind simultaneously.

Under v2 they split into eleven files, each ~150-300 lines, each with a sibling test:

// b1_image_selection_policy.php — pure decision, no I/O
final class ImageSelectionPolicy {
    public static function pick(PageContext $ctx, Settings $settings, array $candidates): ?ImageDescriptor {
        // candidates is the result of a query to the database/post-meta —
        // selection logic only operates on the already-fetched list
        if ($settings->useSpecific && ($candidates['specific'] ?? null) !== null) return $candidates['specific'];
        if ($settings->useFeatured && ($candidates['featured'] ?? null) !== null) return $candidates['featured'];
        if ($settings->useContent  && ($candidates['content']  ?? null) !== null) return $candidates['content'];
        if ($settings->useMedia    && ($candidates['media']    ?? null) !== null) return $candidates['media'];
        if ($settings->useDefault  && ($candidates['default']  ?? null) !== null) return $candidates['default'];
        return null;
    }
}

// b2_og_tag_service.php — orchestrator. Takes ports at construction.
final class OgTagService {
    public function __construct(
        private SettingsRepository $settings,
        private PostMetaRepository $postMeta,
        private ImageProbeGateway $imageProbe,
        private TagVisibilityPolicy $visibility,
    ) {}

    public function buildTagsFor(PageContext $ctx): array {
        $settings = $this->settings->load();
        if (!$this->visibility->shouldEmitOg($ctx, $settings)) return [];
        $tags = [];
        if ($settings->titleShow)    $tags[] = new MetaTag('property', 'og:title', $ctx->title);
        if ($settings->urlShow)      $tags[] = new MetaTag('property', 'og:url', $ctx->url);
        if ($settings->descShow)     $tags[] = new MetaTag('property', 'og:description', $ctx->description);
        if ($settings->imageShow && $ctx->image)
                                     $tags[] = new MetaTag('property', 'og:image', $ctx->image->url);
        if ($settings->localeShow)   $tags[] = new MetaTag('property', 'og:locale', $ctx->locale->fbCode);
        // ... ~25 lines, each line one well-named conditional
        return $tags;
    }
}

The OgTagService knows nothing about HTTP, the database, $wpdb, or WordPress at all. It takes a PageContext and Settings (both Layer 0 values), returns an array of MetaTags. It's testable with one fixture and one assertion. Today's public class can't be tested without booting WordPress.

Layer 2 — every boundary in exactly one place

The audit counted 43 raw $_POST / $_GET / $_REQUEST accesses. Under v2 there's exactly one file that touches superglobals: c3_admin_form_controller.php. Everywhere else, code receives typed values.

// c3_page_context_controller.php — turns the current WP request into PageContext.
// THIS is the only file that calls get_queried_object, $post, is_singular, etc.
final class PageContextController {
    public function __construct(
        private PostMetaRepository $postMeta,
        private LocalePolicy $localePolicy,
        private ImageProbeGateway $imageProbe,
        private Clock $clock,
    ) {}

    public function extract(): PageContext {
        global $post;
        $queried = get_queried_object();
        $target = new PostTarget(
            postId: $post->ID ?? 0,
            postType: get_post_type() ?: '',
            isHome: is_front_page(),
            isArchive: is_archive(),
        );
        // ... build PageContext field by field, with all the WP boundary calls
        // (get_the_title, get_permalink, get_the_excerpt, $post->post_modified, ...)
        // happening here ONLY
    }
}

// c1_settings_repo.php
final class WpOptionSettingsRepository implements SettingsRepository {
    public function load(): Settings {
        $raw = get_option('wonderm00n_open_graph_settings', []);
        // map raw array to typed Settings, validating + applying defaults
    }
    public function save(Settings $settings): void {
        update_option('wonderm00n_open_graph_settings', $settings->toArray());
    }
}

// c2_facebook_gateway.php — outbound HTTP, isolated
final class FacebookGraphGateway implements FacebookGateway {
    public function __construct(private string $appId, private string $appSecret) {}

    public function clearCacheForUrl(string $url): FacebookResult {
        $response = wp_remote_post(
            "https://graph.facebook.com/?id={$url}&scrape=true&access_token={$this->appId}|{$this->appSecret}"
        );
        if (is_wp_error($response)) return FacebookResult::error($response->get_error_message());
        $body = json_decode(wp_remote_retrieve_body($response), true);
        return is_array($body) ? FacebookResult::ok($body) : FacebookResult::error('non-JSON body');
    }
}

The Modeled-boundary check (§4.4) is satisfied by construction: JSON.parse, new URL, raw $_POST, wp_remote_*, $wpdb — every one of those tokens grep would find appears only inside a c[123]_*.php file. Any other layer doing the same thing is a verifier violation.

Layer 3 — wiring only

Hook registrations live in d_*.php. Every callback is the same shape: parse → service → return.

// d_frontend_hooks.php
add_action('wp_head', function () {
    global $og_plugin_deps;
    $ctx = $og_plugin_deps->pageContext->extract();
    $ogTags     = $og_plugin_deps->ogService->buildTagsFor($ctx);
    $twTags     = $og_plugin_deps->twService->buildTagsFor($ctx);
    $schemaTags = $og_plugin_deps->schemaService->buildTagsFor($ctx);
    echo render_meta_tags($ogTags);                // pure string concat from MetaTag[]
    echo render_meta_tags($twTags);
    echo render_schema_jsonld($schemaTags);
}, 5);

// d_post_save_hooks.php
add_action('save_post', function (int $postId) {
    if (wp_is_post_revision($postId)) return;
    global $og_plugin_deps;
    $url = get_permalink($postId);
    $og_plugin_deps->fbCacheService->clearForUrl($url);    // b2 service, not gateway directly
}, 10, 1);

Notice the d_post_save_hooks.php callback: it talks to a Layer 1 service (b2_facebook_cache_service), not directly to the Layer 2 gateway. That's not strictly required by the verifier (d_ files may import from any lower layer), but it's the kind of thing the §5 violation count "trailing signal" surfaces. A direct c2_* call from a hook is a sign that the operation has no business rules yet — the moment one shows up ("don't notify FB if the post is a draft"), the call site has to move into a service anyway.

What the §5 metrics would look like

Estimated for the refactored plugin, alongside what the original would report:

metric original plugin v2-refactored plugin tdd.md (measured)
§4 checks passing 0 / 7 7 / 7 7 / 7
graphDepth ~3 ~5 (entry → service → policy → port → adapter) 7
boundaryRatio <10% 100% 100%
workingSetFit (50–500 LOC) ~47% ~95% 80%
violationCounts (sum) 17+ 0 0

The workingSetFit rises from ~47% to ~95% because the 1,554-line god-class becomes eleven 150-300 line files, the 784-line admin class becomes six files, and the 11 admin UI files consolidate into one view file plus a controller. Almost everything lands in the sweet spot.

What this isn't

This is a thought experiment, not a pull request.

  • The original plugin's maintainers know their codebase and their users; they have ten years of incidents and edge cases in their heads that this sketch ignores. A real refactor would take months and risk breaking compatibility with the PRO add-on, with WooCommerce, with Yoast.
  • The §5 deltas in the table above are predictions, not measurements. The plugin would have to be actually refactored, the PHP-aware verifier built, and the metrics computed for the numbers to be empirical. Today they're informed guesses.
  • "100% v2 compliant" doesn't mean "better." It means "every architectural decision is visible in the file tree, every boundary is in one place, every business rule has a unit test that fails for the right reason." Whether that's worth ten months of work for a 200k-install plugin that already does its job is the maintainer's call, not mine.

What the sketch buys is the same thing the §5 metrics emitter bought: now there's a concrete description of "the same plugin under v2." If the maintainers ever do this refactor — or if someone forks the plugin to build v2-discipline-from-scratch — the deltas are measurable on the axes both versions share. That's the kind of comparison §6 of the spec actually requires before claiming v2 is worth following.


Companion posts:

  • The audit — pointing v2 at the real plugin: 0 of 7 checks pass, why that's the expected baseline
  • The generic WP profile — a hypothetical event-registration plugin under v2 from scratch
  • The §5 metrics emitter — the empirical artefact §6 requires before any later claim can be measured as a delta
  • The v2 verifier post — yesterday's piece on building the verifier itself