Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 27 additions & 0 deletions .github/workflows/pr-cleanup.yml
Original file line number Diff line number Diff line change
@@ -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
101 changes: 101 additions & 0 deletions .github/workflows/pr-playground-preview.yml
Original file line number Diff line number Diff line change
@@ -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 }}
59 changes: 59 additions & 0 deletions hm-query-loop.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down Expand Up @@ -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();
}
171 changes: 168 additions & 3 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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 <BlockEdit { ...props } />;
}

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 (
<>
<BlockControls>
<ToolbarGroup>
<ToolbarButton
icon={ isPreview ? 'edit' : 'visibility' }
label={
isPreview
? __( 'Edit query loop', 'hm-query-loop' )
: __(
'Preview query loop',
'hm-query-loop'
)
}
onClick={ toggleMode }
/>
</ToolbarGroup>
</BlockControls>
{ isPreview ? (
<ServerSideRender
block="hm-query-loop/preview"
attributes={ { content: snapshot } }
httpMethod="POST"
urlQueryArgs={ urlQueryArgs }
/>
) : (
<BlockEdit { ...props } />
) }
</>
);
};
}, 'withSSRPreview' );

addFilter(
'editor.BlockEdit',
'hm-query-loop/with-ssr-preview',
withSSRPreview
);
Loading