<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Akshat Saxena</title>
    <description>The latest articles on DEV Community by Akshat Saxena (@akshat009).</description>
    <link>https://dev.to/akshat009</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4098314%2F96700a0e-acb8-4e81-aef1-214c4dd38c19.png</url>
      <title>DEV Community: Akshat Saxena</title>
      <link>https://dev.to/akshat009</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/akshat009"/>
    <language>en</language>
    <item>
      <title>Scaffold a WordPress plugin with linting, tests and CI already wired</title>
      <dc:creator>Akshat Saxena</dc:creator>
      <pubDate>Wed, 02 Sep 2026 10:59:16 +0000</pubDate>
      <link>https://dev.to/akshat009/scaffold-a-wordpress-plugin-with-linting-tests-and-ci-already-wired-3mbb</link>
      <guid>https://dev.to/akshat009/scaffold-a-wordpress-plugin-with-linting-tests-and-ci-already-wired-3mbb</guid>
      <description>&lt;p&gt;Every WordPress plugin I start begins with the same unglamorous hour: PSR-4&lt;br&gt;
autoloading, a PHPCS ruleset, PHPUnit with mocked WordPress functions, a build&lt;br&gt;
step for the admin JS, the GitHub Actions file. Only then do I get to the feature&lt;br&gt;
I actually opened the editor for.&lt;/p&gt;

&lt;p&gt;I packaged that hour into a generator. You answer a few prompts, tick the pieces&lt;br&gt;
you need, and get a plugin that already lints, tests and ships.&lt;/p&gt;
&lt;h2&gt;
  
  
  One command
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx create-wp-plugin-cli
&lt;span class="c"&gt;# or: npm create wp-plugin-cli&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;No install. Node 20+. Generated plugins target PHP 8.2 (fixed) — constructor&lt;br&gt;
promotion, &lt;code&gt;readonly&lt;/code&gt;, first-class callables throughout.&lt;/p&gt;
&lt;h2&gt;
  
  
  The interactive run
&lt;/h2&gt;

&lt;p&gt;Twelve questions. Most have a pre-filled answer you accept with Enter; the two&lt;br&gt;
that matter are the &lt;strong&gt;lint target&lt;/strong&gt; and the &lt;strong&gt;modules&lt;/strong&gt; multiselect.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ npx create-wp-plugin-cli

🚀 Welcome to create-wp-plugin-cli scaffold generator!

√ 1. Plugin name: … Loyalty Points
√ 2. Plugin slug: … loyalty-points
√ 3. PHP namespace: … LoyaltyPoints
√ 4. Function/constant prefix (&amp;gt;= 4 chars, lowercase): … loyp
√ 5-7. Author name / email / URI …
√ 8. Description (one line): … Award and redeem loyalty points at checkout.
√ 9. Coding standard target for composer lint: › WordPress.org (standard hosting)
√ 10. Include React admin app build pipeline? … no
√ 11. Modules to include (space to toggle):
      › REST API, cron, WP-CLI commands, WooCommerce integration
√ 11a. WooCommerce components: › Payment Gateway, Custom Order Status
√ 12. Output directory: … ./loyalty-points
√ Proceed with these values? … yes

[ok] Successfully scaffolded plugin "Loyalty Points" in ./loyalty-points!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--lint-target&lt;/code&gt; picks the PHPCS ruleset &lt;code&gt;composer lint&lt;/code&gt; enforces: &lt;code&gt;wp-org&lt;/code&gt;&lt;br&gt;
(WordPress-Extra + Docs), &lt;code&gt;vip&lt;/code&gt; (WordPress-VIP-Go), or &lt;code&gt;both&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  What you always get
&lt;/h2&gt;

