syntaxai/tdd.md · commit 8e135ed

Blog: the Open Graph plugin, rebuilt under SAMA v2 (thought experiment)

The audit post yesterday showed the real plugin scoring 0 of 7 §4
checks. This post answers the companion question Bas asked: what
would the SAME plugin look like if it had been built under v2 from
day one?

Parallel-architecture sketch — same scope, same features, same user-
facing behaviour, same plugin metadata, same settings keys (existing
installs upgrade without losing config). Just laid out so a PHP-aware
verifier would report 7/7 instead of 0/7:

- 1,554-line public god-class → eleven 150-300 line files (one per
  concern: OG tags, Twitter Cards, Schema.org, image selection
  policy, locale policy, etc.) each with a sibling test
- 784-line admin class → six files
- 43 raw $_POST accesses → one c3_admin_form_controller.php
- $wpdb usage → three c1_*_repo.php files only
- Outbound HTTP → three c2_*_gateway.php files
- Hook registrations → six d_*_hooks.php files, callbacks are
  three-line compositions

Estimated §5 metric deltas: workingSetFit ~95% (vs ~47%),
boundaryRatio 100% (vs <10%), graphDepth ~5 (vs ~3), violationCounts
0 (vs 17+).

Honest framing: thought experiment, not a PR. The deltas are
predictions not measurements (PHP-aware verifier doesn't exist yet).
The sketch's value is making "100% v2 compliant" concrete enough
that readers can map it onto their own code, and giving any future
v2-discipline plugin a measurable baseline to compare against.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
author
syntaxai <[email protected]>
date
2026-05-23 14:54:18 +01:00
parent
164bfe2
commit
8e135ed0ecdffb97e6936ab6a61ce843deb7f91d

2 files changed · +334 −0

added content/blog/sama-v2-wordpress-plugin-rebuilt.md +328 −0
@@ -0,0 +1,328 @@
1+# The Open Graph plugin, rebuilt under SAMA v2 — a thought experiment
2+
3+[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.
4+
5+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?**
6+
7+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.
8+
9+## What stays exactly the same
10+
11+Before the architecture, the contract that doesn't move:
12+
13+- The plugin still emits `og:*`, `twitter:*`, and Schema.org JSON-LD on the public side.
14+- Admin UI still has tabs for General / Facebook / Twitter / Schema / SEO / 3rd-party / Tools.
15+- All current settings keys (`fb_image_use_specific`, `fb_twitter_card_type`, etc.) stay valid; existing installs upgrade without losing their config.
16+- WooCommerce product handling, Yoast/AIOSEO compatibility, Facebook locale lookup, FB cache-clear on save — all still there.
17+- The plugin header in `wonderm00n-open-graph.php` is unchanged; WordPress still recognizes it.
18+
19+What changes is *where each piece of work lives in the source tree*, not what the user gets.
20+
21+## The profile
22+
23+```toml
24+sama_version = "2.0"
25+profile = "og-plugin"
26+
27+[layers.0]
28+prefixes = ["a_"]
29+
30+[layers.1]
31+sublayers = [
32+ { name = "policy", prefix = "b1_" },
33+ { name = "service", prefix = "b2_" },
34+]
35+
36+[layers.2]
37+sublayers = [
38+ { name = "repository", prefix = "c1_" }, # WP options + post meta + transients
39+ { name = "gateway", prefix = "c2_" }, # outbound HTTP (Facebook API, locale XML, image probe)
40+ { name = "controller", prefix = "c3_" }, # parses admin form posts + extracts PageContext from WP request
41+]
42+
43+[layers.3]
44+prefixes = ["d_"]
45+```
46+
47+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.
48+
49+## The directory
50+
51+```
52+wonderm00ns-simple-facebook-open-graph-tags/
53+├── wonderm00n-open-graph.php # 5 lines: WP header + require __DIR__.'/src/d_main.php'
54+├── src/
55+│ ├── a_meta_tag.php # MetaTag value type (name|property + content + emitter)
56+│ ├── a_page_context.php # PageContext: title, desc, image, locale, type, url, dates
57+│ ├── a_settings.php # typed Settings (replaces the current mixed-array option)
58+│ ├── a_image_descriptor.php # ImageDescriptor: url + width + height + mime
59+│ ├── a_locale.php # Locale value type (WP code + FB code mapping)
60+│ ├── a_post_target.php # PostTarget: post_id, post_type, is_home, is_archive
61+│ ├── a_ports.php # interfaces: SettingsRepo, PostMetaRepo, ImageCacheRepo,
62+│ │ # FacebookGateway, LocaleXmlGateway,
63+│ │ # ImageProbeGateway, Clock
64+│ │
65+│ ├── b1_image_selection_policy.php # which image: specific > featured > content > media > default
66+│ ├── b1_description_policy.php # char limit + fallback chain (excerpt > content > default)
67+│ ├── b1_locale_policy.php # WP locale → FB locale code
68+│ ├── b1_tag_visibility_policy.php # settings-driven: which tags to emit on which page types
69+│ ├── b1_url_policy.php # canonical URL + trailing-slash handling
70+│ │
71+│ ├── b2_og_tag_service.php # build OpenGraphTags from (PageContext, Settings)
72+│ ├── b2_twitter_tag_service.php # build TwitterCardTags
73+│ ├── b2_schema_tag_service.php # build SchemaOrgTags JSON-LD
74+│ ├── b2_woocommerce_service.php # WC product → PageContext enrichment
75+│ ├── b2_settings_service.php # read/write settings with validation
76+│ ├── b2_facebook_cache_service.php # clear FB cache after post save (orchestrates the gateway call)
77+│ │
78+│ ├── c1_settings_repo.php # wraps get_option/update_option for 'wonderm00n_open_graph_settings'
79+│ ├── c1_post_meta_repo.php # wraps get/update_post_meta for per-post overrides
80+│ ├── c1_image_cache_repo.php # wraps transients for image-size cache
81+│ │
82+│ ├── c2_facebook_gateway.php # FB Graph debug API call via wp_remote_post
83+│ ├── c2_locale_xml_gateway.php # bundled FacebookLocales.xml + optional refresh from facebook.com
84+│ ├── c2_image_probe_gateway.php # wp_remote_get HEAD to determine image dimensions
85+│ │
86+│ ├── c3_admin_form_controller.php # parses settings save $_POST into a typed Settings update
87+│ ├── c3_page_context_controller.php # extracts PageContext from the current WP request
88+│ │ # (get_queried_object, $post, is_singular, ...)
89+│ │
90+│ ├── d_main.php # boots autoloader, constructs adapters, wires deps
91+│ ├── d_frontend_hooks.php # wp_head: emit OG/Twitter/Schema tags
92+│ ├── d_admin_hooks.php # admin_menu, admin_init, settings page registration
93+│ ├── d_admin_pages_view.php # the admin tab markup (was 2,067 LOC across 11 files)
94+│ ├── d_post_save_hooks.php # save_post → b2_facebook_cache_service.clearForUrl()
95+│ ├── d_woocommerce_hooks.php # WC-specific filters
96+│ └── d_third_party_hooks.php # Yoast/AIOSEO/etc. compatibility filters
97+│
98+├── lang/ # unchanged: .mo/.po translation files
99+├── assets/ # unchanged: admin CSS + JS bundles
100+└── tests/ # NEW: PHPUnit, one *.test.php per b*_ and c*_ file
101+ ├── b1_image_selection_policy.test.php
102+ ├── b2_og_tag_service.test.php
103+ ├── ... (one per source file in layers 1 and 2)
104+```
105+
106+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.
107+
108+## Layer 0 — what stops being implicit
109+
110+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.
111+
112+Under v2, those become explicit types:
113+
114+```php
115+// a_meta_tag.php
116+final class MetaTag {
117+ public function __construct(
118+ public readonly string $kind, // 'property' | 'name' | 'itemprop'
119+ public readonly string $key, // 'og:title', 'twitter:card', ...
120+ public readonly string $content,
121+ ) {}
122+}
123+
124+// a_page_context.php — everything the tag services need to know about
125+// the page being rendered, in one immutable struct
126+final class PageContext {
127+ public function __construct(
128+ public readonly PostTarget $target,
129+ public readonly string $title,
130+ public readonly string $description,
131+ public readonly string $url,
132+ public readonly ?ImageDescriptor $image,
133+ public readonly Locale $locale,
134+ public readonly string $type,
135+ public readonly ?string $publishedAtIso,
136+ public readonly ?string $modifiedAtIso,
137+ public readonly ?string $authorName,
138+ public readonly array $sections,
139+ ) {}
140+}
141+
142+// a_ports.php — interfaces Layer 1 calls through
143+interface SettingsRepository {
144+ public function load(): Settings;
145+ public function save(Settings $settings): void;
146+}
147+
148+interface FacebookGateway {
149+ public function clearCacheForUrl(string $url): FacebookResult;
150+}
151+
152+interface ImageProbeGateway {
153+ public function probe(string $imageUrl): ?ImageDescriptor;
154+}
155+
156+interface Clock { public function nowIso(): string; }
157+```
158+
159+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.
160+
161+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.
162+
163+## Layer 1 — what the policy/service split buys
164+
165+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.
166+
167+Under v2 they split into eleven files, each ~150-300 lines, each with a sibling test:
168+
169+```php
170+// b1_image_selection_policy.php — pure decision, no I/O
171+final class ImageSelectionPolicy {
172+ public static function pick(PageContext $ctx, Settings $settings, array $candidates): ?ImageDescriptor {
173+ // candidates is the result of a query to the database/post-meta —
174+ // selection logic only operates on the already-fetched list
175+ if ($settings->useSpecific && ($candidates['specific'] ?? null) !== null) return $candidates['specific'];
176+ if ($settings->useFeatured && ($candidates['featured'] ?? null) !== null) return $candidates['featured'];
177+ if ($settings->useContent && ($candidates['content'] ?? null) !== null) return $candidates['content'];
178+ if ($settings->useMedia && ($candidates['media'] ?? null) !== null) return $candidates['media'];
179+ if ($settings->useDefault && ($candidates['default'] ?? null) !== null) return $candidates['default'];
180+ return null;
181+ }
182+}
183+
184+// b2_og_tag_service.php — orchestrator. Takes ports at construction.
185+final class OgTagService {
186+ public function __construct(
187+ private SettingsRepository $settings,
188+ private PostMetaRepository $postMeta,
189+ private ImageProbeGateway $imageProbe,
190+ private TagVisibilityPolicy $visibility,
191+ ) {}
192+
193+ public function buildTagsFor(PageContext $ctx): array {
194+ $settings = $this->settings->load();
195+ if (!$this->visibility->shouldEmitOg($ctx, $settings)) return [];
196+ $tags = [];
197+ if ($settings->titleShow) $tags[] = new MetaTag('property', 'og:title', $ctx->title);
198+ if ($settings->urlShow) $tags[] = new MetaTag('property', 'og:url', $ctx->url);
199+ if ($settings->descShow) $tags[] = new MetaTag('property', 'og:description', $ctx->description);
200+ if ($settings->imageShow && $ctx->image)
201+ $tags[] = new MetaTag('property', 'og:image', $ctx->image->url);
202+ if ($settings->localeShow) $tags[] = new MetaTag('property', 'og:locale', $ctx->locale->fbCode);
203+ // ... ~25 lines, each line one well-named conditional
204+ return $tags;
205+ }
206+}
207+```
208+
209+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.
210+
211+## Layer 2 — every boundary in exactly one place
212+
213+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.
214+
215+```php
216+// c3_page_context_controller.php — turns the current WP request into PageContext.
217+// THIS is the only file that calls get_queried_object, $post, is_singular, etc.
218+final class PageContextController {
219+ public function __construct(
220+ private PostMetaRepository $postMeta,
221+ private LocalePolicy $localePolicy,
222+ private ImageProbeGateway $imageProbe,
223+ private Clock $clock,
224+ ) {}
225+
226+ public function extract(): PageContext {
227+ global $post;
228+ $queried = get_queried_object();
229+ $target = new PostTarget(
230+ postId: $post->ID ?? 0,
231+ postType: get_post_type() ?: '',
232+ isHome: is_front_page(),
233+ isArchive: is_archive(),
234+ );
235+ // ... build PageContext field by field, with all the WP boundary calls
236+ // (get_the_title, get_permalink, get_the_excerpt, $post->post_modified, ...)
237+ // happening here ONLY
238+ }
239+}
240+
241+// c1_settings_repo.php
242+final class WpOptionSettingsRepository implements SettingsRepository {
243+ public function load(): Settings {
244+ $raw = get_option('wonderm00n_open_graph_settings', []);
245+ // map raw array to typed Settings, validating + applying defaults
246+ }
247+ public function save(Settings $settings): void {
248+ update_option('wonderm00n_open_graph_settings', $settings->toArray());
249+ }
250+}
251+
252+// c2_facebook_gateway.php — outbound HTTP, isolated
253+final class FacebookGraphGateway implements FacebookGateway {
254+ public function __construct(private string $appId, private string $appSecret) {}
255+
256+ public function clearCacheForUrl(string $url): FacebookResult {
257+ $response = wp_remote_post(
258+ "https://graph.facebook.com/?id={$url}&scrape=true&access_token={$this->appId}|{$this->appSecret}"
259+ );
260+ if (is_wp_error($response)) return FacebookResult::error($response->get_error_message());
261+ $body = json_decode(wp_remote_retrieve_body($response), true);
262+ return is_array($body) ? FacebookResult::ok($body) : FacebookResult::error('non-JSON body');
263+ }
264+}
265+```
266+
267+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.
268+
269+## Layer 3 — wiring only
270+
271+Hook registrations live in `d_*.php`. Every callback is the same shape: parse → service → return.
272+
273+```php
274+// d_frontend_hooks.php
275+add_action('wp_head', function () {
276+ global $og_plugin_deps;
277+ $ctx = $og_plugin_deps->pageContext->extract();
278+ $ogTags = $og_plugin_deps->ogService->buildTagsFor($ctx);
279+ $twTags = $og_plugin_deps->twService->buildTagsFor($ctx);
280+ $schemaTags = $og_plugin_deps->schemaService->buildTagsFor($ctx);
281+ echo render_meta_tags($ogTags); // pure string concat from MetaTag[]
282+ echo render_meta_tags($twTags);
283+ echo render_schema_jsonld($schemaTags);
284+}, 5);
285+
286+// d_post_save_hooks.php
287+add_action('save_post', function (int $postId) {
288+ if (wp_is_post_revision($postId)) return;
289+ global $og_plugin_deps;
290+ $url = get_permalink($postId);
291+ $og_plugin_deps->fbCacheService->clearForUrl($url); // b2 service, not gateway directly
292+}, 10, 1);
293+```
294+
295+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.
296+
297+## What the §5 metrics would look like
298+
299+Estimated for the refactored plugin, alongside what the original would report:
300+
301+| metric | original plugin | v2-refactored plugin | tdd.md (measured) |
302+|---|---|---|---|
303+| §4 checks passing | 0 / 7 | **7 / 7** | 7 / 7 |
304+| graphDepth | ~3 | ~5 (entry → service → policy → port → adapter) | 7 |
305+| boundaryRatio | <10% | **100%** | 100% |
306+| workingSetFit (50–500 LOC) | ~47% | **~95%** | 80% |
307+| violationCounts (sum) | 17+ | **0** | 0 |
308+
309+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.
310+
311+## What this isn't
312+
313+This is a thought experiment, not a pull request.
314+
315+- 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.
316+- 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.
317+- "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.
318+
319+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.
320+
321+---
322+
323+**Companion posts:**
324+
325+- [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
326+- [The generic WP profile](/sama/v2/example-wordpress) — a hypothetical event-registration plugin under v2 from scratch
327+- [The §5 metrics emitter](/blog/sama-v2-metrics-emitter) — the empirical artefact §6 requires before any later claim can be measured as a delta
328+- [The v2 verifier post](/blog/sama-v2-verifier-and-the-rename) — yesterday's piece on building the verifier itself
modified src/a31_blog.ts +6 −0
@@ -12,6 +12,12 @@ export interface BlogEntry {
1212 }
1313
1414 export const ALL_POSTS: BlogEntry[] = [
15+ {
16+ slug: "sama-v2-wordpress-plugin-rebuilt",
17+ title: "The Open Graph plugin, rebuilt under SAMA v2 — a thought experiment",
18+ 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.",
19+ date: "2026-05-23",
20+ },
1521 {
1622 slug: "sama-v2-wordpress-plugin-audit",
1723 title: "Pointing SAMA v2 at a real WordPress plugin in the wild",