Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
155 commits
Select commit Hold shift + click to select a range
f0fe7ba
Studio Code: point the agent at the WordPress source on disk instead …
chubes4 Oct 16, 2026
4c3a32c
Eval: compare trunk and spike prompts on the WordPress-knowledge case…
chubes4 Oct 16, 2026
2064bf1
Eval: format compare-prompt summary
chubes4 Oct 16, 2026
b8dadcd
Studio Code: drop the remaining WordPress tutorials from the agent's …
chubes4 Oct 16, 2026
35582ff
Eval: compare trunk with the v2 prompt; repeat via --repeat
chubes4 Oct 16, 2026
471aac6
Studio Code: enforce WordPress structure in the tools instead of the …
chubes4 Oct 16, 2026
db19913
Studio Code: validate block markup in the harness, not on request
chubes4 Oct 16, 2026
38c26a7
Studio Code: zero-instruction system prompt; skills bring their own t…
chubes4 Oct 16, 2026
bcb192d
Studio Code: state Studio's Automattic family and partners as identity
chubes4 Oct 16, 2026
776085c
Studio Code: read WordPress.com plan features and role; gate wpcom_re…
chubes4 Oct 16, 2026
abccc7f
Studio Code: the harness confirms irreversible calls before they run
chubes4 Oct 16, 2026
1120079
Studio Code eval: the scripted user approves confirmations and the re…
chubes4 Oct 16, 2026
d90b151
Studio Code: pin a request to its site; changes to another site wait …
chubes4 Oct 16, 2026
de04536
Restore the identity paragraph the WordPress.com AI proxy requires
chubes4 Oct 16, 2026
67db7ac
Judge eval cases on outcomes instead of tool calls
chubes4 Oct 16, 2026
feb7c98
Run each eval case against its own site registry with a bystander site
chubes4 Oct 16, 2026
4ab862c
Remove the bystander folder when creating it fails
chubes4 Oct 16, 2026
fa7d332
Validate post content wherever a WP-CLI command reads it, and show wh…
chubes4 Oct 16, 2026
c4988ab
Fix what the trunk-vs-spike eval exposed: theme development mode, JSO…
chubes4 Oct 16, 2026
9d9899e
Validate re-saved block markup before writing it, and deliver the gat…
chubes4 Oct 16, 2026
cdd1028
Clear a theme's pattern cache when the agent writes a pattern file, i…
chubes4 Oct 16, 2026
c70ac30
Report a block markup file the gate could not validate, instead of pa…
chubes4 Oct 16, 2026
c987b30
Simplify the block validation gate: validate without a running-site c…
chubes4 Oct 16, 2026
27f6f9e
Remove the prompt comparison scripts the outcome judge replaces
chubes4 Oct 16, 2026
9964280
Merge remote-tracking branch 'origin/trunk' into spike-refresh-v2
chubes4 Oct 23, 2026
d4af2fc
Studio Code: One write refusal and one saved-copy note
chubes4 Oct 23, 2026
11a977b
Studio Code: Saved Site Editor copies and Coming soon mode are site f…
chubes4 Oct 23, 2026
1c051d3
Studio Code: Ask before creating a site while one is active
chubes4 Oct 23, 2026
f16e605
Studio Code: Site facts as readers of one WordPress site
chubes4 Oct 23, 2026
53395e7
Studio Code: Note every write to a theme file a saved copy replaces
chubes4 Oct 24, 2026
4616ea6
Studio plugin: Run Studio's file write checks on the agent's own file…
chubes4 Oct 24, 2026
60c3797
Compare the hook's written files with forward slashes on Windows too
chubes4 Oct 24, 2026
9911f2a
Studio Code: State a WordPress.com plan's features from the platform'…
chubes4 Oct 24, 2026
17f98f1
Studio Code: Reach WordPress.com and the user's MCP servers through o…
chubes4 Oct 24, 2026
a6df0f8
Studio Code: Let the agent write the theme, and keep it carrying DESI…
chubes4 Oct 24, 2026
2a81dbf
Studio Code: Write DESIGN.md only into a theme built on it, with its …
chubes4 Oct 24, 2026
21d8ac1
Studio Code: Run real wp in the agent's shell
chubes4 Oct 24, 2026
fb678d2
Merge remote-tracking branch 'origin/spike/read-core-source-agent' in…
chubes4 Oct 24, 2026
658d2a8
Studio Code: Keep the look in theme.json, where WordPress reads it
chubes4 Oct 24, 2026
f291019
Merge branch 'spike/read-core-source-agent' of https://github.com/Aut…
chubes4 Oct 24, 2026
1054dc7
Studio Code: Let each tool decide whether and how it appears on a host
chubes4 Oct 24, 2026
0015aff
Merge remote-tracking branch 'origin/spike/read-core-source-agent' in…
chubes4 Oct 24, 2026
3a2ee11
Studio Code: Say at write time what WordPress will render, instead of…
chubes4 Oct 24, 2026
8ca5f50
Studio Code: One registry for what Studio tells the agent, by the act…
chubes4 Oct 24, 2026
b81a658
Studio Code: Add the site notes registry the previous commit's messag…
chubes4 Oct 24, 2026
9e7d07a
Studio Code: Build a site in five steps, with the tools saying what c…
chubes4 Oct 24, 2026
2e70621
Studio Code: Leave annotate, liberate, and plan questions to the tool…
chubes4 Oct 24, 2026
f5e867c
Validate post content where it lands, whatever route wrote it
chubes4 Oct 24, 2026
6a28f2b
Report the post content a failed call changed
chubes4 Oct 24, 2026
2c2fe50
Save block markup through the editor even when a wrapper is missing
chubes4 Oct 24, 2026
5dba2e3
Fix blocks an older block version accepts, and report attributes of t…
chubes4 Oct 24, 2026
64baa26
Continue a turn with the problems still open on the sites it changed
chubes4 Oct 24, 2026
eb4c261
Note a front-page template that renders no page's content
chubes4 Oct 24, 2026
949f290
Say the problems that continue a turn come from Studio's checks
chubes4 Oct 24, 2026
eae3598
Fold the annotate, imagery and build-site skills into their tools, an…
chubes4 Oct 24, 2026
306974c
Remove the annotate, imagery and build-site skill files
chubes4 Oct 24, 2026
90f3709
Name the post content fix by its absolute path
chubes4 Oct 24, 2026
db9be4c
Tag the problems that continue a turn as Studio's checks
chubes4 Oct 24, 2026
479d46a
Tag what Studio adds to a turn: <studio-notes> and <studio-checks>
chubes4 Oct 24, 2026
c1296af
Check what a call changes in an updated theme's files, by any route
chubes4 Oct 24, 2026
da60239
State a site's presets and homepage, on the result of a call that nam…
chubes4 Oct 24, 2026
6fb6ecf
Remove the plugin's hooks on Claude Code's and Codex's file tools
chubes4 Oct 24, 2026
22f98d5
Save a block editor fix only once validation settles, and note in-pag…
chubes4 Oct 24, 2026
1893d0d
Leave out MCP tools named like the harness's own, and skip a server t…
chubes4 Oct 24, 2026
34a125d
Ask before WP-CLI changes a site other than the request's
chubes4 Oct 24, 2026
3511d94
Check only the request's sites for changes, and skip tools that only …
chubes4 Oct 24, 2026
7c9c35e
Give a shell command's writes to the request's sites the write notes
chubes4 Oct 24, 2026
96e282d
Write the fix file's path with forward slashes, and pass the site tes…
chubes4 Oct 24, 2026
5561de4
State that WordPress is not installed until a site first starts
chubes4 Oct 24, 2026
180702f
Note a stat not bumped on stderr, outside a command's output
chubes4 Oct 24, 2026
7df7f3e
Check for a child theme's unchanged copies of its parent's files
chubes4 Oct 24, 2026
d57062f
Merge remote-tracking branch 'origin/spike/read-core-source-agent' in…
chubes4 Oct 24, 2026
6f479fe
Use the merged SiteChanges and OpenProblems in the child theme copies…
chubes4 Oct 24, 2026
867eb20
Type the recorded open problems in the child theme copies test
chubes4 Oct 24, 2026
ab67035
Note the files any call wrote, and on MCP what changed between calls
chubes4 Oct 24, 2026
aa71f60
Validate a page draft as block markup wherever it is written
chubes4 Oct 24, 2026
4edf976
Check at the end of a turn that header links reach home page sections…
chubes4 Oct 24, 2026
03bf6c0
Write the home section links test without replaceAll, for the CLI's T…
chubes4 Oct 24, 2026
e6b805b
Load the active theme's style.css where WordPress does not, as Twenty…
chubes4 Oct 24, 2026
048a5e6
Give shell commands a default timeout of two minutes
chubes4 Oct 24, 2026
ffb8c7f
Give models that cannot view a screenshot the page's rendered layout …
chubes4 Oct 24, 2026
3cce60f
Eval: a multi-page site, a block plugin and a blog, judged on what th…
chubes4 Oct 24, 2026
8940305
Check at the end of a turn that the front page is not also the posts …
chubes4 Oct 24, 2026
260a54a
Self-host the font a theme.json family asks for first, not its label,…
chubes4 Oct 24, 2026
b7b5745
Store colors written as palette values as the palette's presets, as t…
chubes4 Oct 24, 2026
d2707e1
Eval: judge the menu on the pages the prompt asks for, not every publ…
chubes4 Oct 24, 2026
927ca8e
Say when a theme made during the session is a classic theme, citing W…
chubes4 Oct 24, 2026
41fc92e
Read the active theme and its parent in one place
chubes4 Oct 24, 2026
a70e215
Keep module-only names unexported
chubes4 Oct 24, 2026
4c50a1a
Remove the Write, Edit and Bash payload size limits
chubes4 Oct 24, 2026
ea036e6
Studio Code: save the editor's fix block by block, and move textAlign…
chubes4 Oct 24, 2026
357e2c5
Studio Code: leave comment attributes of the wrong type out of the ed…
chubes4 Oct 25, 2026
f04bb18
Studio Code: check that the posts page is a page without content
chubes4 Oct 25, 2026
93c3ff3
Studio Code: check that the header's links to pages are a navigation
chubes4 Oct 25, 2026
2e58b8b
Studio Code: tell of a classic theme at its first PHP template
chubes4 Oct 25, 2026
1d3cd78
Studio Code: say what Studio's end-of-turn checks are
chubes4 Oct 25, 2026
8200fb6
Studio Code: check every page against the template WordPress renders …
chubes4 Oct 25, 2026
fde7807
Studio Code: raise a theme left without an index template at the end …
chubes4 Oct 25, 2026
f48bf4f
Studio Code: declare the template part areas a theme's templates state
chubes4 Oct 25, 2026
c73b48f
Studio Code: save the editor's lossless fix to a post, as to a file
chubes4 Oct 25, 2026
afd5d14
Studio Code: run the agent's own Studio for `studio` in its shell
chubes4 Oct 25, 2026
6cc7d36
Studio Code: raise a home template Settings → Reading renders nowhere
chubes4 Oct 25, 2026
18f3c1e
Merge trunk into spike/read-core-source-agent
chubes4 Oct 25, 2026
98b0b81
Studio Code: ask the user before a write starts a classic theme
chubes4 Oct 25, 2026
efd815b
Ask the user before a category plan applies, and say what WP-CLI's er…
chubes4 Oct 25, 2026
3230ef7
Bind a navigation link that names a page by id to its permalink
chubes4 Oct 25, 2026
3e2bcb2
Give the agent the markup the site's block editor inserts for a block
chubes4 Oct 25, 2026
3e9ba2b
Put what the editor inserts for a block into the note that needs it
chubes4 Oct 25, 2026
3a35371
Complete the move of block markup into the notes
chubes4 Oct 25, 2026
17a65bd
Keep a save through the editor from leaving the content worse
chubes4 Oct 25, 2026
5c6dd77
Run eval cases side by side, each with a range of ports of its own
chubes4 Oct 25, 2026
d219c29
Say only that a namespace has no blocks registered
chubes4 Oct 25, 2026
dcdca3c
Say what the site's block editor threw beside a block it did not regi…
chubes4 Oct 25, 2026
1cc8971
Store the inline styles a block would drop where the block saves them…
chubes4 Oct 25, 2026
3a01869
Eval: cases read as a user writes them, with an abilities plugin and …
chubes4 Oct 25, 2026
d0b3750
Say which capabilities a site's code takes from its administrator
chubes4 Oct 25, 2026
4ce2563
Eval: Bring in the user-reported and debugging cases from the eval PR
chubes4 Oct 25, 2026
0386407
Raise the problems a turn made, not the ones the user's site already had
chubes4 Oct 25, 2026
a413e0b
End the turn when the user closes a question without answering
chubes4 Oct 25, 2026
b8d13a1
Count a header's links to home sections as menu items
chubes4 Oct 25, 2026
1fba6b8
plugin-recommendations: Jetpack's blocks need its Blocks module
chubes4 Oct 25, 2026
7936157
Eval: Read the block styles after the theme's data registers them
chubes4 Oct 25, 2026
f82cd68
visual-design: A treatment blocks share is a block style
chubes4 Oct 25, 2026
cac3e47
Say which keys of a theme JSON file just written WordPress removes
chubes4 Oct 25, 2026
c3cb017
End the turn when the user closes a confirmation without answering
chubes4 Oct 25, 2026
15050b8
Merge branch 'trunk' into spike/read-core-source-agent
chubes4 Oct 25, 2026
0eabcd4
Name the blocks of a post that WordPress renders as nothing
chubes4 Oct 25, 2026
f86e0fa
Say which raw style values a block markup file repeats, and the prese…
chubes4 Oct 25, 2026
52f4edd
Merge remote-tracking branch 'origin/spike/read-core-source-agent' in…
chubes4 Oct 25, 2026
96635e5
Count skills the spike ships in the turn-stats test
chubes4 Oct 25, 2026
c693941
Hold Studio Code's rules in its tools, and say the rest as what to do
chubes4 Oct 26, 2026
c7711a5
Say which theme classes give a look that blocks of one type share
chubes4 Oct 26, 2026
95a748b
Say which of a theme's presets WordPress replaces with core's
chubes4 Oct 26, 2026
93095d2
Take the first screen for the agent, and show the user a capture only…
chubes4 Oct 26, 2026
e721c8e
Tell a model that cannot view images that Read's image is not shown t…
chubes4 Oct 26, 2026
c1afdd3
State a site fact that changed as it now reads, not also as no longer…
chubes4 Oct 26, 2026
b548b4d
Say that loading a skill is the agent's own step
chubes4 Oct 26, 2026
975d0d6
Set the terminal UI's backgrounds off the terminal's own background
chubes4 Oct 26, 2026
2626d12
Revert "Say that loading a skill is the agent's own step"
chubes4 Oct 26, 2026
76875f8
Read the CSS a theme.json holds for the shared block classes note
chubes4 Oct 26, 2026
9005f89
Name where WordPress keeps each kind of change to a theme it updates
chubes4 Oct 26, 2026
3efab19
Judge the preview's kind from its own section of the reply
chubes4 Oct 26, 2026
4e7d4c1
Keep a copy of each post's content as a file in Studio's workspace
chubes4 Oct 26, 2026
903e420
Leave the choice of plugin stack to the user in plugin recommendations
chubes4 Oct 26, 2026
26ddaf2
Judge the slideshow case by a slideshow on the home page, from any pl…
chubes4 Oct 26, 2026
b7e9057
Say what WordPress lists a style variation as when one is written
chubes4 Oct 26, 2026
203753e
Send a change to a pattern a template shows to the template saved for it
chubes4 Oct 26, 2026
bddde96
Reach remote sites through a host, with WordPress.com the one host
chubes4 Oct 26, 2026
e3029ef
Restate the request's site facts after every call, not only Studio's own
chubes4 Oct 26, 2026
7236301
Leave the meta capabilities a post type names for itself out of the a…
chubes4 Oct 26, 2026
1e6dc5c
Say a server-rendered block's output is what its render file prints
chubes4 Oct 26, 2026
2e89002
Report a theme a call left broken, with WordPress's reason
chubes4 Oct 26, 2026
764e871
Start the eval's seeded site and leave it running, as Studio does
chubes4 Oct 26, 2026
a30067e
Load the block editor again when the login lands on another admin page
chubes4 Oct 26, 2026
b2f4931
Merge remote-tracking branch 'origin/spike/read-core-source-agent' in…
chubes4 Oct 26, 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
196 changes: 196 additions & 0 deletions apps/cli/ai/admin-capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/**
* The capabilities the administrator role grants that the site's code takes
* from its administrator. WordPress resolves a capability through
* map_meta_cap() and the user_has_cap filter (wp-includes/capabilities.php),
* which plugins and themes extend, so code that maps a capability to
* do_not_allow denies it to every user and every screen that checks it. A
* post type that names a primitive capability, such as edit_posts, as its
* edit_post, read_post or delete_post makes WordPress map that capability as
* the meta capability for every check (_post_type_meta_capabilities() in
* wp-includes/post.php). The meta capabilities a post type names from its own
* capability_type (get_post_type_capabilities()) need a post by design, so a
* role that holds them, as WooCommerce gives the administrator edit_product,
* is not reported.
*
* When the PHP of a site's active plugins or themes changes, or the plugins and theme active change, Studio asks WordPress
* which of the role's capabilities user_can() denies the first administrator,
* with the site's code loaded and with its plugins and themes skipped: the
* ones denied only with the code loaded are the code's.
*/
import { readdir, stat } from 'fs/promises';
import path from 'path';
import { readWordPressSite } from 'cli/ai/site-database';
import { runWpCli } from 'cli/ai/tools/utils';
import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client';
import type { SiteChangeSource, Versions } from 'cli/ai/site-changes';
import type { SiteData } from 'cli/lib/cli-config/core';

