8e135ed0ecdffb97e6936ab6a61ce843deb7f91d diff --git a/content/blog/sama-v2-wordpress-plugin-rebuilt.md b/content/blog/sama-v2-wordpress-plugin-rebuilt.md new file mode 100644 index 0000000000000000000000000000000000000000..b50268a6d20b29feafc656a555e0d8b78754be32 --- /dev/null +++ b/content/blog/sama-v2-wordpress-plugin-rebuilt.md @@ -0,0 +1,328 @@ +# The Open Graph plugin, rebuilt under SAMA v2 — a thought experiment + +[Yesterday's audit](/blog/sama-v2-wordpress-plugin-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 + +```toml +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](/sama/v2/example-wordpress) 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: + +```php +// 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: + +```php +// 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 `MetaTag`s. 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. + +```php +// 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. + +```php +// 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](/blog/sama-v2-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](/blog/sama-v2-wordpress-plugin-audit) — pointing v2 at the real plugin: 0 of 7 checks pass, why that's the expected baseline +- [The generic WP profile](/sama/v2/example-wordpress) — a hypothetical event-registration plugin under v2 from scratch +- [The §5 metrics emitter](/blog/sama-v2-metrics-emitter) — the empirical artefact §6 requires before any later claim can be measured as a delta +- [The v2 verifier post](/blog/sama-v2-verifier-and-the-rename) — yesterday's piece on building the verifier itself diff --git a/src/a31_blog.ts b/src/a31_blog.ts index 5f7abbec7804bef7ad2d0d07169d7f9b857162ea..5fef265cacf6e548057779e4e857308985b1b147 100644 --- a/src/a31_blog.ts +++ b/src/a31_blog.ts @@ -12,6 +12,12 @@ export interface BlogEntry { } export const ALL_POSTS: BlogEntry[] = [ + { + slug: "sama-v2-wordpress-plugin-rebuilt", + title: "The Open Graph plugin, rebuilt under SAMA v2 — a thought experiment", + description: "Yesterday's audit showed the Open Graph plugin scores 0 of 7 §4 checks. The companion question Bas asked: what would the same plugin look like if it had been built under v2 from day one? This post is the parallel-architecture sketch — same scope, same features, same user-facing behaviour, just laid out so a PHP-aware verifier would report 7/7. The 1,554-line public god-class becomes eleven 150-300 line files, each with a sibling test. The 784-line admin class becomes six. The 43 raw $_POST/$_GET accesses collapse into ONE c3_admin_form_controller.php; superglobals appear nowhere else. $wpdb lives in exactly three repository files. Outbound HTTP (Facebook Graph cache-clear, locale XML, image probe) lives in three gateway files. Estimated §5 metric deltas vs the original: workingSetFit ~95% (vs ~47%), boundaryRatio 100% (vs <10%), graphDepth ~5 (vs ~3), violationCounts 0 (vs 17+). Honest framing: this is a sketch, not a PR. The §5 deltas are predictions, not measurements — the PHP-aware verifier doesn't exist yet and the plugin would have to actually be refactored for the numbers to be empirical. But the sketch makes 100% v2 compliant concrete enough that readers can map it onto their own code, and gives any future v2-discipline plugin a measurable baseline to be compared against.", + date: "2026-05-23", + }, { slug: "sama-v2-wordpress-plugin-audit", title: "Pointing SAMA v2 at a real WordPress plugin in the wild",