Skip to content
Merged
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
39 changes: 39 additions & 0 deletions apps/cli/ai/block-content-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const BLOCK_COMMENT_PATTERN = /<!--\s*\/?wp:[^>]*-->/g;
const SINGLE_SCRIPT_PATTERN = /^<script(?:\s[^>]*)?>[\s\S]*<\/script>\s*$/i;
const SINGLE_SVG_PATTERN = /^<svg(?:\s[^>]*)?>[\s\S]*<\/svg>\s*$/i;
const INTERACTION_MARKUP_PATTERN = /<(marquee)\b/i;
const HTML_BLOCK_PATTERN = /<!--\s*wp:html(?:\s+[\s\S]*?)?\s*-->([\s\S]*?)<!--\s*\/wp:html\s*-->/g;
const PLUGIN_RECOMMENDATION_HTML_BLOCK_POLICIES = [
{
htmlPatterns: [ /<(form|input|select|textarea|fieldset)\b/i ],
Expand All @@ -10,6 +11,18 @@ const PLUGIN_RECOMMENDATION_HTML_BLOCK_POLICIES = [
},
];

export interface HtmlBlockPolicyResult {
blockNumber: number;
line: number;
content: string;
issues: string[];
}

export interface HtmlBlockPolicyReport {
totalHtmlBlocks: number;
invalidHtmlBlocks: HtmlBlockPolicyResult[];
}

export function getHtmlBlockPolicyIssues( content: string ): string[] {
const html = content.replace( BLOCK_COMMENT_PATTERN, '' ).trim();
if ( ! html ) {
Expand All @@ -35,3 +48,29 @@ export function getHtmlBlockPolicyIssues( content: string ): string[] {
'core/html contains markup that should use editable core blocks. Load the block-content skill and use core/group, core/columns, core/heading, core/paragraph, core/list, core/image, core/buttons, and theme CSS instead. Keep core/html only for inline SVG, interaction markup with no block equivalent (marquee, cursor), or a single script block.',
];
}

export function validateHtmlBlockPolicy( content: string ): HtmlBlockPolicyReport {
const invalidHtmlBlocks: HtmlBlockPolicyResult[] = [];
let totalHtmlBlocks = 0;

for ( const match of content.matchAll( HTML_BLOCK_PATTERN ) ) {
totalHtmlBlocks++;
const blockContent = match[ 1 ] ?? '';
const issues = getHtmlBlockPolicyIssues( blockContent );
if ( issues.length === 0 ) {
continue;
}

invalidHtmlBlocks.push( {
blockNumber: totalHtmlBlocks,
line: content.slice( 0, match.index ?? 0 ).split( '\n' ).length,
content: blockContent.trim(),
issues,
} );
}

return {
totalHtmlBlocks,
invalidHtmlBlocks,
};
}
244 changes: 144 additions & 100 deletions apps/cli/ai/block-validator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { getHtmlBlockPolicyIssues } from 'cli/ai/block-content-policy';
import { EditorPage } from 'cli/ai/browser-utils';

interface BlockValidationResult {
Expand All @@ -9,14 +8,23 @@ interface BlockValidationResult {
expectedContent?: string;
}

export interface ValidationReport {
export interface ValidationReportBase {
totalBlocks: number;
validBlocks: number;
invalidBlocks: number;
results: BlockValidationResult[];
error?: string;
}

export interface BlockFixProposal {
fixedContent: string;
report: ValidationReportBase;
}

export interface ValidationReport extends ValidationReportBase {
proposedFix?: BlockFixProposal;
}

// Cache one EditorPage per site URL so repeated validations reuse the same
// browser tab instead of navigating and loading the block editor each time.
const editorPages = new Map< string, EditorPage >();
Expand Down Expand Up @@ -48,22 +56,21 @@ export async function validateBlocks(
try {
const page = await editorPage.getPage();

const report = await page.evaluate( ( html: string ) => {
const validation = await page.evaluate( ( html: string ) => {
/* eslint-disable @typescript-eslint/no-explicit-any */
const wpBlocks = ( window as any ).wp?.blocks;
if ( ! wpBlocks ) {
return {
totalBlocks: 0,
validBlocks: 0,
invalidBlocks: 0,
results: [] as any[],
error: 'wp.blocks is not available on this page.',
report: {
totalBlocks: 0,
validBlocks: 0,
invalidBlocks: 0,
results: [] as any[],
error: 'wp.blocks is not available on this page.',
},
};
}

const blocks = wpBlocks.parse( html );
const results: any[] = [];

function extractIssues( validationIssues: any[] ): string[] {
const issues: string[] = [];
for ( const issue of validationIssues ) {
Expand All @@ -87,88 +94,152 @@ export async function validateBlocks(
return issues;
}

function validateRecursive( block: any ) {
if ( ! block.name ) {
return;
}
if ( block.name === 'core/freeform' || block.name === 'core/missing' ) {
return;
}
function validateContent( blockHtml: string ) {
const blocks = wpBlocks.parse( blockHtml );
const results: any[] = [];

const blockType = wpBlocks.getBlockType( block.name );
let issues: string[] = [];
let isValid = true;
let expectedContent: string | undefined;

if ( ! blockType ) {
isValid = false;
issues.push(
`Block type "${ block.name }" is not registered. ` +
'It may require a plugin that is not active.'
);
} else {
// validateBlock performs the save-function comparison.
// Handle both return formats:
// WP 6.3+: { isValid: boolean, validationIssues: Array }
// Older: [boolean, Array]
const validation = wpBlocks.validateBlock( block, blockType );

if ( Array.isArray( validation ) ) {
isValid = validation[ 0 ];
if ( ! isValid ) {
issues = extractIssues( validation[ 1 ] || [] );
}
function validateRecursive( block: any ) {
if ( ! block.name ) {
return;
}
if ( block.name === 'core/freeform' || block.name === 'core/missing' ) {
return;
}

const blockType = wpBlocks.getBlockType( block.name );
let issues: string[] = [];
let isValid = true;
let expectedContent: string | undefined;

if ( ! blockType ) {
isValid = false;
issues.push(
`Block type "${ block.name }" is not registered. ` +
'It may require a plugin that is not active.'
);
} else {
isValid = validation.isValid;
// validateBlock performs the save-function comparison.
// Handle both return formats:
// WP 6.3+: { isValid: boolean, validationIssues: Array }
// Older: [boolean, Array]
const validationResult = wpBlocks.validateBlock( block, blockType );

if ( Array.isArray( validationResult ) ) {
isValid = validationResult[ 0 ];
if ( ! isValid ) {
issues = extractIssues( validationResult[ 1 ] || [] );
}
} else {
isValid = validationResult.isValid;
if ( ! isValid ) {
issues = extractIssues( validationResult.validationIssues || [] );
}
}

if ( ! isValid ) {
issues = extractIssues( validation.validationIssues || [] );
// Re-render expected HTML including inner blocks for comparison
try {
expectedContent = wpBlocks.getSaveContent(
blockType,
block.attributes,
block.innerBlocks
);
} catch {
// If re-rendering fails, skip expected content
}
}
}

if ( ! isValid ) {
// Re-render expected HTML including inner blocks for comparison
try {
expectedContent = wpBlocks.getSaveContent(
blockType,
block.attributes,
block.innerBlocks
);
} catch {
// If re-rendering fails, skip expected content
results.push( {
blockName: block.name,
isValid,
issues,
originalContent: block.originalContent || '',
expectedContent: isValid ? undefined : expectedContent,
} );

if ( block.innerBlocks ) {
for ( const inner of block.innerBlocks ) {
validateRecursive( inner );
}
}
}

results.push( {
blockName: block.name,
isValid,
issues,
originalContent: block.originalContent || '',
expectedContent: isValid ? undefined : expectedContent,
} );

if ( block.innerBlocks ) {
for ( const inner of block.innerBlocks ) {
validateRecursive( inner );
}
for ( const block of blocks ) {
validateRecursive( block );
}

const validCount = results.filter( ( r: any ) => r.isValid ).length;
return {
blocks,
report: {
totalBlocks: results.length,
validBlocks: validCount,
invalidBlocks: results.length - validCount,
results,
},
};
}

function normalizeBlock( block: any ): any {
if ( ! block.name || block.name === 'core/freeform' || block.name === 'core/missing' ) {
return block;
}

const blockType = wpBlocks.getBlockType( block.name );
if ( ! blockType || typeof wpBlocks.createBlock !== 'function' ) {
return block;
}

const fixedInnerBlocks = Array.isArray( block.innerBlocks )
? block.innerBlocks.map( normalizeBlock )
: [];

try {
return wpBlocks.createBlock(
block.name,
block.attributes,
fixedInnerBlocks.length > 0 ? fixedInnerBlocks : undefined
);
} catch {
return block;
}
}

const initial = validateContent( html );

if ( initial.report.invalidBlocks === 0 ) {
return { report: initial.report };
}

for ( const block of blocks ) {
validateRecursive( block );
const fixedBlocks = initial.blocks.map( normalizeBlock );
const fixedContent = wpBlocks.serialize( fixedBlocks );

if ( fixedContent === html ) {
return { report: initial.report };
}

const validCount = results.filter( ( r: any ) => r.isValid ).length;
const fixed = validateContent( fixedContent );
return {
totalBlocks: results.length,
validBlocks: validCount,
invalidBlocks: results.length - validCount,
results,
report: initial.report,
proposedFix: {
fixedContent,
report: fixed.report,
},
};
/* eslint-enable @typescript-eslint/no-explicit-any */
}, content );

return applyBlockContentPolicy( report as ValidationReport );
const report = validation.report as ValidationReport;

if ( validation.proposedFix ) {
report.proposedFix = {
fixedContent: validation.proposedFix.fixedContent,
report: validation.proposedFix.report as ValidationReportBase,
};
}

return report;
} catch ( error ) {
// If navigation or evaluation failed, discard the cached page so the
// next call gets a fresh one.
Expand All @@ -187,33 +258,6 @@ export async function validateBlocks(
}
}

function applyBlockContentPolicy( report: ValidationReport ): ValidationReport {
const results = report.results.map( ( result ) => {
if ( result.blockName !== 'core/html' ) {
return result;
}

const policyIssues = getHtmlBlockPolicyIssues( result.originalContent );
if ( policyIssues.length === 0 ) {
return result;
}

return {
...result,
isValid: false,
issues: [ ...result.issues, ...policyIssues ],
};
} );

const validBlocks = results.filter( ( result ) => result.isValid ).length;
return {
...report,
results,
validBlocks,
invalidBlocks: results.length - validBlocks,
};
}

/** Clean up all cached editor pages. */
export async function cleanupValidatorPages(): Promise< void > {
for ( const ep of editorPages.values() ) {
Expand Down
Loading