diff --git a/.github/workflows/pr-cleanup.yml b/.github/workflows/pr-cleanup.yml
new file mode 100644
index 0000000..d0d6a04
--- /dev/null
+++ b/.github/workflows/pr-cleanup.yml
@@ -0,0 +1,27 @@
+name: PR Cleanup
+
+on:
+ pull_request:
+ types: [closed]
+
+permissions:
+ contents: write
+
+jobs:
+ cleanup:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Delete PR preview branch
+ run: |
+ BRANCH_NAME="pr-${{ github.event.pull_request.number }}-built"
+
+ echo "Checking if branch $BRANCH_NAME exists..."
+
+ # Check if the branch exists on remote
+ if git ls-remote --heads "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" "$BRANCH_NAME" | grep -q "$BRANCH_NAME"; then
+ echo "Branch $BRANCH_NAME found. Deleting..."
+ git push "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" --delete "$BRANCH_NAME"
+ echo "Successfully deleted branch $BRANCH_NAME"
+ else
+ echo "Branch $BRANCH_NAME does not exist. Nothing to clean up."
+ fi
diff --git a/.github/workflows/pr-playground-preview.yml b/.github/workflows/pr-playground-preview.yml
new file mode 100644
index 0000000..1754532
--- /dev/null
+++ b/.github/workflows/pr-playground-preview.yml
@@ -0,0 +1,101 @@
+name: PR Playground Preview
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened, edited]
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ build-and-preview:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm i
+
+ - name: Replace version with PR info
+ run: |
+ # Create PR version string
+ PR_VERSION="Pull Request #${{ github.event.pull_request.number }} - ${{ github.event.pull_request.title }}"
+
+ # Replace __VERSION__ in plugin file
+ sed -i "s/__VERSION__/$PR_VERSION/g" hm-query-loop.php
+
+ echo "Updated plugin version to: $PR_VERSION"
+ cat hm-query-loop.php | grep "Version:"
+
+ - name: Build plugin
+ run: npm run build
+
+ - name: Push built files to PR branch
+ run: |
+ # Configure git
+ git config --local user.email "github-actions[bot]@users.noreply.github.com"
+ git config --local user.name "github-actions[bot]"
+
+ # Create and checkout a new branch for this PR's built files
+ BRANCH_NAME="pr-${{ github.event.pull_request.number }}-built"
+
+ # Create/recreate the branch from current state
+ git checkout -B "$BRANCH_NAME"
+
+ # Add all files including built assets and any lock file changes
+ git add -f build/
+ git add -A
+
+ # Commit if there are changes
+ if git diff --staged --quiet; then
+ echo "No changes to commit"
+ else
+ git commit -m "Build assets for PR #${{ github.event.pull_request.number }} (commit: ${{ github.event.pull_request.head.sha }})"
+ git push origin "$BRANCH_NAME" --force
+ fi
+
+ - name: Generate Playground blueprint and post preview button
+ uses: WordPress/action-wp-playground-pr-preview@v2
+ with:
+ mode: 'append-to-description'
+ blueprint: |
+ {
+ "landingPage": "/wp-admin/",
+ "preferredVersions": {
+ "php": "8.3",
+ "wp": "6.8"
+ },
+ "steps": [
+ {
+ "step": "login",
+ "username": "admin",
+ "password": "password"
+ },
+ {
+ "step": "installTheme",
+ "themeData": {
+ "resource": "wordpress.org/themes",
+ "slug": "twentytwentyfive"
+ },
+ "options": {
+ "activate": true
+ }
+ },
+ {
+ "step": "installPlugin",
+ "pluginZipFile": {
+ "resource": "url",
+ "url": "https://github.com/${{ github.repository }}/archive/refs/heads/pr-${{ github.event.pull_request.number }}-built.zip"
+ }
+ }
+ ]
+ }
+ github-token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/hm-query-loop.php b/hm-query-loop.php
index 7c1f8da..91a6dfe 100644
--- a/hm-query-loop.php
+++ b/hm-query-loop.php
@@ -52,6 +52,9 @@ function init() {
// Initialize query presets functionality.
QueryPresets\init();
+
+ // Register the preview block for server-side rendering in the editor.
+ register_preview_block();
}
add_action( 'init', __NAMESPACE__ . '\\init', 9 );
@@ -532,3 +535,59 @@ function track_displayed_posts( $posts, $query ) {
return $posts;
}
+
+/**
+ * Register the preview block used for server-side rendering query loop
+ * previews in the editor. This block is not insertable by users.
+ */
+function register_preview_block() {
+ register_block_type(
+ 'hm-query-loop/preview',
+ [
+ 'api_version' => 3,
+ 'attributes' => [
+ 'content' => [
+ 'type' => 'string',
+ 'default' => '',
+ ],
+ ],
+ 'supports' => [
+ 'inserter' => false,
+ ],
+ 'render_callback' => __NAMESPACE__ . '\\render_preview_block',
+ ]
+ );
+}
+
+/**
+ * Render callback for the preview block.
+ *
+ * Processes the serialized block content through do_blocks() to produce
+ * the same output as a frontend page render. Adds the inert attribute to
+ * all top-level child elements so that links and interactive elements
+ * cannot be clicked in the editor preview.
+ *
+ * @param array $attributes Block attributes containing 'content'.
+ * @return string Rendered HTML.
+ */
+function render_preview_block( $attributes ) {
+ $content = $attributes['content'] ?? '';
+
+ if ( empty( $content ) ) {
+ return '';
+ }
+
+ $html = do_blocks( $content );
+
+ // WP_HTML_Processor::create_fragment() wraps the HTML in an implicit
+ // body context, so top-level elements are at depth 1, not 0.
+ $processor = \WP_HTML_Processor::create_fragment( $html );
+
+ while ( $processor->next_tag() ) {
+ if ( $processor->get_current_depth() === 1 ) {
+ $processor->set_attribute( 'inert', true );
+ }
+ }
+
+ return $processor->get_updated_html();
+}
diff --git a/src/index.js b/src/index.js
index b738e27..9a65129 100644
--- a/src/index.js
+++ b/src/index.js
@@ -3,22 +3,90 @@
*/
import { addFilter } from '@wordpress/hooks';
import { createHigherOrderComponent } from '@wordpress/compose';
-import { useSelect } from '@wordpress/data';
-import { InspectorControls } from '@wordpress/block-editor';
+import {
+ useSelect,
+ useDispatch,
+ select as syncSelect,
+ register,
+ createReduxStore,
+} from '@wordpress/data';
+import { InspectorControls, BlockControls } from '@wordpress/block-editor';
import {
PanelBody,
ToggleControl,
TextControl,
SelectControl,
+ ToolbarGroup,
+ ToolbarButton,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
-import { createContext, useContext, useEffect } from '@wordpress/element';
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useState,
+ useCallback,
+} from '@wordpress/element';
+import ServerSideRender from '@wordpress/server-side-render';
+import { registerBlockType, serialize } from '@wordpress/blocks';
/**
* Styles
*/
import './index.scss';
+/**
+ * Register the preview block used for server-side rendering query loop
+ * previews. This block is not insertable and has no editor UI; it exists
+ * solely so the ServerSideRender component can resolve the block type
+ * when sanitizing attributes before making the API request.
+ */
+registerBlockType( 'hm-query-loop/preview', {
+ title: 'Query Loop Preview',
+ category: 'widgets',
+ supports: {
+ inserter: false,
+ },
+ attributes: {
+ content: {
+ type: 'string',
+ default: '',
+ },
+ },
+ edit: () => null,
+ save: () => null,
+} );
+
+/**
+ * Per-clientId Preview/Edit mode store.
+ * Mode resets on page reload (intentional — like collapsed-panel state).
+ */
+const PREVIEW_MODE_STORE = 'hm-query-loop/preview-mode';
+
+register(
+ createReduxStore( PREVIEW_MODE_STORE, {
+ reducer( state = { modes: {} }, action ) {
+ if ( action.type === 'SET_MODE' ) {
+ return {
+ modes: { ...state.modes, [ action.clientId ]: action.mode },
+ };
+ }
+ return state;
+ },
+ actions: {
+ setMode: ( clientId, mode ) => ( {
+ type: 'SET_MODE',
+ clientId,
+ mode,
+ } ),
+ },
+ selectors: {
+ getMode: ( state, clientId ) =>
+ state.modes[ clientId ] || 'edit',
+ },
+ } )
+);
+
/**
* Create a context for tracking used post IDs within a query loop in the editor.
*/
@@ -552,3 +620,100 @@ addFilter(
'hm-query-loop/with-post-template-styles',
withPostTemplateStyles
);
+
+/**
+ * Add a Preview/Edit toolbar toggle to each Query Loop block.
+ *
+ * In Preview mode, the block renders a server-side HTML snapshot. The snapshot
+ * is taken once on entry into preview mode (keyed on the mode transition), so
+ * typing elsewhere in the editor never triggers additional renderer requests.
+ *
+ * In Edit mode the standard live editor is shown with all settings accessible.
+ *
+ * The rendered HTML has the inert attribute on its top-level elements
+ * (added server-side) to prevent link navigation.
+ */
+const withSSRPreview = createHigherOrderComponent( ( BlockEdit ) => {
+ return ( props ) => {
+ const { name, clientId } = props;
+
+ if ( name !== 'core/query' ) {
+ return ;
+ }
+
+ const mode = useSelect(
+ ( select ) => select( PREVIEW_MODE_STORE ).getMode( clientId ),
+ [ clientId ]
+ );
+
+ const { setMode } = useDispatch( PREVIEW_MODE_STORE );
+
+ const postId = useSelect( ( select ) => {
+ try {
+ const id = select( 'core/editor' )?.getCurrentPostId?.();
+ return Number.isInteger( id ) && id > 0 ? id : 0;
+ } catch {
+ return 0;
+ }
+ }, [] );
+
+ // Stable snapshot of serialized block content for the SSR component.
+ // Captured in the toggle handler (not a useEffect) so that the first
+ // render in preview mode already has content — avoiding an empty
+ // initial POST to the block renderer endpoint.
+ const [ snapshot, setSnapshot ] = useState( '' );
+
+ const toggleMode = useCallback( () => {
+ if ( mode === 'edit' ) {
+ const block =
+ syncSelect( 'core/block-editor' ).getBlock( clientId );
+ if ( block ) {
+ setSnapshot( serialize( [ block ] ) );
+ }
+ setMode( clientId, 'preview' );
+ } else {
+ setMode( clientId, 'edit' );
+ }
+ }, [ mode, clientId, setMode ] );
+
+ const isPreview = mode === 'preview';
+ const urlQueryArgs = postId ? { post_id: postId } : {};
+
+ return (
+ <>
+
+
+
+
+
+ { isPreview ? (
+
+ ) : (
+
+ ) }
+ >
+ );
+ };
+}, 'withSSRPreview' );
+
+addFilter(
+ 'editor.BlockEdit',
+ 'hm-query-loop/with-ssr-preview',
+ withSSRPreview
+);