diff --git a/.gitignore b/.gitignore
index 3e92ec2dc3..74aa671664 100644
--- a/.gitignore
+++ b/.gitignore
@@ -136,7 +136,6 @@ artifacts
.cursor/
.claude/
CLAUDE.md
-!skills/CLAUDE.md
# Bundled Node binary (downloaded during build)
apps/studio/bin/node
diff --git a/apps/cli/README.md b/apps/cli/README.md
index 13f472a8bf..fa91080edb 100644
--- a/apps/cli/README.md
+++ b/apps/cli/README.md
@@ -12,7 +12,7 @@ The Studio CLI lets you:
- Pull from and push to WordPress.com sites.
- Publish ephemeral preview sites to share (requires WordPress.com login).
- Build WordPress sites in Studio Code with an interactive AI agent specialized in WordPress, backed by the full power of the Studio CLI.
-- Integrate with other AI coding agents. Every site comes with an `AGENTS.md` file.
+- Integrate with other AI coding agents through `studio mcp`, which serves the Studio tools over MCP.
diff --git a/apps/cli/ai/active-code.ts b/apps/cli/ai/active-code.ts
new file mode 100644
index 0000000000..b574af54a5
--- /dev/null
+++ b/apps/cli/ai/active-code.ts
@@ -0,0 +1,75 @@
+/**
+ * The code a site runs: its active plugins and themes, read from the options
+ * WordPress loads them by (active_plugins, template and stylesheet), and the
+ * files in their folders, as one version a check compares to see whether that
+ * code changed. Dependencies a build installs (node_modules) are not the
+ * site's code and are not read.
+ */
+import { readdir, stat } from 'fs/promises';
+import path from 'path';
+import { readWordPressSite } from 'cli/ai/site-database';
+
+// A file as its path, modification time and size.
+async function version( file: string, from: string ): Promise< string > {
+ const { mtimeMs, size } = await stat( file ).catch( () => ( { mtimeMs: 0, size: -1 } ) );
+ return `${ path.relative( from, file ) }:${ mtimeMs }:${ size }`;
+}
+
+async function files( dir: string, wanted: ( name: string ) => boolean ): Promise< string[] > {
+ const entries = await readdir( dir, { withFileTypes: true } ).catch( () => [] );
+ const found = await Promise.all(
+ entries.map( ( entry ) => {
+ const at = path.join( dir, entry.name );
+ if ( entry.isDirectory() ) {
+ return entry.name === 'node_modules' ? [] : files( at, wanted );
+ }
+ return entry.isFile() && wanted( entry.name ) ? [ at ] : [];
+ } )
+ );
+ return found.flat();
+}
+
+/**
+ * The version of each of the site's active plugins and themes, by
+ * `plugin ` (as active_plugins names it) and `theme `, from the
+ * files in them `wanted` keeps.
+ */
+export async function activeCode(
+ sitePath: string,
+ wanted: ( name: string ) => boolean
+): Promise< Record< string, string > > {
+ 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 !== ''
+ ),
+ };
+ } );
+ // A plugin in a folder is the folder; a single-file plugin is its file.
+ const dirVersion = async ( dir: string ) =>
+ ( await Promise.all( ( await files( dir, wanted ) ).map( ( file ) => version( file, dir ) ) ) )
+ .sort()
+ .join( '|' );
+ const plugins = active.plugins.map( async ( plugin ) => [
+ `plugin ${ plugin }`,
+ plugin.includes( '/' )
+ ? await dirVersion( path.join( content, 'plugins', path.dirname( plugin ) ) )
+ : await version( path.join( content, 'plugins', plugin ), content ),
+ ] );
+ const themes = active.themes.map( async ( theme ) => [
+ `theme ${ theme }`,
+ await dirVersion( path.join( content, 'themes', theme ) ),
+ ] );
+ return Object.fromEntries( await Promise.all( [ ...plugins, ...themes ] ) );
+}
+
+/** The site's active plugins and themes, and the files in them `wanted` keeps, as one version. */
+export async function activeCodeVersion(
+ sitePath: string,
+ wanted: ( name: string ) => boolean
+): Promise< string > {
+ return JSON.stringify( await activeCode( sitePath, wanted ) );
+}
diff --git a/apps/cli/ai/admin-capabilities.ts b/apps/cli/ai/admin-capabilities.ts
new file mode 100644
index 0000000000..3472882cfc
--- /dev/null
+++ b/apps/cli/ai/admin-capabilities.ts
@@ -0,0 +1,341 @@
+/**
+ * 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.
+ *
+ * In the same runs, and one with only the changed plugins or theme skipped,
+ * Studio states what the changed code registers with WordPress: what the
+ * registries hold only with it loaded.
+ *
+ * Site fact: what all the site's code registers, by registry, from the same
+ * registries with the code loaded and skipped, and where WordPress lists each.
+ */
+import { activeCode } from 'cli/ai/active-code';
+import { runWpCli } from 'cli/ai/tools/utils';
+import { findSiteByFolder } from 'cli/lib/cli-config/sites';
+import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client';
+import type { SiteChangeSource, Versions } from 'cli/ai/site-changes';
+import type { WordPressSite } from 'cli/ai/site-database';
+import type { SiteData } from 'cli/lib/cli-config/core';
+
+const KEY = 'administrator capabilities';
+
+// The registries a plugin or theme adds to, by the function that adds to each.
+const REGISTRIES: Record< string, string > = {
+ 'post types': 'register_post_type()',
+ taxonomies: 'register_taxonomy()',
+ 'REST routes': 'register_rest_route()',
+ 'block types': 'register_block_type()',
+ abilities: 'wp_register_ability(), in wp-includes/abilities-api.php',
+ shortcodes: 'add_shortcode()',
+};
+
+const REGISTERED = `echo 'REGISTERED=' . wp_json_encode( array(
+ 'post types' => array_keys( get_post_types() ),
+ 'taxonomies' => array_keys( get_taxonomies() ),
+ 'REST routes' => array_keys( rest_get_server()->get_routes() ),
+ 'block types' => array_keys( WP_Block_Type_Registry::get_instance()->get_all_registered() ),
+ 'abilities' => function_exists( 'wp_get_abilities' ) ? array_map( function ( $ability ) { return $ability->get_name(); }, array_values( wp_get_abilities() ) ) : array(),
+ 'shortcodes' => array_keys( $GLOBALS['shortcode_tags'] ),
+) ) . "\\n";
+echo 'OUT_OF_REST=' . wp_json_encode( array(
+ 'post types' => array_keys( get_post_types( array( 'show_in_rest' => false ) ) ),
+ 'taxonomies' => array_keys( get_taxonomies( array( 'show_in_rest' => false ) ) ),
+ 'abilities' => function_exists( 'wp_get_abilities' ) ? array_values( array_filter( array_map( function ( $ability ) { return $ability->get_meta_item( 'show_in_rest' ) ? null : $ability->get_name(); }, array_values( wp_get_abilities() ) ) ) ) : array(),
+) ) . "\\n";`;
+
+// The setting that keeps a registry's item out of WordPress's REST API, which
+// is how agents and the block editor reach it.
+const IN_REST: Record< string, string > = {
+ 'post types': 'show_in_rest false',
+ taxonomies: 'show_in_rest false',
+ abilities:
+ 'meta show_in_rest false, so not listed by wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php',
+};
+
+type Registered = Record< string, string[] >;
+
+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 );`;
+
+// What WordPress prints after a marker, on its line.
+const printed = ( output: string, marker: string ) =>
+ JSON.parse( output.slice( output.indexOf( marker ) + marker.length ).split( '\n' )[ 0 ] );
+
+async function askWordPress(
+ site: SiteData,
+ skip: string[]
+): Promise< { denied: Denied; registered: Registered; outOfRest: Registered } > {
+ const output = await runWpCli( site, [ 'eval', REGISTERED + DENIED, ...skip ] );
+ return {
+ denied: printed( output, 'DENIED_CAPS=' ),
+ registered: printed( output, 'REGISTERED=' ),
+ outOfRest: printed( output, 'OUT_OF_REST=' ),
+ };
+}
+
+// The site's active code, by plugin and theme: which are active, and their
+// PHP files. Must-use plugins load with plugins skipped too.
+async function readVersions( sitePath: string ): Promise< Versions > {
+ return activeCode( sitePath, ( name ) => name.endsWith( '.php' ) );
+}
+
+// The WP-CLI flags that skip the given plugins and themes: a plugin by its
+// folder, or a single-file plugin by its name (WP_CLI\Utils\get_plugin_name()).
+function skipping( keys: string[] ): string[] {
+ const plugins = keys
+ .filter( ( key ) => key.startsWith( 'plugin ' ) )
+ .map( ( key ) =>
+ key
+ .slice( 'plugin '.length )
+ .split( '/' )[ 0 ]
+ .replace( /\.php$/, '' )
+ );
+ return [
+ ...( plugins.length ? [ `--skip-plugins=${ plugins.join( ',' ) }` ] : [] ),
+ ...( keys.some( ( key ) => key.startsWith( 'theme ' ) ) ? [ '--skip-themes' ] : [] ),
+ ];
+}
+
+const list = ( items: string[] ) =>
+ items.length < 2
+ ? items.join( '' )
+ : `${ items.slice( 0, -1 ).join( ', ' ) } and ${ items.at( -1 ) }`;
+
+const MAX_NAMES = 12;
+
+function describeRegistered(
+ site: SiteData,
+ changed: string[],
+ added: Registered,
+ outOfRest: Registered
+): string {
+ const names = ( items: string[] ) =>
+ items.length > MAX_NAMES
+ ? `${ items.slice( 0, MAX_NAMES ).join( ', ' ) } and ${ items.length - MAX_NAMES } more`
+ : items.join( ', ' );
+ const holds = Object.keys( REGISTRIES ).filter( ( registry ) => added[ registry ]?.length );
+ const none = Object.keys( REGISTRIES ).filter( ( registry ) => ! added[ registry ]?.length );
+ return `${ site.name }: with ${ list( changed ) } loaded, WordPress registers ${ [
+ ...holds.map( ( registry ) => {
+ const hidden = added[ registry ].filter(
+ ( name ) => outOfRest[ registry ]?.includes( name )
+ );
+ return `${ registry } ${ names( added[ registry ] ) } (${ REGISTRIES[ registry ] }${
+ hidden.length
+ ? `; with ${ IN_REST[ registry ] }, out of the REST API: ${ names( hidden ) }`
+ : ''
+ })`;
+ } ),
+ ...( none.length
+ ? [
+ `no ${ list(
+ none.map( ( registry ) => `${ registry } (${ REGISTRIES[ registry ] })` )
+ ) } of its own`,
+ ]
+ : [] ),
+ ].join( '; ' ) }.`;
+}
+
+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( ' ' );
+}
+
+// Where WordPress's REST API lists a registry's items, and the registries whose
+// items are named by namespace (namespace/name).
+const LISTED_AT: Record< string, string > = {
+ 'post types': '/wp/v2/types',
+ taxonomies: '/wp/v2/taxonomies',
+ 'REST routes': '/wp-json/',
+ 'block types': '/wp/v2/block-types/',
+ abilities: '/wp-abilities/v1/abilities',
+};
+const NAMESPACED = [ 'block types', 'abilities' ];
+
+function describeSiteCode( added: Registered ): string | undefined {
+ const holds = Object.keys( REGISTRIES ).filter( ( registry ) => added[ registry ]?.length );
+ if ( ! holds.length ) {
+ return undefined;
+ }
+ const counts = holds.map( ( registry ) => {
+ const namespaces = [
+ ...new Set( added[ registry ].map( ( name ) => name.split( '/' )[ 0 ] ) ),
+ ];
+ return `${ registry } ${ added[ registry ].length }${
+ NAMESPACED.includes( registry ) ? ` (namespace ${ list( namespaces ) })` : ''
+ }`;
+ } );
+ const listed = holds.filter( ( registry ) => LISTED_AT[ registry ] );
+ return `With the active plugins and theme loaded, WordPress registers, beyond core: ${ list(
+ counts
+ ) }. Its REST API lists those shown in it: ${ listed
+ .map( ( registry ) => `${ registry } at ${ LISTED_AT[ registry ] }` )
+ .join( ', ' ) }.`;
+}
+
+// By site, the site's code it was read for and the fact, read again only when that code changes.
+const siteCodeFacts = new Map< string, { code: string; fact: Promise< string | undefined > } >();
+
+async function readSiteCodeFact( sitePath: string ): Promise< string | undefined > {
+ const site = await findSiteByFolder( sitePath );
+ if ( ! site ) {
+ return undefined;
+ }
+ try {
+ await connectToDaemon();
+ const [ withCode, core ] = [
+ await askWordPress( site, [] ),
+ await askWordPress( site, [ '--skip-plugins', '--skip-themes' ] ),
+ ];
+ return describeSiteCode(
+ Object.fromEntries(
+ Object.entries( withCode.registered ).map( ( [ registry, names ] ) => [
+ registry,
+ names.filter( ( name ) => ! core.registered[ registry ]?.includes( name ) ),
+ ] )
+ )
+ );
+ } catch {
+ // Code that does not load is reported by what runs it.
+ return undefined;
+ } finally {
+ await disconnectFromDaemon();
+ }
+}
+
+export async function siteCodeFact( site: WordPressSite ): Promise< string | undefined > {
+ const code = JSON.stringify( await readVersions( site.root ) );
+ const known = siteCodeFacts.get( site.root );
+ if ( known?.code === code ) {
+ return known.fact;
+ }
+ const fact = readSiteCodeFact( site.root );
+ siteCodeFacts.set( site.root, { code, fact } );
+ return fact;
+}
+
+export const adminCapabilities: SiteChangeSource = {
+ name: 'admin-capabilities',
+ read: readVersions,
+ async check( site, before, after ) {
+ const changed = Object.keys( after ).filter( ( key ) => before[ key ] !== after[ key ] );
+ const removed = Object.keys( before ).some( ( key ) => ! ( key in after ) );
+ if ( ! changed.length && ! removed ) {
+ return { reports: [] };
+ }
+ let withCode: Awaited< ReturnType< typeof askWordPress > >;
+ let core: Awaited< ReturnType< typeof askWordPress > >;
+ let without: Awaited< ReturnType< typeof askWordPress > >;
+ try {
+ await connectToDaemon();
+ withCode = await askWordPress( site, [] );
+ core = await askWordPress( site, [ '--skip-plugins', '--skip-themes' ] );
+ without = changed.length ? await askWordPress( site, skipping( changed ) ) : withCode;
+ } catch {
+ // Code that does not load is reported by what runs it.
+ return { reports: [] };
+ } finally {
+ await disconnectFromDaemon();
+ }
+ const denied = Object.fromEntries(
+ Object.entries( withCode.denied.caps ).filter( ( [ cap ] ) => ! ( cap in core.denied.caps ) )
+ );
+ const added = Object.fromEntries(
+ Object.entries( withCode.registered ).map( ( [ registry, names ] ) => [
+ registry,
+ names.filter( ( name ) => ! without.registered[ registry ]?.includes( name ) ),
+ ] )
+ );
+ return {
+ reports: [
+ {
+ key: KEY,
+ problem: Object.keys( denied ).length
+ ? describeDenied( site, withCode.denied.user, denied )
+ : undefined,
+ },
+ ...( changed.length
+ ? [
+ {
+ key: 'registered',
+ summary: describeRegistered( site, changed, added, withCode.outOfRest ),
+ },
+ ]
+ : [] ),
+ ],
+ };
+ },
+};
diff --git a/apps/cli/ai/block-alignment.ts b/apps/cli/ai/block-alignment.ts
new file mode 100644
index 0000000000..30fa7cf3b7
--- /dev/null
+++ b/apps/cli/ai/block-alignment.ts
@@ -0,0 +1,179 @@
+/**
+ * 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 =
+ //g;
+
+interface BlockAttributes {
+ align?: unknown;
+ className?: unknown;
+ layout?: { type?: unknown; inherit?: unknown; contentSize?: unknown; wideSize?: unknown };
+}
+
+interface OpenBlock {
+ label: string;
+ name: string;
+ attributes: BlockAttributes;
+ constrains: boolean;
+ reachesEdges: boolean;
+ /** Where its opening comment starts and ends in the markup. */
+ at: number;
+ end: number;
+ selfClosing: boolean;
+}
+
+interface Trap {
+ subject: string;
+ stop: OpenBlock;
+ /** The block that holds the stop to the content width. */
+ holder: OpenBlock;
+}
+
+// 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 the 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.
+function traps( markup: string ): Trap[] {
+ const open: OpenBlock[] = [];
+ const found: Trap[] = [];
+ for ( const match of markup.matchAll( BLOCK_DELIMITER ) ) {
+ const [ delimiter, closing, name, json, selfClosing ] = match;
+ 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 block: OpenBlock = {
+ label: labelOf( name, attributes ),
+ name: name.includes( '/' ) ? name : `core/${ name }`,
+ attributes,
+ constrains: constrains( attributes ),
+ reachesEdges: ! parent || ( parent.reachesEdges && ( full || ! parent.constrains ) ),
+ at: match.index,
+ end: match.index + delimiter.length,
+ selfClosing: Boolean( selfClosing ),
+ };
+ const isPostContent = block.name === POST_CONTENT;
+ if ( ( full || isPostContent ) && ! block.reachesEdges ) {
+ // The outermost block that stops short: the one align full would free.
+ const chain = [ ...open, block ];
+ const index = chain.findIndex( ( { reachesEdges } ) => ! reachesEdges );
+ found.push( {
+ subject: isPostContent
+ ? `${ POST_CONTENT }, and so no align full block in a page's content,`
+ : `${ block.label }, align full,`,
+ stop: chain[ index ],
+ holder: chain[ index - 1 ],
+ } );
+ }
+ if ( ! selfClosing ) {
+ open.push( block );
+ }
+ }
+ return found;
+}
+
+/** What keeps each full-width block in this markup, and the page content, from the page edges. */
+export function trappedFullWidthNotes( markup: string ): string[] {
+ return [
+ ...new Set(
+ traps( markup ).map(
+ ( { subject, stop } ) =>
+ `${ 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.`
+ )
+ ),
+ ];
+}
+
+// The attributes that give a block no look of its own: what it is and how it
+// lays out its blocks.
+const STRUCTURAL = new Set( [ 'layout', 'tagName', 'lock', 'metadata', 'anchor' ] );
+
+const ownSizes = ( { layout }: BlockAttributes ) =>
+ layout?.contentSize !== undefined || layout?.wideSize !== undefined;
+
+/**
+ * The markup with each block that keeps full-width blocks from the page edges
+ * made align full, where that changes nothing else: a block with no look of
+ * its own whose constrained layout holds its other blocks to the content width
+ * the block around it held them to. `supportsFull` says whether a block type
+ * supports align full (block.json supports.align).
+ */
+export function freeTrappedBlocks(
+ markup: string,
+ supportsFull: ( name: string ) => boolean
+): { markup: string; freed: string[] } {
+ const stops = new Map< number, OpenBlock >();
+ for ( const { stop, holder } of traps( markup ) ) {
+ if (
+ stop.constrains &&
+ ! ownSizes( stop.attributes ) &&
+ ! ownSizes( holder.attributes ) &&
+ Object.keys( stop.attributes ).every( ( key ) => STRUCTURAL.has( key ) ) &&
+ supportsFull( stop.name )
+ ) {
+ stops.set( stop.at, stop );
+ }
+ }
+ let freed = markup;
+ const labels: string[] = [];
+ // From the last, so each place stays where the scan found it.
+ for ( const stop of [ ...stops.values() ].sort( ( a, b ) => b.at - a.at ) ) {
+ // A saved block carries the alignfull class on its wrapper, as the
+ // align support adds it (wp-includes/block-supports/align.php).
+ const wrapper = stop.selfClosing
+ ? undefined
+ : /^\s*<[a-z][a-z0-9]*\b[^>]*?\bclass="([^"]*)"/i.exec( freed.slice( stop.end ) );
+ if ( ! stop.selfClosing && ! wrapper ) {
+ continue;
+ }
+ const comment = freed
+ .slice( stop.at, stop.end )
+ .replace(
+ /^()$/,
+ `$1 ${ JSON.stringify( { ...stop.attributes, align: 'full' } ) }$2`
+ );
+ const after = wrapper
+ ? freed
+ .slice( stop.end )
+ .replace( wrapper[ 0 ], `${ wrapper[ 0 ].slice( 0, -1 ) } alignfull"` )
+ : freed.slice( stop.end );
+ freed = freed.slice( 0, stop.at ) + comment + after;
+ labels.push( stop.label );
+ }
+ return { markup: freed, freed: labels.reverse() };
+}
diff --git a/apps/cli/ai/block-content-policy.ts b/apps/cli/ai/block-content-policy.ts
index 8104a6f962..d7665372cf 100644
--- a/apps/cli/ai/block-content-policy.ts
+++ b/apps/cli/ai/block-content-policy.ts
@@ -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',
},
];
@@ -16,6 +17,8 @@ interface HtmlBlockPolicyResult {
line: number;
content: string;
issues: string[];
+ /** The block a plugin registers for it, when one is recommended. */
+ block?: string;
}
export interface HtmlBlockPolicyReport {
@@ -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 blocks. Keep core/html only for inline SVG, interaction markup with no block equivalent (marquee, cursor), or a single script block.',
];
}
@@ -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 } ),
} );
}
diff --git a/apps/cli/ai/block-editor-scripts.ts b/apps/cli/ai/block-editor-scripts.ts
new file mode 100644
index 0000000000..b22f73d70a
--- /dev/null
+++ b/apps/cli/ai/block-editor-scripts.ts
@@ -0,0 +1,88 @@
+/**
+ * The blocks a site's own code registers from a block.json that WordPress
+ * gives nothing to register them in the block editor. A block type has the
+ * editor scripts its block.json "editorScript" names
+ * (register_block_type_from_metadata() in wp-includes/blocks.php fills
+ * WP_Block_Type::$editor_script_handles from it), which the block editor
+ * loads for it; a block whose "supports" has "autoRegister" and that renders
+ * on the server needs none, as the editor registers it itself
+ * (_wp_enqueue_auto_register_blocks() in wp-includes/blocks.php).
+ *
+ * When the block.json, PHP or JavaScript files of a site's active plugins or
+ * themes change, Studio asks WordPress, with the site's code loaded, which
+ * block types named by a block.json in them have neither.
+ */
+import { activeCodeVersion } from 'cli/ai/active-code';
+import { runWpCli } from 'cli/ai/tools/utils';
+import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client';
+import type { SiteChangeSource } from 'cli/ai/site-changes';
+
+const KEY = 'block editor scripts';
+
+const WITHOUT = `$dirs = array_map( 'dirname', array_filter( wp_get_active_and_valid_plugins(), fn ( $file ) => dirname( $file ) !== WP_PLUGIN_DIR ) );
+$dirs = array_unique( array_merge( $dirs, array( get_template_directory(), get_stylesheet_directory() ) ) );
+$without = array();
+foreach ( $dirs as $dir ) {
+ $files = new RecursiveIteratorIterator(
+ new RecursiveCallbackFilterIterator(
+ new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ),
+ fn ( $file ) => 'node_modules' !== $file->getFilename()
+ )
+ );
+ foreach ( $files as $file ) {
+ if ( 'block.json' !== $file->getFilename() ) {
+ continue;
+ }
+ $metadata = wp_json_file_decode( $file->getPathname(), array( 'associative' => true ) );
+ $type = is_array( $metadata ) && isset( $metadata['name'] ) && is_string( $metadata['name'] )
+ ? WP_Block_Type_Registry::get_instance()->get_registered( $metadata['name'] )
+ : null;
+ if ( ! $type || $type->editor_script_handles || ( ! empty( $type->supports['autoRegister'] ) && $type->render_callback ) ) {
+ continue;
+ }
+ $without[ $type->name ][] = $file->getPathname();
+ }
+}
+echo 'WITHOUT_EDITOR_SCRIPT=' . wp_json_encode( (object) $without );`;
+
+export const blockEditorScripts: SiteChangeSource = {
+ name: KEY,
+ async read( sitePath ) {
+ return {
+ code: await activeCodeVersion( sitePath, ( name ) => /\.(php|js|json)$/.test( name ) ),
+ };
+ },
+ async check( site, before, after ) {
+ if ( before.code === after.code ) {
+ return { reports: [] };
+ }
+ let without: Record< string, string[] >;
+ try {
+ await connectToDaemon();
+ const output = await runWpCli( site, [ 'eval', WITHOUT ] );
+ without = JSON.parse(
+ output.slice( output.indexOf( 'WITHOUT_EDITOR_SCRIPT=' ) + 'WITHOUT_EDITOR_SCRIPT='.length )
+ ) as Record< string, string[] >;
+ } catch {
+ // Code that does not load is reported by what runs it.
+ return { reports: [] };
+ } finally {
+ await disconnectFromDaemon();
+ }
+ const names = Object.keys( without );
+ return {
+ reports: [
+ {
+ key: KEY,
+ problem: names.length
+ ? `${ site.name }: WordPress registers ${ names
+ .map( ( name ) => `${ name } (${ without[ name ].join( ', ' ) })` )
+ .join(
+ ', '
+ ) } with no editor script. register_block_type_from_metadata() (wp-includes/blocks.php) gives a block type the scripts its block.json "editorScript" names, and the block editor loads them for it (WP_Block_Type::$editor_script_handles). "editorScript" takes a handle the plugin registers before the block, or "file:./index.js", which register_block_script_handle() registers with the dependencies and version of the index.asset.php beside it, as @wordpress/scripts writes it. A block that renders on the server can instead have "supports": { "autoRegister": true }, and the editor registers it itself (_wp_enqueue_auto_register_blocks() in wp-includes/blocks.php).`
+ : undefined,
+ },
+ ],
+ };
+ },
+};
diff --git a/apps/cli/ai/block-render-check.ts b/apps/cli/ai/block-render-check.ts
new file mode 100644
index 0000000000..eeed7666ed
--- /dev/null
+++ b/apps/cli/ai/block-render-check.ts
@@ -0,0 +1,277 @@
+/**
+ * The blocks of a post or page that WordPress renders as nothing on the front
+ * end. Validation compares the markup written with what the block saves, so a
+ * block whose save is empty, as a slideshow written without its slides is,
+ * passes it and still shows nothing. Studio renders the content as the front
+ * end does, through the_content, whose do_blocks() renders each block with
+ * render_block() (wp-includes/blocks.php), and names the blocks whose output
+ * is empty.
+ *
+ * A template, template part or pattern renders inside a page, so when one
+ * changes Studio renders the front page as a visitor who is not logged in
+ * gets it, and states which of its blocks rendered as nothing. Blocks render
+ * as nothing there by design, as a query's pagination does with one page of
+ * posts, so that is what WordPress shows, not a problem.
+ */
+import { readFile, stat } from 'fs/promises';
+import path from 'path';
+import { readPosts, readVersions } from 'cli/ai/post-content-check';
+import { readWordPressSite } from 'cli/ai/site-database';
+import { themeMarkupFiles } from 'cli/ai/site-markup';
+import { runWpCli } from 'cli/ai/tools/utils';
+import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client';
+import type { ChangeReport, SiteChangeSource, Versions } from 'cli/ai/site-changes';
+import type { SiteData } from 'cli/lib/cli-config/core';
+
+// The post types whose content the front end renders through the_content.
+const FRONT_END_TYPES = new Set( [ 'post', 'page' ] );
+
+const RENDER = ( ids: number[] ) => `
+$empty = array();
+foreach ( ${ JSON.stringify( ids ) } as $id ) {
+ $post = get_post( $id );
+ if ( ! $post ) {
+ continue;
+ }
+ $GLOBALS['post'] = $post;
+ setup_postdata( $post );
+ $found = array();
+ // A dynamic block renders its inner blocks into its own output, as a
+ // form field does its label, so their output alone is not what shows.
+ $parent = function ( $parsed, $source, $parent_block ) {
+ $parsed['studioInDynamicBlock'] = $parent_block && $parent_block->block_type && $parent_block->block_type->is_dynamic();
+ return $parsed;
+ };
+ $record = function ( $html, $block ) use ( &$found ) {
+ if ( ! empty( $block['blockName'] ) && empty( $block['studioInDynamicBlock'] ) && '' === trim( $html ) ) {
+ $type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] );
+ $sourced = array();
+ foreach ( $type ? (array) $type->attributes : array() as $name => $attribute ) {
+ if ( isset( $attribute['source'] ) && 'meta' !== $attribute['source'] ) {
+ $sourced[] = $name;
+ }
+ }
+ // The file a block.json "render" names, which
+ // register_block_type_from_metadata() requires inside an output buffer.
+ $render = null;
+ if ( $type && $type->render_callback instanceof Closure ) {
+ $used = ( new ReflectionFunction( $type->render_callback ) )->getStaticVariables();
+ $render = isset( $used['template_path'] ) && is_string( $used['template_path'] ) ? $used['template_path'] : null;
+ }
+ $found[] = array(
+ 'name' => $block['blockName'],
+ 'saved' => '' !== trim( implode( '', array_filter( $block['innerContent'], 'is_string' ) ) ),
+ 'sourced' => $sourced,
+ 'render' => $render,
+ );
+ }
+ return $html;
+ };
+ add_filter( 'render_block_data', $parent, PHP_INT_MAX, 3 );
+ add_filter( 'render_block', $record, PHP_INT_MAX, 2 );
+ apply_filters( 'the_content', $post->post_content );
+ remove_filter( 'render_block', $record, PHP_INT_MAX );
+ remove_filter( 'render_block_data', $parent, PHP_INT_MAX );
+ wp_reset_postdata();
+ $empty[ $id ] = $found;
+}
+echo 'EMPTY_BLOCKS=' . wp_json_encode( (object) $empty );`;
+
+// The front page as template-loader.php resolves it for '/', rendered by
+// get_the_block_template_html() (wp-includes/block-template.php). The blocks a
+// dynamic block renders into its own output, and the content core/post-content
+// renders, which the posts' own check covers, are not named.
+const RENDER_FRONT_PAGE = `
+$_SERVER['REQUEST_URI'] = '/';
+wp();
+$names = array();
+$content = 0;
+add_filter( 'render_block_data', function ( $parsed, $source, $parent_block ) use ( &$content ) {
+ $parsed['studioSkipped'] = $content > 0 || ( $parent_block && $parent_block->block_type && $parent_block->block_type->is_dynamic() );
+ if ( 'core/post-content' === $parsed['blockName'] ) {
+ ++$content;
+ }
+ return $parsed;
+}, PHP_INT_MAX, 3 );
+add_filter( 'render_block', function ( $html, $block ) use ( &$names, &$content ) {
+ if ( 'core/post-content' === $block['blockName'] ) {
+ --$content;
+ }
+ if ( ! empty( $block['blockName'] ) && empty( $block['studioSkipped'] ) && '' === trim( $html ) ) {
+ $names[] = $block['blockName'];
+ }
+ return $html;
+}, PHP_INT_MAX, 2 );
+$template = false;
+foreach ( array( 'is_front_page' => 'get_front_page_template', 'is_home' => 'get_home_template', 'is_page' => 'get_page_template', 'is_singular' => 'get_singular_template' ) as $tag => $getter ) {
+ if ( $tag() && ( $template = $getter() ) ) {
+ break;
+ }
+}
+if ( ! $template ) {
+ get_index_template();
+}
+if ( ! empty( $GLOBALS['_wp_current_template_content'] ) ) {
+ get_the_block_template_html();
+}
+echo 'FRONT_EMPTY=' . wp_json_encode( array_values( array_unique( $names ) ) );`;
+
+/** The blocks of the front page that render as nothing for a visitor who is not logged in. */
+async function frontPageEmptyBlocks( site: SiteData ): Promise< string[] > {
+ try {
+ await connectToDaemon();
+ const output = await runWpCli( site, [ 'eval', RENDER_FRONT_PAGE ] );
+ return JSON.parse(
+ output.slice( output.indexOf( 'FRONT_EMPTY=' ) + 'FRONT_EMPTY='.length )
+ ) as string[];
+ } finally {
+ await disconnectFromDaemon();
+ }
+}
+
+const FILE = 'file ';
+
+// The posts the front end renders, and the active theme's template, template
+// part and pattern files, by modification time and size.
+async function readRenderedVersions( sitePath: string ): Promise< Versions > {
+ const themes = await readWordPressSite( sitePath, ( site ) => [
+ ...new Set( [ site.option( 'template' ), site.option( 'stylesheet' ) ] ),
+ ] );
+ const files = (
+ await Promise.all(
+ themes
+ .filter( ( theme ): theme is string => typeof theme === 'string' && theme !== '' )
+ .map( ( theme ) =>
+ themeMarkupFiles( path.join( sitePath, 'wp-content', 'themes', theme ) )
+ )
+ )
+ ).flat();
+ const versions = await Promise.all(
+ files.map( async ( file ) => {
+ const { mtimeMs, size } = await stat( file );
+ return [ FILE + file, `${ mtimeMs }:${ size }` ];
+ } )
+ );
+ return { ...( await readVersions( sitePath ) ), ...Object.fromEntries( versions ) };
+}
+
+// The blocks a piece of markup holds, by name, as the block parser names them.
+const blockNames = ( markup: string ) =>
+ [ ...markup.matchAll( //g;
+
+/**
+ * Block markup with `is-style-` beside the class on each block that
+ * carries it, in its attributes and its element's class, or undefined if a
+ * block's element has no class to add it to.
+ */
+export function withBlockStyle( markup: string, name: string ): string | undefined {
+ const style = `is-style-${ name }`;
+ let out = '';
+ let last = 0;
+ for ( const match of markup.matchAll( BLOCK ) ) {
+ const [ comment, , json, selfClosing ] = match;
+ let className: unknown;
+ try {
+ className = JSON.parse( json ).className;
+ } catch {
+ continue;
+ }
+ const classes = String( className ?? '' ).split( /\s+/ );
+ if ( ! classes.includes( name ) || classes.includes( style ) ) {
+ continue;
+ }
+ const attribute = `"className":${ JSON.stringify( className ) }`;
+ if ( ! json.includes( attribute ) ) {
+ return undefined;
+ }
+ const updated = comment.replace(
+ attribute,
+ `"className":${ JSON.stringify( `${ className } ${ style }` ) }`
+ );
+ const after = match.index + comment.length;
+ out += markup.slice( last, match.index ) + updated;
+ last = after;
+ if ( selfClosing ) {
+ continue;
+ }
+ // The block's element is the first tag after its comment.
+ const tag = markup.slice( after ).match( /^\s*<[a-z][a-z0-9-]*\b[^>]*>/i )?.[ 0 ];
+ const classAttribute = tag?.match( /\sclass="([^"]*)"/ );
+ if ( ! tag || ! classAttribute || ! classAttribute[ 1 ].split( /\s+/ ).includes( name ) ) {
+ return undefined;
+ }
+ out += tag.replace( classAttribute[ 0 ], ` class="${ classAttribute[ 1 ] } ${ style }"` );
+ last = after + tag.length;
+ }
+ return out + markup.slice( last );
+}
+
+// Whether an element carries the class without the style: one that is not a
+// block's, as in a core/html block.
+function carriedWithoutStyle( markup: string, name: string ): boolean {
+ return [ ...markup.matchAll( /\sclass="([^"]*)"/g ) ].some( ( [ , classes ] ) => {
+ const list = classes.split( /\s+/ );
+ return list.includes( name ) && ! list.includes( `is-style-${ name }` );
+ } );
+}
+
+// Whether something Studio does not move names the class: another theme file,
+// whose code or markup may print or style it, or CSS the site saved, which
+// WordPress prints after the theme's (Additional CSS, Global Styles).
+async function namedElsewhere(
+ siteRoot: string,
+ themeDir: string,
+ name: string,
+ moved: string[]
+): Promise< boolean > {
+ const word = new RegExp( `(^|[^A-Za-z0-9_-])${ name }(?![A-Za-z0-9_-])` );
+ const entries = await readdir( themeDir, { recursive: true, withFileTypes: true } );
+ const files = entries
+ .filter( ( entry ) => entry.isFile() && /\.(php|js|html|json)$/.test( entry.name ) )
+ .map( ( entry ) => path.join( entry.parentPath, entry.name ) )
+ .filter(
+ ( file ) =>
+ ! moved.includes( file ) && ! file.includes( `${ path.sep }node_modules${ path.sep }` )
+ );
+ if ( files.some( ( file ) => word.test( readFileSync( file, 'utf8' ) ) ) ) {
+ return true;
+ }
+ const saved = await readWordPressSite( siteRoot, ( site ) =>
+ site.query< { content: string } >(
+ `SELECT post_content AS content FROM ${ site.prefix }posts WHERE post_type IN ('custom_css', 'wp_global_styles')`
+ )
+ );
+ return saved.some( ( { content } ) => token( name ).test( content ) );
+}
+
+const title = ( name: string ) =>
+ name
+ .split( /[-_]+/ )
+ .filter( Boolean )
+ .map( ( word ) => word[ 0 ].toUpperCase() + word.slice( 1 ) )
+ .join( ' ' );
+
+/**
+ * Moves a class's look into a block style of the theme for the block types
+ * that carry it, and says what Studio did; undefined, changing nothing, if it
+ * cannot be moved as it renders.
+ */
+export async function moveToBlockStyle(
+ siteRoot: string,
+ themeDir: string,
+ name: string,
+ blockTypes: string[],
+ stylesheets: string[],
+ markup: Markup[]
+): Promise< string | undefined > {
+ const styleFile = path.join( themeDir, 'styles', 'blocks', `${ name }.json` );
+ if (
+ existsSync( styleFile ) ||
+ ( await isUpdatableTheme( siteRoot, path.basename( themeDir ) ) )
+ ) {
+ return undefined;
+ }
+ const sheets = stylesheets.map( ( file ) => ( { file, css: readFileSync( file, 'utf8' ) } ) );
+ const css = movableCss(
+ sheets.map( ( sheet ) => sheet.css ),
+ name
+ );
+ const styled = markup.map( ( item ) => ( {
+ ...item,
+ styled: withBlockStyle( item.content, name ),
+ } ) );
+ if (
+ ! css ||
+ styled.some(
+ ( item ) => item.styled === undefined || carriedWithoutStyle( item.styled, name )
+ ) ||
+ ( await namedElsewhere( siteRoot, themeDir, name, [
+ ...stylesheets,
+ ...markup.flatMap( ( item ) => item.file ?? [] ),
+ ] ) )
+ ) {
+ return undefined;
+ }
+ const changed = styled.filter( ( item ) => item.styled !== item.content );
+ // The posts first: one Studio cannot save leaves the stylesheet as it was.
+ for ( const item of changed.filter( ( candidate ) => candidate.post ) ) {
+ try {
+ await saveMarkup( siteRoot, item, item.styled! );
+ } catch {
+ return undefined;
+ }
+ }
+ for ( const item of changed.filter( ( candidate ) => candidate.file ) ) {
+ await writeFile( item.file!, item.styled!, 'utf8' );
+ }
+ await mkdir( path.dirname( styleFile ), { recursive: true } );
+ await writeFile(
+ styleFile,
+ JSON.stringify(
+ {
+ $schema: 'https://schemas.wp.org/trunk/theme.json',
+ version: 3,
+ title: title( name ),
+ slug: name,
+ blockTypes,
+ styles: { css },
+ },
+ null,
+ '\t'
+ ) + '\n',
+ 'utf8'
+ );
+ const moved = [];
+ for ( const sheet of sheets ) {
+ const rest = withoutRules( sheet.css, name );
+ if ( rest !== sheet.css ) {
+ await writeFile( sheet.file, rest, 'utf8' );
+ moved.push( path.relative( themeDir, sheet.file ) );
+ }
+ }
+ const places = changed.map( ( item ) => placeOf( item, themeDir ) );
+ return `Studio moved the look \`.${ name }\` gives ${ blockTypes.join(
+ ', '
+ ) } blocks into a block style, WordPress's form for a look blocks of one type share: ${ styleFile }, which WordPress registers for its "blockTypes" (WP_Theme_JSON_Resolver::get_style_variations() in wp-includes/class-wp-theme-json-resolver.php), the block's Styles panel offers, and Global Styles edits. Its rules moved from ${ moved.join(
+ ', '
+ ) } into the style's "styles": { "css" }, as they were, and the blocks in ${ places.join(
+ ', '
+ ) } carry "is-style-${ name }" beside "${ name }", so they render as before. A block takes the look as "className":"is-style-${ name }"; the style's "styles" can hold each property at its path (WP_Theme_JSON::PROPERTIES_METADATA in wp-includes/class-wp-theme-json.php).`;
+}
diff --git a/apps/cli/ai/block-theme.ts b/apps/cli/ai/block-theme.ts
new file mode 100644
index 0000000000..dcad7cd92c
--- /dev/null
+++ b/apps/cli/ai/block-theme.ts
@@ -0,0 +1,137 @@
+/**
+ * Whether a theme made during the session is a block theme, as WordPress's
+ * default theme is. WordPress treats a theme as a block theme when it has
+ * templates/index.html (WP_Theme::is_block_theme() in
+ * wp-includes/class-wp-theme.php), and only a block theme's templates, parts
+ * and styles are edited in the Site Editor, around page content that is blocks.
+ *
+ * A theme the site already had keeps the kind it is. One made during the
+ * session is classic only when the user chooses it, asked before the write
+ * that starts it (classicThemeQuestion); one that WordPress lists as broken
+ * for having neither index yet stays an open problem while it is.
+ */
+import { readFile, readdir, stat } from 'fs/promises';
+import path from 'path';
+import { resolveSiteFile } from 'cli/ai/site-file-guards';
+import { readThemeHeader, isUpdatableTheme } from 'cli/ai/themes';
+import type { SiteChangeSource, Versions } from 'cli/ai/site-changes';
+
+const CREATED = 'created:';
+const exists = ( file: string ) => stat( file ).then( Boolean, () => false );
+
+/**
+ * A standalone theme's kind: block, with a block template index
+ * (WP_Theme::is_block_theme()); classic, with PHP templates at its root
+ * instead, as index.php, header.php or page.php are, which WordPress's
+ * default theme has none of. Its one root PHP file is functions.php, which
+ * WordPress loads for every theme (wp-settings.php). A child theme is its
+ * parent's kind, so it is left to its parent.
+ */
+async function themeKind( root: string, theme: string ): Promise< string > {
+ const dir = path.join( root, 'wp-content', 'themes', theme );
+ if ( await readThemeHeader( root, theme, 'Template' ).catch( () => '' ) ) {
+ return 'child';
+ }
+ if ( await exists( path.join( dir, 'templates/index.html' ) ) ) {
+ return 'block';
+ }
+ return ( await rootTemplates( dir ) ).length ? 'classic' : 'unfinished';
+}
+
+async function rootTemplates( dir: string ): Promise< string[] > {
+ const files = await readdir( dir ).catch( () => [] as string[] );
+ return files.filter( ( file ) => file.endsWith( '.php' ) && file !== 'functions.php' ).sort();
+}
+
+/** WP_DEFAULT_THEME, the theme core falls back to (wp-includes/default-constants.php). */
+async function defaultTheme( root: string ): Promise< string | undefined > {
+ const php = await readFile(
+ path.join( root, 'wp-includes/default-constants.php' ),
+ 'utf8'
+ ).catch( () => '' );
+ return php.match( /define\(\s*'WP_DEFAULT_THEME',\s*'([^']+)'/ )?.[ 1 ];
+}
+
+/**
+ * The question a write that starts a classic theme asks the user first: a
+ * root PHP template, such as index.php, header.php or page.php, in a theme
+ * made without one, with no block template index. A classic theme is the
+ * user's choice: its templates are edited only in code. Themes the site has
+ * keep their kind, so a classic theme's further templates, a block theme's
+ * PHP and a child theme (its parent's kind) ask nothing, nor do themes
+ * WordPress updates, whose files Studio does not write.
+ */
+export async function classicThemeQuestion(
+ filePath: string
+): Promise< { question: string; consequence: string; declined: string } | undefined > {
+ const file = resolveSiteFile( filePath );
+ const theme = file?.theme;
+ if (
+ ! file ||
+ ! theme ||
+ ! /^[^/]+\.php$/.test( theme.file ) ||
+ theme.file === 'functions.php' ||
+ ( await themeKind( file.siteRoot, theme.slug ) ) !== 'unfinished' ||
+ ( await isUpdatableTheme( file.siteRoot, theme.slug ) )
+ ) {
+ return undefined;
+ }
+ const fallback = await defaultTheme( file.siteRoot );
+ return {
+ question: `Make "${ theme.slug }" a classic theme?`,
+ consequence: `Its templates are PHP files, edited only in code: the Site Editor cannot edit its templates, header, footer or styles.`,
+ declined: `"${ theme.slug }" is to be a block theme, as WordPress's default theme${
+ fallback ? `, ${ fallback } (WP_DEFAULT_THEME),` : ''
+ } is: its templates are block markup in templates/*.html, with templates/index.html (WP_Theme::is_block_theme() in wp-includes/class-wp-theme.php), and its parts in parts/*.html, all edited in the Site Editor.`,
+ };
+}
+
+// A theme WordPress reports broken, with neither index (WP_Theme::__construct()).
+async function missingTemplateProblem( root: string, theme: string ): Promise< string > {
+ const fallback = await defaultTheme( root );
+ const named = fallback ? `, ${ fallback } (WP_DEFAULT_THEME),` : '';
+ return `${ theme } (wp-content/themes/${ theme }), made this session, has neither templates/index.html nor index.php, so WordPress lists it as broken: "Template is missing. Standalone themes need to have a templates/index.html or index.php template file." (WP_Theme::__construct() in wp-includes/class-wp-theme.php). WordPress's default theme${ named } is a block theme with templates/index.html. Give ${ theme } its templates/index.html.`;
+}
+
+export const blockTheme: SiteChangeSource = {
+ name: 'block-theme',
+ async read( sitePath, last ) {
+ const dir = path.join( sitePath, 'wp-content', 'themes' );
+ const themes = await readdir( dir ).catch( () => [] as string[] );
+ const versions: Versions = {};
+ for ( const theme of themes ) {
+ if ( ! ( await exists( path.join( dir, theme, 'style.css' ) ) ) ) {
+ continue;
+ }
+ const kind = await themeKind( sitePath, theme );
+ versions[ theme ] = ( last?.[ theme ]?.startsWith( CREATED ) ? CREATED : '' ) + kind;
+ }
+ return versions;
+ },
+ async check( site, before, after ) {
+ const next: Versions = {};
+ const reports = [];
+ for ( const [ theme, version ] of Object.entries( after ) ) {
+ const created = ! ( theme in before ) || version.startsWith( CREATED );
+ next[ theme ] = created && ! version.startsWith( CREATED ) ? CREATED + version : version;
+ if ( ! created || before[ theme ] === next[ theme ] ) {
+ continue;
+ }
+ const broken =
+ next[ theme ] === `${ CREATED }unfinished` &&
+ ! ( await isUpdatableTheme( site.path, theme ) );
+ reports.push( {
+ key: `block theme ${ theme }`,
+ problem: broken ? await missingTemplateProblem( site.path, theme ) : undefined,
+ atTurnEnd: true,
+ } );
+ }
+ // A theme made this session and deleted since leaves no problem.
+ for ( const theme of Object.keys( before ) ) {
+ if ( before[ theme ].startsWith( CREATED ) && ! ( theme in after ) ) {
+ reports.push( { key: `block theme ${ theme }`, problem: undefined } );
+ }
+ }
+ return { reports, next };
+ },
+};
diff --git a/apps/cli/ai/block-validation-gate.ts b/apps/cli/ai/block-validation-gate.ts
new file mode 100644
index 0000000000..9b99e329f3
--- /dev/null
+++ b/apps/cli/ai/block-validation-gate.ts
@@ -0,0 +1,579 @@
+/**
+ * Block validation as a gate the harness runs, not a tool the model must
+ * remember to call.
+ *
+ * Writing block markup (a theme template or part, or any file that opens with a
+ * block, as a page draft does wherever it is written) queues that file. Validation starts in the background at once,
+ * and what puts content on the site or renders it (a shell command, which
+ * runs WP-CLI, or take_screenshot) waits for it first. A fix the block editor
+ * can make losslessly is applied to the file silently, once validation has
+ * settled and only while the file still holds the content validated, so an
+ * edit made meanwhile is never overwritten; the model hears only about what it
+ * has to change itself.
+ */
+import { createHash } from 'crypto';
+import { readFileSync } from 'fs';
+import { readFile, writeFile } from 'fs/promises';
+import path from 'path';
+import { validateHtmlBlockPolicy } from 'cli/ai/block-content-policy';
+import {
+ bindNavigationLinks,
+ blocksSavingElements,
+ blockNames,
+ insertedTemplates,
+ storePresetValues,
+ dropCommentsBetweenBlocks,
+ validateBlocks,
+ type PresetValues,
+ type PresetValuesResult,
+ type ValidationReportBase,
+} from 'cli/ai/block-validator';
+import { getOpenProblems, type OpenProblems } from 'cli/ai/open-problems';
+import { readWordPressSite } from 'cli/ai/site-database';
+import { themePresets } from 'cli/ai/theme-presets';
+import { getSiteByFolder, getSiteUrl } from 'cli/lib/cli-config/sites';
+
+const THEME_MARKUP =
+ /[\\/]wp-content[\\/]themes[\\/][^\\/]+[\\/](templates|parts)[\\/][^\\/]+\.html$/;
+// A theme's patterns are the PHP files in its patterns folder, which WordPress
+// includes for what they output (WP_Theme::get_block_patterns(),
+// WP_Block_Patterns_Registry::get_content()).
+const THEME_PATTERN = /[\\/]wp-content[\\/]themes[\\/][^\\/]+[\\/]patterns[\\/].+\.php$/;
+// The header comment a pattern opens with, and the newline PHP drops after its closing tag.
+const PATTERN_HEADER = /^\s*<\?php\s*\/\*[\s\S]*?\*\/\s*\?>\n?/;
+
+/**
+ * A theme pattern's header and the block markup it outputs, when the header
+ * is its only PHP, so the file outputs the markup as written.
+ */
+function splitPattern( content: string ): { header: string; markup: string } | undefined {
+ const header = content.match( PATTERN_HEADER )?.[ 0 ];
+ if ( header === undefined ) {
+ return undefined;
+ }
+ const markup = content.slice( header.length );
+ return markup.includes( '' ) ? undefined : { header, markup };
+}
+
+/**
+ * Whether content is block markup: it opens with a block's comment delimiter,
+ * as WordPress serializes a block (get_comment_delimited_block_content()).
+ * A draft is one wherever it is written; code that only mentions a delimiter
+ * does not open with one.
+ */
+export function opensWithBlock( content: string ): boolean {
+ return content.trimStart().startsWith( '/g, '' )
+ .replace( /<\/?p>| /gi, '' )
+ .trim();
+ const blocks = wpBlocks.parse( html );
+ const kept = blocks.filter( ( block: any ) => ! onlyComments( block ) );
+ return kept.length === blocks.length
+ ? { content: html, comments: 0 }
+ : { content: wpBlocks.serialize( kept ), comments: blocks.length - kept.length };
+ /* eslint-enable @typescript-eslint/no-explicit-any */
+ }, content );
+}
+
+/**
+ * Binds the url of each navigation link that names a post or term by its id
+ * but has no url to that entity's link, as the editor's link picker does
+ * (useEntityBinding in @wordpress/block-library): core prints a link's href
+ * only from its url (wp-includes/blocks/navigation-link.php), and fills the
+ * url from the binding with the entity's permalink (core/post-data and
+ * core/term-data in wp-includes/block-bindings). Content the editor would
+ * save otherwise is returned as is.
+ */
+export async function bindNavigationLinks(
+ content: string,
+ siteUrl: string
+): Promise< NavigationLinksResult > {
+ if ( ! /([\s\S]*?)/g;
+
+/** The note on the buttons in `markup` that link nowhere, if any. */
+export function buttonsWithoutLinkNotes( markup: string ): string[] {
+ const texts = new Map< string, number >();
+ for ( const [ , inner ] of markup.matchAll( BUTTON ) ) {
+ const link = inner.match( /]*>/ );
+ if ( ! link ) {
+ continue;
+ }
+ const href = link[ 0 ].match( /\shref="([^"]*)"/ )?.[ 1 ];
+ if ( href && href !== '#' ) {
+ continue;
+ }
+ const text = inner.replace( /<[^>]*>/g, '' ).trim() || '(no text)';
+ texts.set( text, ( texts.get( text ) ?? 0 ) + 1 );
+ }
+ if ( ! texts.size ) {
+ return [];
+ }
+ const named = [ ...texts ]
+ .map( ( [ text, count ] ) => ( count > 1 ? `"${ text }" ×${ count }` : `"${ text }"` ) )
+ .join( ', ' );
+ return [
+ `core/button blocks that open nothing: ${ named }. A button's link is its "url", saved as the href of its (wp-includes/blocks/button/block.json); without one the browser gives the link no action, and "#" opens the top of the page. Give each the URL of what it offers.`,
+ ];
+}
diff --git a/apps/cli/ai/chat-artifacts.ts b/apps/cli/ai/chat-artifacts.ts
index 3c6beb1ba1..f2c33f95ec 100644
--- a/apps/cli/ai/chat-artifacts.ts
+++ b/apps/cli/ai/chat-artifacts.ts
@@ -1,8 +1,5 @@
import { randomUUID } from 'node:crypto';
-import { existsSync } from 'node:fs';
-import path from 'node:path';
import {
- DESIGN_SYSTEM_PREVIEW_PATH,
STUDIO_CHAT_ARTIFACT_VERSION,
type StudioChatArtifactData,
type StudioChatArtifactWidgetDraft,
@@ -56,33 +53,3 @@ function cloneChatArtifactWidgets(
widgetProps: { ...widget.widgetProps },
} ) );
}
-
-interface FileWritingTool {
- execute( toolCallId: string, params: { path?: unknown }, ...rest: unknown[] ): Promise< unknown >;
-}
-
-/**
- * Wraps a file-writing tool so that writing a site's DESIGN.md opens the design
- * system in the preview.
- */
-export function withDesignSystemPreview< TTool extends FileWritingTool >(
- tool: TTool,
- cwd: string
-): TTool {
- return {
- ...tool,
- async execute( toolCallId: string, params: { path?: unknown }, ...rest: unknown[] ) {
- const result = await tool.execute( toolCallId, params, ...rest );
- const filePath = path.resolve( cwd, String( params.path ) );
- if (
- path.basename( filePath ) === 'DESIGN.md' &&
- existsSync( path.join( path.dirname( filePath ), 'wp-content' ) )
- ) {
- await emitChatArtifactWidgets( [
- { type: 'site-preview', widgetProps: { path: DESIGN_SYSTEM_PREVIEW_PATH } },
- ] ).catch( () => undefined );
- }
- return result;
- },
- };
-}
diff --git a/apps/cli/ai/child-theme-copies.ts b/apps/cli/ai/child-theme-copies.ts
new file mode 100644
index 0000000000..be42292e64
--- /dev/null
+++ b/apps/cli/ai/child-theme-copies.ts
@@ -0,0 +1,113 @@
+/**
+ * Files a child theme holds unchanged from its parent, by any tool or script.
+ * WordPress already reads a file the child lacks from the parent
+ * (get_theme_file_path(), get_theme_file_uri() for theme.json's `file:./`
+ * fonts, locate_template(), the block template and pattern resolvers) and merges the child's theme.json over the parent's
+ * (WP_Theme_JSON_Resolver::get_theme_data()), so a copy only stops following
+ * the parent's updates; both functions.php files load (wp-settings.php), so a
+ * copied one redeclares the parent's functions. A child theme holds the files
+ * it changes (https://developer.wordpress.org/themes/advanced-topics/child-themes/).
+ *
+ * A screenshot is the child's own: WP_Theme::get_screenshot() reads only the
+ * child's folder.
+ *
+ * Each file's version is both files' modified time and size and whether their
+ * content is the same, compared again only when a time or size moves.
+ */
+import { readdir, readFile, stat } from 'fs/promises';
+import path from 'path';
+import { readThemeHeader } from 'cli/ai/themes';
+import type { SiteChangeSource, Versions } from 'cli/ai/site-changes';
+
+const SCREENSHOT = /^screenshot\.(png|gif|jpe?g|webp|avif)$/;
+const SAME = ' same';
+
+const stampOf = async ( file: string ) => {
+ const { mtimeMs, size } = await stat( file );
+ return { stamp: `${ mtimeMs }:${ size }`, size };
+};
+
+async function readCopies(
+ themesDir: string,
+ child: string,
+ parent: string,
+ last: Versions
+): Promise< Versions > {
+ const versions: Versions = {};
+ const childDir = path.join( themesDir, child );
+ for ( const entry of await readdir( childDir, { recursive: true, withFileTypes: true } ) ) {
+ const relative = path
+ .relative( childDir, path.join( entry.parentPath, entry.name ) )
+ .split( path.sep )
+ .join( '/' );
+ if ( ! entry.isFile() || SCREENSHOT.test( relative ) ) {
+ continue;
+ }
+ const childFile = path.join( childDir, relative );
+ const parentFile = path.join( themesDir, parent, relative );
+ const [ own, inherited ] = await Promise.all( [
+ stampOf( childFile ),
+ stampOf( parentFile ).catch( () => undefined ),
+ ] );
+ if ( ! inherited ) {
+ continue;
+ }
+ const key = `${ child }/${ relative }`;
+ const stamps = `${ own.stamp }|${ inherited.stamp }`;
+ const same = last[ key ]?.startsWith( `${ stamps } ` )
+ ? last[ key ].endsWith( SAME )
+ : own.size === inherited.size &&
+ ( await readFile( childFile ) ).equals( await readFile( parentFile ) );
+ versions[ key ] = `${ stamps }${ same ? SAME : ' own' }`;
+ }
+ return versions;
+}
+
+const copiesOf = ( versions: Versions, child: string ) =>
+ Object.entries( versions )
+ .filter( ( [ key, version ] ) => key.startsWith( `${ child }/` ) && version.endsWith( SAME ) )
+ .map( ( [ key ] ) => key.slice( child.length + 1 ) )
+ .sort();
+
+function problem( child: string, parent: string, files: string[] ): string {
+ const named = files.length > 5 ? `${ files.slice( 0, 5 ).join( ', ' ) }, …` : files.join( ', ' );
+ return `The child theme ${ child } holds files of its parent ${ parent } unchanged: ${ named }. WordPress reads a file the child lacks from the parent and merges the child's theme.json over the parent's, so a copy only stops following ${ parent }'s updates, and a copied functions.php loads beside the parent's. Delete the copies, so ${ child } holds only the files it changes (https://developer.wordpress.org/themes/advanced-topics/child-themes/).`;
+}
+
+export const childThemeCopies: SiteChangeSource = {
+ name: 'child-themes',
+ async read( sitePath, last = {} ) {
+ const themesDir = path.join( sitePath, 'wp-content', 'themes' );
+ const themes = ( await readdir( themesDir, { withFileTypes: true } ).catch( () => [] ) )
+ .filter( ( entry ) => entry.isDirectory() )
+ .map( ( entry ) => entry.name );
+ const versions: Versions = {};
+ for ( const child of themes ) {
+ const parent = await readThemeHeader( sitePath, child, 'Template' ).catch( () => '' );
+ if ( parent && parent !== child ) {
+ versions[ child ] = parent;
+ Object.assign( versions, await readCopies( themesDir, child, parent, last ) );
+ }
+ }
+ return versions;
+ },
+ async check( _site, before, after ) {
+ const reports = [];
+ const children = new Set(
+ [ ...Object.keys( before ), ...Object.keys( after ) ].filter(
+ ( key ) => ! key.includes( '/' )
+ )
+ );
+ for ( const child of children ) {
+ const was = copiesOf( before, child );
+ const now = copiesOf( after, child );
+ if ( was.join( '\n' ) !== now.join( '\n' ) ) {
+ reports.push( {
+ key: `child theme ${ child }`,
+ problem: now.length ? problem( child, after[ child ], now ) : undefined,
+ } );
+ }
+ }
+ return { reports };
+ },
+};
diff --git a/apps/cli/ai/design-board.ts b/apps/cli/ai/design-board.ts
index f473350d74..416c5bfa20 100644
--- a/apps/cli/ai/design-board.ts
+++ b/apps/cli/ai/design-board.ts
@@ -2,9 +2,9 @@ import {
designSheet,
fontFamilyName as fontFamily,
googleFontsUrl,
- parseDesignMd,
- Style,
-} from '@studio/design-md';
+ themeJsonDesign,
+ type Style,
+} from '@studio/common/sites/design-sheet';
const escapeHtml = ( value: unknown ) =>
String( value ).replace( /[&<>"']/g, ( char ) => `${ char.charCodeAt( 0 ) };` );
@@ -30,8 +30,23 @@ function fontLinks( styles: Style[] ): string {
return url ? `` : '';
}
-export function renderDesignBoard( design: string, image?: string ): string {
- const tokens = parseDesignMd( design );
+/** Renders a theme's theme.json as a design-system board. */
+export function renderDesignBoard( themeJson: string ): string {
+ let parsed: Record< string, unknown >;
+ try {
+ parsed = JSON.parse( themeJson );
+ } catch ( error ) {
+ throw new Error( `The look's theme.json is not valid JSON: ${ ( error as Error ).message }` );
+ }
+ const tokens = themeJsonDesign( parsed );
+ if (
+ Object.keys( tokens.colors ?? {} ).length < 2 ||
+ ! Object.values( tokens.typography ?? {} ).some( ( style ) => style.fontFamily )
+ ) {
+ throw new Error(
+ "The look's theme.json needs at least two hex colors in settings.color.palette, and a font family in styles.typography."
+ );
+ }
const {
palette,
background,
@@ -79,8 +94,6 @@ export function renderDesignBoard( design: string, image?: string ): string {
`padding:${ dimension( button.padding ) }`,
fontCss( button.typography ),
].join( ';' );
- const treatment = tokens.imagery ?? {};
- const overlay = [ 'multiply', 'screen' ].find( ( mode ) => mode === treatment.overlay );
return `
@@ -141,16 +154,7 @@ figcaption{margin-top:12px;font-size:13px;opacity:.65}
.picture{position:relative;flex:1;overflow:hidden;border-radius:${ dimension(
shape
) };background-color:${ css( primary ) };color:${ css( ink( primary ) ) }}
-.picture img{width:100%;height:100%;object-fit:cover;display:block;filter:${ css(
- treatment.filter ?? 'none'
- ) }}
-${
- overlay
- ? `.picture img+i{position:absolute;inset:0;background-color:${ css(
- primary
- ) };mix-blend-mode:${ overlay }}`
- : ''
-}
+.picture img{width:100%;height:100%;object-fit:cover;display:block}
.pattern{background-image:radial-gradient(currentColor 20%,transparent 21%);background-size:32px 32px}
.strip{position:absolute;left:0;right:0;bottom:0;height:12px;display:flex}
@@ -174,9 +178,7 @@ ${
accents.length ? `