const KEY = 'administrator capabilities';

interface Denied {
user: number;
caps: Record< string, { mapped: string[]; types: Array< [ string, string ] > } >;
}

const DENIED = `global $post_type_meta_caps;
$admins = get_users( array( 'role' => 'administrator', 'number' => 1, 'fields' => 'ID', 'orderby' => 'ID' ) );
$role = get_role( 'administrator' );
$denied = array( 'user' => $admins ? (int) $admins[0] : 0, 'caps' => new stdClass() );
foreach ( $admins && $role ? array_keys( array_filter( $role->capabilities ) ) : array() as $cap ) {
if ( user_can( $denied['user'], $cap ) ) {
continue;
}
$types = array();
$own = false;
foreach ( isset( $post_type_meta_caps[ $cap ] ) ? get_post_types( array( 'map_meta_cap' => true ), 'objects' ) : array() as $type ) {
foreach ( array( 'read_post', 'edit_post', 'delete_post' ) as $meta ) {
if ( $type->cap->$meta === $cap ) {
// The meta capability get_post_type_capabilities() names from the
// type's capability_type, as WooCommerce's edit_product, needs a post
// by design, whatever roles hold its name.
$singular = ( (array) $type->capability_type )[0];
$own = $own || $cap === str_replace( '_post', '_' . $singular, $meta );
$types[] = array( $type->name, $meta );
}
}
}
if ( $own ) {
continue;
}
$denied['caps']->$cap = array( 'mapped' => map_meta_cap( $cap, $denied['user'] ), 'types' => $types );
}
echo 'DENIED_CAPS=' . wp_json_encode( $denied );`;

