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 +);