Skip to content

feat: Tailwind Gutenberg - #157

Draft
timothy-mugo wants to merge 14 commits into
mainfrom
task/implement-tailwind-css-plugin
Draft

timothy-mugo wants to merge 14 commits into
mainfrom
task/implement-tailwind-css-plugin

Conversation

@timothy-mugo

Copy link
Copy Markdown
Contributor

Description

Tailwind Gutenberg

Adds a plugin that lets editors type Tailwind utility classes onto any Gutenberg block, with autocomplete, live preview, and frontend output. It works with both Wordpress and headless CMS setup.

Features

  • Editor: inspector token field with autosuggest (3,291-entry index, generated from Tailwind's real compiler), live preview via @tailwindcss/browser injected into the post-editor iframe, toolbar button + Ctrl/Cmd+Shift+T shortcut.
  • Frontend: no server-side compiler exists here, so the editor's compiled CSS is uploaded via REST and written to a hashed file; GET /wp-json/twg/v1/css exposes it for the external frontend app.
  • Dynamic-block support (render_block), a settings page (frontend mode, theme/safelist/preflight), and uninstall.php cleanup.
  • Includes a fix for a real bug (each editor session was compiling only its own post's classes, silently overwriting the site wide stylesheet on every save) and a couple of spec deviations noted in commit messages (FormTokenField instead of a hand-rolled token field; no "Rebuild CSS" button, since there's nothing for it to trigger).
Screenshot 2026-09-21 at 15 13 34

Type of change

  • Bug fix (fix:)
  • New feature (feat:)
  • Breaking change (BREAKING CHANGE:)
  • Refactor / chore (refactor: / chore:)
  • Documentation update (docs:)

Affected package(s)

  • @devgateway/dvz-wp-commons
  • @devgateway/create-wp-customizer
  • @devgateway/upgrade-wp-customizer
  • plugins/wp-react-blocks-plugin
  • plugins/wp-react-custom-api
  • plugins/wp-react-custom-rest-menu
  • Other plugin / theme / Docker (no changeset needed)

Checklist

  • PR title follows Conventional Commits format
  • pnpm build passes locally
  • No hardcoded credentials, internal URLs, client names, or PII introduced
  • Any new dependency has a GPL-2.0-or-later-compatible license (MIT, BSD, Apache-2.0, ISC are all compatible)

@timothy-mugo
timothy-mugo force-pushed the task/implement-tailwind-css-plugin branch from ee18d1b to b1aeea4 Compare September 21, 2026 13:02
timothy-mugo and others added 11 commits September 21, 2026 16:10
Bundles @tailwindcss/browser as a static asset (not imported into canvas.js
itself, since it self-executes against whatever `document` it runs in) and
injects it directly into the post-editor iframe's own document, where its
built-in MutationObserver picks up class changes and recompiles reactively.
This replaces the spec's `compile()`/`build()` pseudocode, which does not
match the package's actual API (a self-executing global bundle, no exports) -
verified by installing it and reading dist/index.global.js before writing
any of this.

Also adds an `editor.BlockListBlock` filter applying twgClasses to the live
canvas wrapper: the existing extraProps filter only affects serialized
save() markup, not the DOM rendered while editing, so the canvas compiler
would never see the classes without it.

Preflight is intentionally excluded from the canvas import to avoid
resetting editor chrome styles. Iframe injection uses the querySelector
approach (not the spec's component-route-first preference) with a
MutationObserver + capturing load listener for resilience across
device-preview remounts, chosen for predictability over unstable/private
Gutenberg APIs given end-to-end testing is deferred until all milestones
are done.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
No Node.js exists in this deployment's production runtime (PHP-FPM Alpine,
confirmed in the earlier spec compatibility review), so there is no
server-side Tailwind compile step. Instead, canvas.js locates the <style>
tag @tailwindcss/browser writes into the editor iframe, and POSTs its
current text to a new REST endpoint after each non-autosave save. PHP does
a dumb write to a content-hashed file in wp-content/uploads/twg/, pruning
older files down to the 2 most recent.

Twg\Class_Index walks parse_blocks() output on save_post to maintain a
site-wide option of every class seen (additive only; full rescan/prune is
a later settings-page action). Twg\Rest exposes GET/POST /twg/v1/css, the
POST route gated on current_user_can('edit_posts') since it writes
attacker-controllable bytes to a publicly served file.

Since this WordPress instance is used headless (also from the earlier
review), there is no PHP-rendered frontend page to wp_enqueue_style()
onto — GET /twg/v1/css is what the external frontend app is expected to
poll/fetch to link the current stylesheet itself.

Verified with standalone PHP harnesses (WP functions stubbed) for the
block-tree class-collection recursion and the file hash/write/prune
logic, since neither needs a live WordPress instance to check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
tools/generate-index.mjs uses Tailwind's real Node compile() API (verified
by installing tailwindcss and reading dist/lib.js - the .d.ts for the "."
export is misleading, but `compile` is a genuine named export in the
actual runtime bundle) to batch-compile a curated set of candidate utility
names (colors/spacing/radius/shadows/font-size/font-weight extracted
straight from the installed theme.css, plus a static list of common
parameterless utilities) and parse the real output CSS for each class's
declaration. Nothing is guessed: only classes that actually produced CSS
end up in assets/class-index.json (3,291 entries, ~21KB gzipped),
committed since there's no build step that regenerates it in production.