async function deniedCaps( site: SiteData, skipCode: boolean ): Promise< Denied > {
const output = await runWpCli( site, [
'eval',
DENIED,
...( skipCode ? [ '--skip-plugins', '--skip-themes' ] : [] ),
] );
const json = output.slice( output.indexOf( 'DENIED_CAPS=' ) + 'DENIED_CAPS='.length );
return JSON.parse( json ) as Denied;
}

async function phpFiles( dir: string ): Promise< string[] > {
const entries = await readdir( dir, { recursive: true, withFileTypes: true } ).catch( () => [] );
return Promise.all(
entries
.filter( ( entry ) => entry.isFile() && entry.name.endsWith( '.php' ) )
.map( async ( entry ) => {
const file = path.join( entry.parentPath, entry.name );
const { mtimeMs, size } = await stat( file ).catch( () => ( { mtimeMs: 0, size: -1 } ) );
return `${ path.relative( dir, file ) }:${ mtimeMs }:${ size }`;
} )
);
}

// The site's active code, as one version: which plugins and themes are
// active, and their PHP files. Must-use plugins load with plugins skipped too.
async function readVersions( sitePath: string ): Promise< Versions > {
const content = path.join( sitePath, 'wp-content' );
const active = await readWordPressSite( sitePath, ( site ) => {
const plugins = site.option( 'active_plugins' );
return {
plugins: plugins && typeof plugins === 'object' ? Object.values( plugins ).map( String ) : [],
themes: [ ...new Set( [ site.option( 'template' ), site.option( 'stylesheet' ) ] ) ].filter(
( theme ): theme is string => typeof theme === 'string' && theme !== ''
),
};
} );
const dirs = [
...active.plugins
.filter( ( plugin ) => plugin.includes( '/' ) )
.map( ( plugin ) => path.join( content, 'plugins', path.dirname( plugin ) ) ),
...active.themes.map( ( theme ) => path.join( content, 'themes', theme ) ),
];
const singleFiles = await Promise.all(
active.plugins
.filter( ( plugin ) => ! plugin.includes( '/' ) )
.map( ( plugin ) =>
stat( path.join( content, 'plugins', plugin ) ).then(
( { mtimeMs, size } ) => `${ plugin }:${ mtimeMs }:${ size }`,
() => plugin
)
)
);
const files = await Promise.all( dirs.map( phpFiles ) );
return {
code: JSON.stringify( [
active,
singleFiles,
files.map( ( dirFiles ) => dirFiles.sort().join( '|' ) ),
] ),
};
}

