Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
7ac5e6a
chore: Update WordPress runtime version and improve safe decoding for…
timothy-mugo Sep 11, 2026
36f9184
feat: scaffold tailwind gutenberg
timothy-mugo Sep 21, 2026
20b4693
feat: add live Tailwind preview in the block editor canvas (Milestone 2)
timothy-mugo Sep 21, 2026
b27ce88
feat: add frontend CSS pipeline via REST upload (Milestone 3)
timothy-mugo Sep 21, 2026
1a5aef6
feat: add autocomplete index generator and token-field UI (Milestone 4)
timothy-mugo Sep 21, 2026
a96b3a4
feat: apply classes to dynamic blocks via render_block (Milestone 5)
timothy-mugo Sep 21, 2026
095ccf9
feat: add settings page and wire safelist/theme/preflight through (Mi…
timothy-mugo Sep 21, 2026
de24e7f
feat: toolbar button, keyboard shortcut, uninstall cleanup, i18n (Mil…
timothy-mugo Sep 21, 2026
966e26a
fix: feed the site-wide class index into every editor compile
timothy-mugo Sep 21, 2026
46202ab
chore: add spacer between textarea and toggle control
timothy-mugo Sep 21, 2026
6d9a37c
fix: enqueue the compiled stylesheet on normal WordPress page renders
timothy-mugo Sep 21, 2026
3e6b2f2
docs: add a Development section to the plugin readme
timothy-mugo Sep 21, 2026
37409a0
Merge branch 'main' of https://github.com/devgateway/data-viz-wordpre…
timothy-mugo Sep 21, 2026
899db73
chore: add spacer between textfield nd toggle
timothy-mugo Sep 21, 2026
6f73aaa
fix(tailwind-gutenberg): enable tailwind class collection from raw HT…
timothy-mugo Oct 13, 2026
e937a95
feat(block-styles): add endpoint to retrieve block styles and cache m…
timothy-mugo Oct 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ COPY package.json /app/package.json
#Packages
COPY packages/commons/package.json /app/packages/commons/package.json
COPY plugins/wp-react-blocks-plugin/blocks/package.json /app/plugins/wp-react-blocks-plugin/blocks/package.json
COPY plugins/tailwind-gutenberg/package.json /app/plugins/tailwind-gutenberg/package.json

RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile --shamefully-hoist
Expand All @@ -30,7 +31,7 @@ COPY . /app

# Build the plugins
RUN BLOCKS_CATEGORY=wp-react-lib-blocks BLOCKS_NS=viz \
pnpm -r --filter="@devgateway/dvz-wp-commons" --filter="dg-react-blocks" build
pnpm -r --filter="@devgateway/dvz-wp-commons" --filter="dg-react-blocks" --filter="tailwind-gutenberg" build

# Organize WordPress files to the container
COPY wp-theme wp-content/themes/dg-semantic
Expand Down
1 change: 1 addition & 0 deletions plugins/tailwind-gutenberg/assets/class-index.json

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions plugins/tailwind-gutenberg/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "devgateway/tailwind-gutenberg",
"description": "Adds Tailwind CSS utility classes to any Gutenberg block, with autocomplete and live preview.",
"type": "wordpress-plugin",
"license": "GPL-2.0-or-later",
"require": {
"php": ">=8.0"
},
"autoload": {
"psr-4": {
"Twg\\": "src/"
}
}
}
20 changes: 20 additions & 0 deletions plugins/tailwind-gutenberg/composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions plugins/tailwind-gutenberg/index.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<?php
// Silence is golden.
164 changes: 164 additions & 0 deletions plugins/tailwind-gutenberg/js/canvas.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import apiFetch from '@wordpress/api-fetch';
import { subscribe, select } from '@wordpress/data';
import { store as editorStore } from '@wordpress/editor';

const IFRAME_SELECTOR = 'iframe[name="editor-canvas"]';
const MARKER_ATTRIBUTE = 'data-twg-canvas';
const COMPILED_ATTRIBUTE = 'data-twg-compiled';

// Preflight is opt-in (off by default) since it resets margins/borders and
// would visibly wreck the editor chrome outside the canvas for most themes.
function buildCanvasCss() {
const imports = window.twgCanvasData?.preflight
? '@import "tailwindcss";'
: '@import "tailwindcss/theme" layer(theme);\n@import "tailwindcss/utilities" layer(utilities);';

return `${ imports }\n${ window.twgCanvasData?.themeCss || '' }`;
}

// @tailwindcss/browser only compiles classes it finds on elements actually
// in the DOM, so safelisted classes need a real (if hidden) element to
// live on rather than being handed to the compiler directly.
function injectSafelist( doc ) {
const safelist = window.twgCanvasData?.safelist || [];

if ( ! safelist.length ) {
return;
}

const marker = doc.createElement( 'div' );
marker.setAttribute( 'aria-hidden', 'true' );
marker.setAttribute( 'data-twg-safelist', '1' );
marker.style.display = 'none';
marker.className = safelist.join( ' ' );
doc.body.appendChild( marker );
}

// @tailwindcss/browser writes its compiled output into a <style> tag it
// creates itself, with no id or attribute to identify it by. It appends
// that tag to <head> synchronously as soon as it runs, so watching for the
// next plain <style> element added to <head> right after injection is the
// only way to get a handle on it.
function markCompiledStyleTag( doc ) {
const observer = new window.MutationObserver( ( mutations ) => {
for ( const mutation of mutations ) {
for ( const node of mutation.addedNodes ) {
if (
node.tagName === 'STYLE' &&
! node.hasAttribute( 'type' )
) {
node.setAttribute( COMPILED_ATTRIBUTE, '1' );
observer.disconnect();
return;
}
}
}
} );

observer.observe( doc.head, { childList: true } );
}

function injectIntoIframe( iframe ) {
const doc = iframe.contentDocument;

if (
! doc ||
! doc.head ||
! doc.body ||
doc.head.querySelector( `script[${ MARKER_ATTRIBUTE }]` )
) {
// A missing head/body means the iframe document isn't ready yet
// (e.g. mid-navigation right after a device-preview switch); since
// nothing gets marked below, the observers retry on the next
// mutation/load instead of half-completing the injection.
return;
}

const themeStyle = doc.createElement( 'style' );
themeStyle.setAttribute( 'type', 'text/tailwindcss' );
themeStyle.textContent = buildCanvasCss();
doc.head.appendChild( themeStyle );

injectSafelist( doc );
markCompiledStyleTag( doc );

const script = doc.createElement( 'script' );
script.setAttribute( MARKER_ATTRIBUTE, '1' );
script.src = window.twgCanvasData.tailwindBrowserUrl;
doc.head.appendChild( script );
}

function tryInject() {
const iframe = document.querySelector( IFRAME_SELECTOR );

if ( ! iframe ) {
return;
}

try {
injectIntoIframe( iframe );
} catch ( error ) {
// The iframe's document can be mid-navigation (e.g. right after a
// device-preview switch recreates it); the observers below retry on
// the next mutation/load, so failures here are silently dropped.
}
}

function getCompiledCss() {
const iframe = document.querySelector( IFRAME_SELECTOR );
const style = iframe?.contentDocument?.head?.querySelector(
`style[${ COMPILED_ATTRIBUTE }]`
);

return style?.textContent || '';
}

function uploadCompiledCss() {
const css = getCompiledCss();

if ( ! css ) {
return;
}

apiFetch( {
path: '/twg/v1/css',
method: 'POST',
data: { css },
} ).catch( () => {
// Best-effort: the editor preview already reflects the current
// classes regardless of whether the frontend copy uploaded.
} );
}

// Device-preview switches and other editor UI destroy and recreate the
// iframe, so a one-shot check at boot isn't enough.
new window.MutationObserver( tryInject ).observe( document.body, {
childList: true,
subtree: true,
} );

// iframe 'load' events don't bubble, so this has to be a capturing listener.
document.addEventListener(
'load',
( event ) => {
if ( event.target.matches?.( IFRAME_SELECTOR ) ) {
tryInject();
}
},
true
);

tryInject();

let wasSaving = false;

subscribe( () => {
const isSaving = select( editorStore ).isSavingPost();
const isAutosaving = select( editorStore ).isAutosavingPost();

if ( wasSaving && ! isSaving && ! isAutosaving ) {
uploadCompiledCss();
}

wasSaving = isSaving;
} );
139 changes: 139 additions & 0 deletions plugins/tailwind-gutenberg/js/components/ClassTokenField.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import {
FormTokenField,
ToggleControl,
TextareaControl,
} from '@wordpress/components';
import { useCallback, useRef, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';

// One shared worker for every block's token field, created lazily on first
// use so the ~3,000-entry index is never fetched until someone actually
// needs autocomplete.
let worker = null;
let requestCounter = 0;
const pendingRequests = new Map();

function getWorker() {
if ( worker || ! window.twgEditorData ) {
return worker;
}

worker = new window.Worker( window.twgEditorData.workerUrl );
worker.postMessage( {
type: 'init',
indexUrl: window.twgEditorData.classIndexUrl,
maxResults: window.twgEditorData.suggestLimit,
} );

worker.addEventListener( 'message', ( event ) => {
const { requestId, results } = event.data;
const resolve = pendingRequests.get( requestId );

if ( resolve ) {
pendingRequests.delete( requestId );
resolve( results );
}
} );

return worker;
}

function queryWorker( query ) {
const instance = getWorker();

if ( ! instance ) {
return Promise.resolve( [] );
}

const requestId = ++requestCounter;

return new Promise( ( resolve ) => {
pendingRequests.set( requestId, resolve );
instance.postMessage( { type: 'query', query, requestId } );
} );
}

export default function ClassTokenField( { value, onChange } ) {
const [ suggestions, setSuggestions ] = useState( [] );
const [ declaration, setDeclaration ] = useState( '' );
const [ isRaw, setIsRaw ] = useState( false );
const debounceRef = useRef();

const tokens = value ? value.trim().split( /\s+/ ).filter( Boolean ) : [];

const handleInputChange = useCallback( ( input ) => {
window.clearTimeout( debounceRef.current );

const query = input.trim();

if ( ! query ) {
setSuggestions( [] );
setDeclaration( '' );
return;
}

debounceRef.current = window.setTimeout( async () => {
const results = await queryWorker( query );

setSuggestions( results.map( ( r ) => r.c ) );
setDeclaration( results.find( ( r ) => r.c === query )?.d || '' );
}, 150 );
}, [] );

const handleChange = ( nextTokens ) => {
onChange( nextTokens.join( ' ' ) );
setSuggestions( [] );
setDeclaration( '' );
};

if ( isRaw ) {
return (
<>
<TextareaControl
__nextHasNoMarginBottom
label={ __( 'Classes', 'tailwind-gutenberg' ) }
value={ value || '' }
onChange={ onChange }
help={ __(
'Space-separated Tailwind classes.',
'tailwind-gutenberg'
) }
/>
<span style={{ marginBottom: '8px' }}></span>
<ToggleControl
__nextHasNoMarginBottom
label={ __( 'Paste raw', 'tailwind-gutenberg' ) }
checked={ isRaw }
onChange={ setIsRaw }
/>
</>
);
}

return (
<>
<FormTokenField
__next40pxDefaultSize
__nextHasNoMarginBottom
__experimentalShowHowTo={ false }
__experimentalAutoSelectFirstMatch
label={ __( 'Classes', 'tailwind-gutenberg' ) }
value={ tokens }
suggestions={ suggestions }
onChange={ handleChange }
onInputChange={ handleInputChange }
tokenizeOnSpace
/>
{ declaration && (
<p className="twg-declaration-preview">{ declaration }</p>
) }
<span style={{ marginBottom: '8px' }}></span>
<ToggleControl
__nextHasNoMarginBottom
label={ __( 'Paste raw', 'tailwind-gutenberg' ) }
checked={ isRaw }
onChange={ setIsRaw }
/>
</>
);
}
Loading