&lt;p&gt;Regardless of module choices:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/Plugin.php&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bootloader with a re-entry guard; &lt;code&gt;boot()&lt;/code&gt; is the composition root&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;src/Core/Activator.php&lt;/code&gt; / &lt;code&gt;Deactivator.php&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Implement &lt;code&gt;Contracts\Activatable&lt;/code&gt; / &lt;code&gt;Deactivatable&lt;/code&gt;, run from the (de)activation hooks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/Services.php&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Static locator of memoised singletons — &lt;strong&gt;only&lt;/strong&gt; emitted if a module needs a shared collaborator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tests/Unit/Plugin_TestCase.php&lt;/code&gt;, &lt;code&gt;tests/bootstrap.php&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Brain Monkey base — unit tests run with no WordPress install&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;composer.json&lt;/code&gt;, &lt;code&gt;phpcs.xml&lt;/code&gt;, &lt;code&gt;phpunit.xml.dist&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;PHPCS/WPCS(+VIP), PHPUnit, PHPCompatibility against &lt;code&gt;8.2-&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.github/workflows/ci.yml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PHPCS + a PHPUnit 8.2/8.3/8.4 matrix (+ more when there's JS)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  The modules
&lt;/h2&gt;

&lt;p&gt;All opt-in, all freely combinable — nothing here depends on anything else.&lt;/p&gt;
&lt;h3&gt;
  
  
  Admin, content &amp;amp; data
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Module&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;admin_settings&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A Settings API page split into &lt;code&gt;Admin\Settings_Registrar&lt;/code&gt; / &lt;code&gt;Settings_Repository&lt;/code&gt; / a view&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cpt_taxonomy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;PostTypes\Post_Types&lt;/code&gt; — a CPT + taxonomy, wired into activation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;custom_table&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;dbDelta()&lt;/code&gt; schema + &lt;code&gt;Database\Schema&lt;/code&gt; + an &lt;code&gt;Item_Repository&lt;/code&gt;, with version-checked migrations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;caching&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Cache_Service&lt;/code&gt; — a persistent object cache &lt;strong&gt;or&lt;/strong&gt; a transient fallback, chosen per call, never both&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h3&gt;
  
  
  Front-end
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Module&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;shortcode&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A &lt;code&gt;Frontend\Shortcode&lt;/code&gt; class registering one shortcode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;block&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Native Gutenberg block(s): &lt;code&gt;block:dynamic&lt;/code&gt; (&lt;code&gt;render.php&lt;/code&gt;) and/or &lt;code&gt;block:static&lt;/code&gt; (&lt;code&gt;save()&lt;/code&gt;). &lt;code&gt;Blocks\Block_Registrar&lt;/code&gt; globs &lt;code&gt;assets/build/blocks/*&lt;/code&gt;, so more blocks need no PHP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;interactivity&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A WordPress Interactivity API store — &lt;code&gt;view.js&lt;/code&gt; + a Script Module (WP 6.5+)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h3&gt;
  
  
  Integration points
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Module&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rest_api&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A &lt;code&gt;WP_REST_Controller&lt;/code&gt; subclass with a real permission callback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ajax_handler&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A nonce- and capability-guarded &lt;code&gt;admin-ajax&lt;/code&gt; handler + the &lt;code&gt;assets/js/main.js&lt;/code&gt; it enqueues&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cron&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Cron\Scheduler&lt;/code&gt; — a scheduled event with a worked example body&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cli&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;wp &amp;lt;prefix&amp;gt; status&lt;/code&gt; and &lt;code&gt;wp &amp;lt;prefix&amp;gt; cache clear&lt;/code&gt;, behind a &lt;code&gt;defined( 'WP_CLI' )&lt;/code&gt; guard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;elementor_widget&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Widget_Registrar&lt;/code&gt; auto-discovers &lt;code&gt;src/Widgets/*&lt;/code&gt;; convention-based CSS/JS enqueue&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h3&gt;
  
  
  Tooling
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Module&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;editor_config&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.vscode/&lt;/code&gt; snippets, settings, recommended extensions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;integration_tests&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A real &lt;code&gt;wp-phpunit&lt;/code&gt; suite (&lt;code&gt;composer test:integration&lt;/code&gt;), &lt;code&gt;phpunit-integration.xml.dist&lt;/code&gt;, a boot test, &lt;code&gt;.wp-env.json&lt;/code&gt;, and a CI integration job&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h3&gt;
  
  
  WooCommerce
&lt;/h3&gt;

&lt;p&gt;Pass &lt;code&gt;woocommerce&lt;/code&gt; for all nine, or pick components. Each is a &lt;code&gt;Woo\Providers\*&lt;/code&gt;&lt;br&gt;
class wired inside a single &lt;code&gt;class_exists( 'WooCommerce' )&lt;/code&gt; guard, so the plugin&lt;br&gt;
is inert on a site without WooCommerce.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;woo:gateway&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A &lt;code&gt;WC_Payment_Gateway&lt;/code&gt; subclass + a Blocks (block checkout) payment method type&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;woo:shipping&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A &lt;code&gt;WC_Shipping_Method&lt;/code&gt; subclass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;woo:email&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A &lt;code&gt;WC_Email&lt;/code&gt; subclass + HTML and plain-text templates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;woo:order-status&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A custom, HPOS-compliant order status&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;woo:product-type&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A custom product type with data tabs/panels&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;woo:blocks&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cart &amp;amp; Checkout block extensions (front-end script + integration class)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;woo:action-scheduler&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;An Action Scheduler-backed background task runner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;woo:store-api&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A Store API extension via &lt;code&gt;ExtendSchema&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;woo:my-account&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A custom My Account endpoint (route + template)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A JS pipeline (&lt;code&gt;wp-scripts&lt;/code&gt; build + Playwright) is added automatically for&lt;br&gt;
&lt;code&gt;--react&lt;/code&gt;, &lt;code&gt;interactivity&lt;/code&gt;, &lt;code&gt;block&lt;/code&gt;, &lt;code&gt;woo:gateway&lt;/code&gt;, or &lt;code&gt;woo:blocks&lt;/code&gt;. Jest comes&lt;br&gt;
in with &lt;code&gt;--react&lt;/code&gt;. &lt;code&gt;uninstall.php&lt;/code&gt; is derived — it ships only when a selected&lt;br&gt;
module actually persists something (an option, a table, a scheduled event).&lt;/p&gt;
&lt;h2&gt;
  
  
  The architecture, in 60 seconds
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Plugin&lt;/code&gt; is a bootloader with a re-entry guard. Its &lt;code&gt;boot()&lt;/code&gt; is a flat list —&lt;br&gt;
here's the real thing the run above produced:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;boot&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;booted&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;booted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;defined&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WP_CLI'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="no"&gt;WP_CLI&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CLI\Commands&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;init_hooks&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Rest\Rest_Controller&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;init_hooks&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Cron\Scheduler&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;init_hooks&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;class_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WooCommerce'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Woo\Providers\Gateway_Provider&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;init_hooks&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Woo\Providers\Order_Status_Provider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nc"&gt;Services&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;order_status_service&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;init_hooks&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A "module" is a plain class with an &lt;code&gt;init_hooks()&lt;/code&gt; method. No base class, no&lt;br&gt;
interface, no auto-discovery — a class runs only because &lt;code&gt;boot()&lt;/code&gt; names it. CLI&lt;br&gt;
commands sit behind a &lt;code&gt;WP_CLI&lt;/code&gt; check; WooCommerce providers behind the&lt;br&gt;
&lt;code&gt;class_exists&lt;/code&gt; guard.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Services&lt;/code&gt; is emitted only when something needs it — here, because&lt;br&gt;
&lt;code&gt;Order_Status_Provider&lt;/code&gt; takes a &lt;code&gt;Services::order_status_service()&lt;/code&gt; injection.&lt;br&gt;
&lt;code&gt;Gateway_Provider&lt;/code&gt; needs nothing, so it's constructed bare. &lt;code&gt;Services::set()&lt;/code&gt; /&lt;br&gt;
&lt;code&gt;reset()&lt;/code&gt; are the test seams.&lt;/p&gt;
&lt;h2&gt;
  
  
  Tests, per module
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;loyalty-points
composer &lt;span class="nb"&gt;install
&lt;/span&gt;composer &lt;span class="nb"&gt;test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Green on the first run. Every selected module gets its own unit test — the run&lt;br&gt;
above generated &lt;code&gt;Rest_Controller_Test&lt;/code&gt;, &lt;code&gt;Commands_Test&lt;/code&gt;, &lt;code&gt;Gateway_Test&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;Order_Status_Service_Test&lt;/code&gt;, plus &lt;code&gt;Services_Test&lt;/code&gt; and a &lt;code&gt;Module_Hooks_Test&lt;/code&gt; that&lt;br&gt;
asserts each module's &lt;code&gt;init_hooks()&lt;/code&gt; registers exactly the hooks it claims. All&lt;br&gt;
Brain Monkey, no WordPress install.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Gateway_Test&lt;/code&gt;, for instance, checks the gateway instantiates with id&lt;br&gt;
&lt;code&gt;loyp_gateway&lt;/code&gt; and that the unimplemented &lt;code&gt;process_payment()&lt;/code&gt; stub &lt;em&gt;fails&lt;br&gt;
closed&lt;/em&gt; — returns &lt;code&gt;['result' =&amp;gt; 'failure']&lt;/code&gt;, never marks an order paid.&lt;/p&gt;

&lt;p&gt;For anything browser-facing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm run build
npm run &lt;span class="nb"&gt;test&lt;/span&gt;:e2e        &lt;span class="c"&gt;# Playwright — `npx @wordpress/env start` for a target site&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The CI file it wrote
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;.github/workflows/ci.yml&lt;/code&gt;, unprompted:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;phpcs&lt;/strong&gt; — &lt;code&gt;composer validate --strict&lt;/code&gt; + &lt;code&gt;composer lint&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;phpunit&lt;/strong&gt; — a &lt;strong&gt;PHP 8.2 / 8.3 / 8.4&lt;/strong&gt; matrix running &lt;code&gt;composer test&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;node-build&lt;/strong&gt; — &lt;code&gt;npm run build&lt;/code&gt;, JS/CSS lint, then Playwright E2E against a
&lt;code&gt;wp-env&lt;/code&gt; site (only when the selection has a JS side)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;integration&lt;/strong&gt; — the &lt;code&gt;wp-phpunit&lt;/code&gt; suite, when you picked &lt;code&gt;integration_tests&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Using it in scripts
&lt;/h2&gt;

&lt;p&gt;Every answer is a flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx create-wp-plugin-cli &lt;span class="nt"&gt;--yes&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Loyalty Points"&lt;/span&gt; &lt;span class="nt"&gt;--prefix&lt;/span&gt; loyp &lt;span class="nt"&gt;--namespace&lt;/span&gt; LoyaltyPoints &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--modules&lt;/span&gt; &lt;span class="s2"&gt;"rest_api,cron,cli,woo:gateway,woo:order-status"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--lint-target&lt;/span&gt; both &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--out&lt;/span&gt; ./loyalty-points
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Piping in without &lt;code&gt;--yes&lt;/code&gt; and no TTY exits with an error instead of hanging. A&lt;br&gt;
failure partway through generation rolls back the directory it created.&lt;/p&gt;

&lt;h2&gt;
  
  
  When not to use it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A one-file plugin.&lt;/strong&gt; If it's 40 lines in a single file, this is overkill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want a DI container.&lt;/strong&gt; It deliberately doesn't ship one — modules are
plain classes and &lt;code&gt;boot()&lt;/code&gt; is the composition root.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Block-only projects&lt;/strong&gt; are usually better served by &lt;code&gt;@wordpress/create-block&lt;/code&gt;
directly; this wraps it when you also need PHP, tests and CI around the block.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/create-wp-plugin-cli" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/create-wp-plugin-cli&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source (GPL-2.0-or-later): &lt;a href="https://github.com/akshat009/create-wp-plugin-cli" rel="noopener noreferrer"&gt;https://github.com/akshat009/create-wp-plugin-cli&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Issues and PRs welcome.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>A Gutenberg block that uses the Interactivity API — with no render callback and no framework on the front end</title>
      <dc:creator>Akshat Saxena</dc:creator>
      <pubDate>Fri, 28 Aug 2026 04:55:16 +0000</pubDate>
      <link>https://dev.to/akshat009/a-gutenberg-block-that-uses-the-interactivity-api-with-no-render-callback-and-no-framework-on-the-mhb</link>
      <guid>https://dev.to/akshat009/a-gutenberg-block-that-uses-the-interactivity-api-with-no-render-callback-and-no-framework-on-the-mhb</guid>
      <description>&lt;p&gt;A YouTube embed looks innocent enough. &lt;/p&gt;

&lt;p&gt;You drop an &lt;code&gt;iframe&lt;/code&gt; into a page, give it a &lt;code&gt;src&lt;/code&gt;, and you're done, right? &lt;/p&gt;

&lt;p&gt;Except you're not. &lt;/p&gt;

&lt;p&gt;Google's Lighthouse documentation puts a full embedded YouTube player at around &lt;strong&gt;540 KB&lt;/strong&gt;. On a live page, it's closer to &lt;strong&gt;1.3 MB&lt;/strong&gt;. And the cost doesn't get amortized when you add another embed — two players can mean roughly twice the payload because the resources aren't shared.&lt;/p&gt;

&lt;p&gt;HTTP Archive data referenced by web.dev puts another horrifying metric on it: the median YouTube embed occupies the browser's main thread for &lt;strong&gt;more than 1.7 seconds&lt;/strong&gt;. &lt;/p&gt;

&lt;p&gt;And here's the part that bothered me most: &lt;strong&gt;All of that happens even if nobody presses Play.&lt;/strong&gt; 🤯&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution: The Facade Pattern
&lt;/h2&gt;

&lt;p&gt;The fix isn't new. It's the &lt;strong&gt;facade pattern&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Show a poster.&lt;/li&gt;
&lt;li&gt;Show a play button.&lt;/li&gt;
&lt;li&gt;Don't load the actual player.&lt;/li&gt;
&lt;li&gt;Only load it when the visitor clicks.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Tools like &lt;a href="https://github.com/paulirish/lite-youtube-embed" rel="noopener noreferrer"&gt;&lt;code&gt;lite-youtube-embed&lt;/code&gt;&lt;/a&gt; have been doing this for years. &lt;/p&gt;

&lt;p&gt;I wanted to bring this exact same concept into a WordPress Gutenberg block—but not just for YouTube, and &lt;em&gt;definitely&lt;/em&gt; not by shipping an entire JavaScript framework to the browser just to replace a JPEG with an iframe.&lt;/p&gt;

&lt;p&gt;That experiment became the &lt;a href="https://wordpress.org/plugins/story-video-block/" rel="noopener noreferrer"&gt;Story Video Block&lt;/a&gt; (now live on the WP repo, source on &lt;a href="https://github.com/akshat009/story-video-block" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;). &lt;/p&gt;

&lt;p&gt;Here are the most interesting architectural decisions and technical gotchas I ran into while building it. 👇&lt;/p&gt;




&lt;h2&gt;
  
  
  The Interactivity Dilemma
&lt;/h2&gt;

&lt;p&gt;The actual interaction needed for a facade is tiny. &lt;em&gt;Click. Swap in the embed. Play the video.&lt;/em&gt; That's basically it. &lt;/p&gt;

&lt;p&gt;But traditional block solutions can be surprisingly expensive for such a small interaction:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You could enqueue a classic &lt;code&gt;view.js&lt;/code&gt; and manually handle event delegation and DOM manipulation.&lt;/li&gt;
&lt;li&gt;You could ship React to the front end (some block plugins actually do this). You're essentially sending a UI framework to someone's phone so a poster image can turn into an iframe.&lt;/li&gt;
&lt;li&gt;You could use jQuery (please don't).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Enter WordPress's Interactivity API ⚡
&lt;/h3&gt;

&lt;p&gt;Since WordPress 6.5, we have the &lt;strong&gt;Interactivity API&lt;/strong&gt;. It gives blocks a declarative way to add client-side behavior. More importantly, the runtime is shared by all blocks on the page. &lt;/p&gt;

&lt;p&gt;The opt-in is incredibly simple in your &lt;code&gt;block.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"editorScript"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"file:./index.js"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"style"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"file:./style-index.css"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"viewScriptModule"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"file:./view.js"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice one detail: &lt;strong&gt;&lt;code&gt;viewScriptModule&lt;/code&gt;, not &lt;code&gt;viewScript&lt;/code&gt;.&lt;/strong&gt; &lt;br&gt;
That registers the file through the Script Modules API and allows you to natively import the shared WP runtime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@wordpress/interactivity&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Revelation: You Don't Need a Dynamic Block
&lt;/h2&gt;

&lt;p&gt;This was my biggest misconception. Because almost every Interactivity API tutorial uses a dynamic block (&lt;code&gt;render.php&lt;/code&gt;), I assumed they were mandatory. They aren't.&lt;/p&gt;

&lt;p&gt;The directives are just HTML attributes. A static block's &lt;code&gt;save()&lt;/code&gt; function can output them, and the frontend runtime will hydrate whatever it finds. &lt;/p&gt;

&lt;p&gt;Here's how my static &lt;code&gt;save()&lt;/code&gt; looks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blockProps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useBlockProps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;clsx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;`has-media-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt; &lt;span class="nx"&gt;videoPosition&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="nx"&gt;videoPosition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;`card-style-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt; &lt;span class="nx"&gt;cardStyle&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="nx"&gt;cardStyle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--story-video-block-bg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;backgroundColor&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;...(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nx"&gt;isFile&lt;/span&gt;
        &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data-wp-interactive&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;create-block/story-video-block&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data-wp-context&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="na"&gt;isPlaying&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="nx"&gt;embedUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="na"&gt;videoSrc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;JSON.stringify()&lt;/code&gt; is the whole server-state story for this block! The markup lives in &lt;code&gt;post_content&lt;/code&gt;. There's no PHP render callback running on every request, yet each block instance gets its own scoped, reactive state. &lt;/p&gt;

&lt;h2&gt;
  
  
  The Performance Trick: The &lt;code&gt;iframe&lt;/code&gt; with no &lt;code&gt;src&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;You cannot render an iframe and just CSS hide it. If it has a &lt;code&gt;src&lt;/code&gt;, the browser &lt;strong&gt;will&lt;/strong&gt; load the player. Hidden doesn't mean unloaded. &lt;/p&gt;

&lt;p&gt;So, the iframe is deliberately rendered without a &lt;code&gt;src&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;
    &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"story-video-block__video"&lt;/span&gt;
    &lt;span class="na"&gt;hidden&lt;/span&gt;
    &lt;span class="na"&gt;data-wp-bind--hidden&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"state.isNotPlaying"&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;iframe&lt;/span&gt;
        &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;heading&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;story-video-block&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;data-wp-bind--src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"context.videoSrc"&lt;/span&gt;
        &lt;span class="na"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"autoplay; fullscreen; picture-in-picture"&lt;/span&gt;
        &lt;span class="na"&gt;allowFullScreen&lt;/span&gt;
    &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Initially, &lt;code&gt;context.videoSrc === ''&lt;/code&gt;. Because WP's &lt;code&gt;data-wp-bind--*&lt;/code&gt; removes an attribute when its value is falsy, there is no &lt;code&gt;src&lt;/code&gt; attribute at all. No request, no player.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bonus UX win:&lt;/strong&gt; Because the source is assigned inside the click handler, the browser treats playback as user-initiated. That means &lt;code&gt;autoplay=1&lt;/code&gt; actually works. One click, video starts. No double-clicking required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Progressive Enhancement: The facade is a link 🔗
&lt;/h2&gt;

&lt;p&gt;I didn't make the facade a &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;. JavaScript shouldn't be the only way to reach the video.&lt;/p&gt;

&lt;p&gt;The facade is a real &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; tag pointing to the original video:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;a&lt;/span&gt;
    &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;videoUrl&lt;/span&gt; &lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"_blank"&lt;/span&gt;
    &lt;span class="na"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"noopener noreferrer"&lt;/span&gt;
    &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"story-video-block__facade"&lt;/span&gt;
    &lt;span class="na"&gt;data-wp-on--click&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"actions.play"&lt;/span&gt;
    &lt;span class="na"&gt;data-wp-bind--hidden&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"state.isPlaying"&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Our Interactivity store then enhances it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getContext&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@wordpress/interactivity&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;create-block/story-video-block&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="nf"&gt;isPlaying&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;isPlaying&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="nf"&gt;isNotPlaying&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;isPlaying&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;play&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isPlaying&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

            &lt;span class="c1"&gt;// If no direct-embed URL (like TikTok), let the original &amp;lt;a&amp;gt; link work!&lt;/span&gt;
            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;embedUrl&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

            &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoSrc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;embedUrl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isPlaying&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives us graceful degradation:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;JS + Supported provider (YouTube/Vimeo):&lt;/strong&gt; Plays inline seamlessly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JS + No direct embed (TikTok):&lt;/strong&gt; Acts as a normal link.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No JS enabled:&lt;/strong&gt; Acts as a normal link.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  🐛 A CSS Gotcha That Cost Me an Evening
&lt;/h2&gt;

&lt;p&gt;The Interactivity API was working. The &lt;code&gt;hidden&lt;/code&gt; attribute was toggling perfectly in the DOM. But the elements were still visible on the screen. &lt;em&gt;Why?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Browsers have a default user-agent style:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;hidden&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But if your block's custom CSS applies an explicit &lt;code&gt;display&lt;/code&gt; (like &lt;code&gt;display: flex;&lt;/code&gt;), it overrides the low-specificity &lt;code&gt;[hidden]&lt;/code&gt; rule. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;hidden&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt; &lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Pro-tip: If you're using &lt;code&gt;data-wp-bind--hidden&lt;/code&gt; and nothing is happening visually, check your CSS specificity before ripping apart your JavaScript!&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why skip WordPress oEmbed on the frontend?
&lt;/h2&gt;

&lt;p&gt;WordPress has robust oEmbed support. Why did I write manual regex provider detection for the frontend? &lt;/p&gt;

&lt;p&gt;Because the &lt;code&gt;/oembed/1.0/proxy&lt;/code&gt; endpoint requires &lt;code&gt;edit_posts&lt;/code&gt; permissions. An anonymous site visitor gets a &lt;code&gt;401 Unauthorized&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Instead, the block parses the URL itself (supporting YouTube, Vimeo, Dailymotion, Twitch, mp4s, etc.) and constructs the embed URL string locally. Zero network requests just to figure out a URL we already know how to build. &lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Building this reminded me of a golden rule in web development: &lt;strong&gt;Don't make every visitor pay for a feature that only some visitors will use.&lt;/strong&gt; &lt;/p&gt;

&lt;p&gt;A video doesn't need to be a 1MB+ third-party application just because someone &lt;em&gt;might&lt;/em&gt; press Play. A static Gutenberg block doesn't need to become dynamic just to hold state. And a tiny bit of interactivity doesn't require shipping a massive JS runtime.&lt;/p&gt;

&lt;p&gt;If you're building blocks for WordPress, give the Interactivity API a shot for your next frontend component! &lt;/p&gt;




&lt;p&gt;&lt;em&gt;The &lt;a href="https://wordpress.org/plugins/story-video-block/" rel="noopener noreferrer"&gt;Story Video Block&lt;/a&gt; is currently v0.1.0, requires WP 6.8+, and is GPL. If you want to check out the full code or contribute, &lt;a href="https://github.com/akshat009/story-video-block" rel="noopener noreferrer"&gt;PRs are welcome on GitHub!&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>gutenberg</category>
      <category>javascript</category>
      <category>webperf</category>
    </item>
  </channel>
</rss>