const list = ( items: string[] ) =>
items.length < 2
? items.join( '' )
: `${ items.slice( 0, -1 ).join( ', ' ) } and ${ items.at( -1 ) }`;

function describeDenied( site: SiteData, user: number, denied: Denied[ 'caps' ] ): string {
const caps = Object.keys( denied );
const mapped = [ ...new Set( caps.flatMap( ( cap ) => denied[ cap ].mapped ) ) ];
const byType = new Map< string, string[] >();
for ( const cap of caps ) {
for ( const [ type, meta ] of denied[ cap ].types ) {
byType.set( type, [ ...( byType.get( type ) ?? [] ), `${ cap } as its ${ meta }` ] );
}
}
const them = caps.length === 1 ? 'it' : 'them';
return [
`${ site.name }: user_can() denies its administrator (user ${ user }) ${ list(
caps
) }, which the administrator role grants and WordPress grants with the site's plugins and theme skipped; map_meta_cap() (wp-includes/capabilities.php) maps ${ them } to ${ list(
mapped
) }.`,
...[ ...byType ].map(
( [ type, named ] ) =>
`Post type ${ type } names ${ list( named ) } capability, so WordPress checks ${
named.length === 1
? 'that capability as the meta capability, which needs'
: 'those capabilities as the meta capabilities, which need'
} a post (_post_type_meta_capabilities() in wp-includes/post.php).`
),
...( byType.size
? [
"register_post_type()'s capability_type names a post type's capabilities from its own name, as core's page post type has edit_page and edit_pages.",
]
: [] ),
].join( ' ' );
}