The editor UI uses @wordpress/components' FormTokenField for the chip/
keyboard interaction (Enter/Tab/Space commit, Backspace-removes-last-chip,
already handled and accessible) rather than hand-rolling it, since that's
the single riskiest surface to get wrong with no live testing available
until every milestone is done. Trade-off: FormTokenField re-filters
suggestions by plain substring containment against the full typed text
(verified in its source), which would silently discard a fuzzy-subsequence
ranking tier - so js/worker.js only ranks prefix-vs-contains, not the
spec's third "fuzzy subsequence" tier. The declaration preview line is
driven by an exact match against the current input rather than a
highlighted-suggestion index for the same reason (FormTokenField doesn't
expose one). SuggestionList.jsx from the spec's file tree isn't created,
since FormTokenField renders its own suggestion list.

The worker still owns what FormTokenField can't do on its own: rejecting
arbitrary-value queries (w-[37px]) outright, splitting the variant prefix
on the last ":", and listing variants (not utilities) when nothing has
been typed for the next segment yet.

Also fixes a bug from Milestone 2: markCompiledStyleTag matched any
existing untyped <style> tag already in the iframe head (e.g. the theme's
own editor styles), not specifically the one @tailwindcss/browser adds -
it only looked at mutation.addedNodes correctly, scoping the match to
newly inserted elements. Verified separately (Node harness) that
tailwindcss's build() is genuinely cumulative across calls, so the M3
upload path does capture the complete stylesheet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Twg\Render hooks render_block to inject twgClasses into dynamic blocks'
server-rendered markup (Query Loop, Navigation, third-party
render_callback blocks) using WP_HTML_Tag_Processor, matching the spec's
approach exactly. It skips any block type it can confirm is NOT dynamic
(WP_Block_Type::is_dynamic()) since those already have their classes
baked in at save time via the Milestone 1 extraProps filter - render_block
fires for every block on every page load, so this avoids redundant work
on the common case.

None of this org's own ~48 viz/* blocks are dynamic (confirmed during the
original spec compatibility review), so in practice this only matters for
core dynamic blocks and any third-party ones - lower priority than the
spec's milestone ordering implies, but still worth having for completeness.

Verified the control flow (static-skip, empty-content guard, multi-class
splitting, safe default for unregistered block types) with a standalone
PHP harness mocking WP_HTML_Tag_Processor and WP_Block_Type_Registry,
since the real WP_HTML_Tag_Processor needs a live WordPress to exercise
directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…lestone 6)

Settings -> Tailwind Classes, backed by twg_settings (own dedicated page,
not house's usual General-Settings-fields convention - justified since
the AJAX-free rebuild-index action and status table don't fit there).

frontend_mode is a 2-way local/cdn toggle rather than the spec's 4-way
cli/editor-cache/cdn/auto, since CLI was already ruled out entirely back
in Milestone 3 (no Node.js in this deployment). theme_css, safelist, and
preflight now actually flow through to canvas.js instead of sitting
inert: theme_css and preflight change what gets imported into the
editor's compile, and safelist classes get applied to a hidden marker
element in the iframe so @tailwindcss/browser's DOM-based class collector
picks them up. suggest_limit flows through to worker.js's result cap.
load_on_frontend gates whether GET /twg/v1/css returns a URL at all, for
sites whose frontend app already bundles Tailwind itself.

"Rebuild CSS" from the spec's button pair is deliberately not built: this
architecture has no server-side compiler at all (confirmed back in
Milestone 3), so there is nothing such a button could actually trigger -
the frontend stylesheet is always whatever the editor most recently
compiled and POSTed. The status panel explains this directly instead of
shipping a button that can't do anything. "Rebuild index" is implemented
as a single synchronous admin-post request (Class_Index::rebuild(), full
rescan including the wp_block post type per the spec's own risk callout
on reusable blocks/patterns) rather than the spec's batched/paginated
AJAX-with-progress-bar version - a reasonable scope cut for most sites,
called out here since very large sites may need the paginated version
later.

Verified Settings_Page::sanitize() and get_safelist_classes() with a
standalone PHP harness (WP functions stubbed): valid input passthrough,
invalid frontend_mode falling back to the default, boolean coercion, and
newline-separated safelist parsing all check out.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…estone 7)