export const adminCapabilities: SiteChangeSource = {
name: 'admin-capabilities',
read: readVersions,
async check( site, before, after ) {
if ( before.code === after.code ) {
return { reports: [] };
}
let withCode: Denied;
let core: Denied;
try {
await connectToDaemon();
withCode = await deniedCaps( site, false );
core = await deniedCaps( site, true );
} catch {
// Code that does not load is reported by what runs it.
return { reports: [] };
} finally {
await disconnectFromDaemon();
}
const denied = Object.fromEntries(
Object.entries( withCode.caps ).filter( ( [ cap ] ) => ! ( cap in core.caps ) )
);
return {
reports: [
{
key: KEY,
problem: Object.keys( denied ).length
? describeDenied( site, withCode.user, denied )
: undefined,
},
],
};
},
};
90 changes: 90 additions & 0 deletions apps/cli/ai/block-alignment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* Full-width blocks that cannot reach the page edges. A block spans the page
* with `align: full` only inside containers that span it: a constrained layout
* (wp-includes/block-supports/layout.php) holds each other child to the
* content width, and the full-width blocks inside that child with it.
*/

// A block's opening, closing, or self-closing comment in serialized markup.
const BLOCK_DELIMITER =
/<!--\s+(\/)?wp:([a-z][a-z0-9_-]*(?:\/[a-z][a-z0-9_-]*)?)\s+(?:(\{[\s\S]*?\})\s+)?(\/)?-->/g;

interface BlockAttributes {
align?: unknown;
className?: unknown;
layout?: { type?: unknown; inherit?: unknown; contentSize?: unknown };
}

interface OpenBlock {
label: string;
constrains: boolean;
reachesEdges: boolean;
}

// Which layout WordPress applies: a `layout` without a type that inherits or
// sets a content size is the constrained layout.
function constrains( { layout }: BlockAttributes ): boolean {
return (
layout?.type === 'constrained' ||
( layout?.type === undefined &&
( layout?.inherit === true || layout?.contentSize !== undefined ) )
);
}

function labelOf( name: string, attributes: BlockAttributes ): string {
const block = name.includes( '/' ) ? name : `core/${ name }`;
return typeof attributes.className === 'string'
? `${ block } (${ attributes.className
.split( /\s+/ )
.map( ( c ) => `.${ c }` )
.join( '' ) })`
: block;
}

// The page content: its align full blocks span the page only when it does.
const POST_CONTENT = 'core/post-content';

/**
* What keeps each full-width block in this markup, and the page content, from
* the page edges, taking its top-level blocks to span the page: those of a
* template do, and those of content do unless the template holds them.
*/
export function trappedFullWidthNotes( markup: string ): string[] {
const open: OpenBlock[] = [];
const notes = new Set< string >();
for ( const [ , closing, name, json, selfClosing ] of markup.matchAll( BLOCK_DELIMITER ) ) {
if ( closing ) {
open.pop();
continue;
}
let attributes: BlockAttributes = {};
try {
attributes = json ? JSON.parse( json ) : {};
} catch {
// Invalid attributes are the block check's to report.
}
const parent = open.at( -1 );
const full = attributes.align === 'full';
const reachesEdges = ! parent || ( parent.reachesEdges && ( full || ! parent.constrains ) );
const block = {
label: labelOf( name, attributes ),
constrains: constrains( attributes ),
reachesEdges,
};
const isPostContent = block.label.startsWith( POST_CONTENT );
if ( ( full || isPostContent ) && ! reachesEdges ) {
// The outermost block that stops short: the one align full would free.
const stop = [ ...open, block ].find( ( { reachesEdges: reaches } ) => ! reaches ) ?? block;
const subject = isPostContent
? `${ POST_CONTENT }, and so no align full block in a page's content,`
: `${ block.label }, align full,`;
notes.add(
`${ subject } cannot reach the page edges: ${ stop.label } is not align full, and the constrained layout around it holds it to the content width (wp-includes/block-supports/layout.php). Give ${ stop.label } align full.`
);
}
if ( ! selfClosing ) {
open.push( block );
}
}
return [ ...notes ];
}
12 changes: 10 additions & 2 deletions apps/cli/ai/block-content-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ const PLUGIN_RECOMMENDATION_HTML_BLOCK_POLICIES = [
{
htmlPatterns: [ /<(form|input|select|textarea|fieldset)\b/i ],
htmlPolicyMessage:
'core/html contains form markup. Load the plugin-recommendations skill and use editable plugin blocks such as Jetpack Forms. This keeps forms editable in the block editor and handles submission without custom backend code.',
"core/html contains form markup. A form plugin's block, such as Jetpack Forms (jetpack/contact-form), stays editable in the block editor and handles submission; the plugin-recommendations skill names the plugin for each feature.",
block: 'jetpack/contact-form',
},
];

Expand All @@ -16,6 +17,8 @@ export interface HtmlBlockPolicyResult {
line: number;
content: string;
issues: string[];
/** The block a plugin registers for it, when one is recommended. */
block?: string;
}

export interface HtmlBlockPolicyReport {
Expand Down Expand Up @@ -45,7 +48,7 @@ export function getHtmlBlockPolicyIssues( content: string ): string[] {
}

return [
'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.',
'core/html contains markup that should use editable core blocks. 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.',
];
}

Expand All @@ -61,11 +64,16 @@ export function validateHtmlBlockPolicy( content: string ): HtmlBlockPolicyRepor
continue;
}

const html = blockContent.replace( BLOCK_COMMENT_PATTERN, '' );
const block = PLUGIN_RECOMMENDATION_HTML_BLOCK_POLICIES.find( ( recommendation ) =>
recommendation.htmlPatterns.some( ( pattern ) => pattern.test( html ) )
)?.block;
invalidHtmlBlocks.push( {
blockNumber: totalHtmlBlocks,
line: content.slice( 0, match.index ?? 0 ).split( '\n' ).length,
content: blockContent.trim(),
issues,
...( block && { block } ),
} );
}

Expand Down
Loading
Loading