Adds the remaining spec polish items:

- Block toolbar button and a real Ctrl/Cmd+Shift+T shortcut
  (@wordpress/keyboard-shortcuts' registerShortcut + useShortcut, verified
  against the installed package - modifier "primaryShift" resolves to
  exactly Cmd+Shift on Mac / Ctrl+Shift on Windows/Linux) that both open
  the block sidebar and expand the panel. PanelBody is now fully
  controlled (opened/onToggle) instead of initialOpen so external
  triggers can actually expand it.
- Conflict handling: a block's existing "Additional CSS Class(es)"
  (attributes.className) now renders as a read-only note above the token
  field, per the spec's Editor UI section - this had been missed in
  Milestone 4.
- uninstall.php, gated on a new opt-in "delete_data_on_uninstall" setting
  (off by default): removes the three plugin options and the generated
  CSS files, never post content. Matches the spec's stated deactivation
  behavior exactly (deactivating alone never deletes anything).
- wp_set_script_translations() for twg-editor (the only bundle using
  __()) plus load_plugin_textdomain() for the PHP-side strings, both
  pointed at a new languages/ directory. No translations exist yet, but
  the JS strings are now wired to be translatable at all - without this
  call they never could be, even once .json translation files exist.
- readme.txt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Each editor session's @tailwindcss/browser instance only ever sees the
classes visible in that one canvas. Since the upload path in Milestone 3
treats the result as the single site-wide stylesheet (one file, one
twg_current_css option), saving post B after post A would upload a
stylesheet containing only post B's classes - silently breaking post A's
frontend styling the moment anyone else saved anything. This was
guaranteed to surface on the very first two-post test and nowhere before
that, since Milestone 3's own tests only ever exercised a single post.

Twg\Class_Index has been write-only until now - populated on save_post,
rebuildable, shown in the status panel, but nothing ever read it back to
affect compiled output. It's now merged into the safelist handed to
canvas.js, using the same hidden-marker-element mechanism Milestone 6
already built for the settings-page safelist field. Every editor session
now compiles a superset covering every class ever seen on the site, so
any save produces a complete stylesheet regardless of which post
triggered it.

Also hardens injectIntoIframe: the doc.body readiness check that
injectSafelist relied on has moved into the function's main early-return
guard. It was possible for a not-yet-ready iframe document to skip the
safelist marker but still complete the rest of the injection (style tag +
script), permanently locked out of a retry by the
script[data-twg-canvas] guard for that iframe instance - which would
have produced the exact same silent breakage this fix addresses, just
triggered by timing instead of by which post was open.

readme.txt's "Tested up to" also corrected to 7.1, matching the actual
runtime image (wordpress:7.1.0-fpm-alpine) rather than a guess.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Every frontend delivery mechanism built so far (GET /wp-json/twg/v1/css)
assumed a headless setup, where a separate frontend app fetches the URL
and links to it itself. Classes still land in rendered markup either way
(that's a separate, unrelated mechanism), but nothing ever attached the
CSS file that defines what those classes do to an actual WordPress page
render - so previewing a post directly through WordPress (not through
the external frontend app) showed unstyled classes with no stylesheet
backing them at all.

Hooks wp_enqueue_scripts and enqueues Compiler::get_current()'s URL,
gated on both frontend_mode being "local" (in "cdn" mode nothing here
should load anything - that's the external app's job) and
load_on_frontend. No `ver` query string, matching the existing
content-hashed-filename caching model rather than adding a redundant
cache-busting param on top of it.

Verified the four gating branches (cdn mode, load_on_frontend off, no
CSS generated yet, and the enqueue-should-fire case) with a standalone
PHP harness stubbing the relevant WP functions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
File layout, build/lint/index-generation commands, the two REST routes,
and the testing approach used so far (no automated test suite; an
isolated throwaway WordPress instance plus standalone PHP harnesses
stubbing WP functions for pure logic).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@timothy-mugo
timothy-mugo force-pushed the task/implement-tailwind-css-plugin branch 2 times, most recently from 5e5ed3a to 3e6b2f2 Compare September 21, 2026 13:12
@timothy-mugo timothy-mugo changed the title feat: scaffold tailwind gutenberg feat: Tailwind Gutenberg Sep 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant