From c5ee3fc9de30207e518086568e564e6e21224fe3 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 22 Oct 2026 17:17:27 -0400 Subject: [PATCH 1/5] Studio Code: State the active site's WordPress version, block type source, and Site Editor copies as facts --- apps/cli/ai/runtimes/pi/index.ts | 25 ++- apps/cli/ai/runtimes/pi/tool-safety.ts | 4 +- apps/cli/ai/saved-overrides.ts | 214 ++++++++++++++++++++++ apps/cli/ai/site-facts.ts | 26 +++ apps/cli/ai/system-prompt.ts | 14 +- apps/cli/ai/tests/saved-overrides.test.ts | 167 +++++++++++++++++ apps/cli/ai/tests/site-facts.test.ts | 31 ++++ apps/cli/ai/tests/system-prompt.test.ts | 11 ++ 8 files changed, 481 insertions(+), 11 deletions(-) create mode 100644 apps/cli/ai/saved-overrides.ts create mode 100644 apps/cli/ai/site-facts.ts create mode 100644 apps/cli/ai/tests/saved-overrides.test.ts create mode 100644 apps/cli/ai/tests/site-facts.test.ts diff --git a/apps/cli/ai/runtimes/pi/index.ts b/apps/cli/ai/runtimes/pi/index.ts index 38e7f9f0c4..f8502cc739 100644 --- a/apps/cli/ai/runtimes/pi/index.ts +++ b/apps/cli/ai/runtimes/pi/index.ts @@ -42,6 +42,8 @@ import { getAiTracksIdentity } from '@studio/common/ai/tracks-identity'; import { type TSchema } from 'typebox'; import { withDesignSystemPreview } from 'cli/ai/chat-artifacts'; import { isImageGenerationAvailable } from 'cli/ai/image-generation'; +import { withSavedOverrideNote } from 'cli/ai/saved-overrides'; +import { readActiveSiteFacts } from 'cli/ai/site-facts'; import { buildSystemPrompt, type ToolPromptContribution } from 'cli/ai/system-prompt'; import { resolveStudioToolDefinitions } from 'cli/ai/tools'; import { createAskUserQuestionTool } from 'cli/ai/tools/ask-user-question'; @@ -319,9 +321,10 @@ async function createStudioAgentSession( const model = buildModel( config.model, family, creds ); const chatArtifactsEnabled = typeof process.send === 'function'; const visionEnabled = aiModelSupportsImages( config.model ); - const [ userInstructions, imageGenerationEnabled ] = await Promise.all( [ + const [ userInstructions, imageGenerationEnabled, activeSiteFacts ] = await Promise.all( [ readGlobalInstructions(), isImageGenerationAvailable(), + readActiveSiteFacts( config.activeSite ), ] ); const tools = buildAgentTools( @@ -334,6 +337,7 @@ async function createStudioAgentSession( userInstructions, tools: tools.map( toolPromptContribution ), activeSite: config.activeSite, + activeSiteFacts, } ); const pendingWork = getPendingWork( config.session ); @@ -685,9 +689,22 @@ function buildAgentTools( const designSystemPreview = < TTool extends AgentToolAny >( tool: TTool ) => chatArtifactsEnabled ? withDesignSystemPreview( tool, STUDIO_SITES_ROOT ) : tool; const piTools: AgentToolAny[] = [ - renameTool( createReadTool( STUDIO_SITES_ROOT ), 'Read' ), - designSystemPreview( renameTool( createWriteTool( STUDIO_SITES_ROOT ), 'Write' ) ), - designSystemPreview( renameTool( createEditTool( STUDIO_SITES_ROOT ), 'Edit' ) ), + withSavedOverrideNote( + renameTool( createReadTool( STUDIO_SITES_ROOT ), 'Read' ), + STUDIO_SITES_ROOT + ), + designSystemPreview( + withSavedOverrideNote( + renameTool( createWriteTool( STUDIO_SITES_ROOT ), 'Write' ), + STUDIO_SITES_ROOT + ) + ), + designSystemPreview( + withSavedOverrideNote( + renameTool( createEditTool( STUDIO_SITES_ROOT ), 'Edit' ), + STUDIO_SITES_ROOT + ) + ), renameTool( createBashTool( STUDIO_SITES_ROOT ), 'Bash' ), renameTool( createGrepTool( STUDIO_SITES_ROOT ), 'Grep' ), renameTool( createFindTool( STUDIO_SITES_ROOT ), 'Glob' ), diff --git a/apps/cli/ai/runtimes/pi/tool-safety.ts b/apps/cli/ai/runtimes/pi/tool-safety.ts index d313c09889..6b9f898ef2 100644 --- a/apps/cli/ai/runtimes/pi/tool-safety.ts +++ b/apps/cli/ai/runtimes/pi/tool-safety.ts @@ -106,7 +106,7 @@ export function getPayloadLimitViolation( toolName: string, params: unknown ): s const CORE_ROOT_FILE = /^(index|xmlrpc|wp-(?!config\b)[a-z-]+)\.php$/; -function resolveToolPath( filePath: string, cwd: string ): string { +export function resolveToolPath( filePath: string, cwd: string ): string { const withoutAt = filePath.replace( /^@/, '' ); const expanded = withoutAt === '~' || withoutAt.startsWith( '~/' ) @@ -116,7 +116,7 @@ function resolveToolPath( filePath: string, cwd: string ): string { } /** The WordPress install a file is in, if any: the nearest folder with wp-includes/ and wp-content/. */ -function findWordPressRoot( filePath: string ): string | undefined { +export function findWordPressRoot( filePath: string ): string | undefined { for ( let dir = path.dirname( filePath ); dir !== path.dirname( dir ); diff --git a/apps/cli/ai/saved-overrides.ts b/apps/cli/ai/saved-overrides.ts new file mode 100644 index 0000000000..7627f6bdc8 --- /dev/null +++ b/apps/cli/ai/saved-overrides.ts @@ -0,0 +1,214 @@ +import { existsSync, readFileSync } from 'fs'; +import path from 'path'; +import { findWordPressRoot, resolveToolPath } from 'cli/ai/runtimes/pi/tool-safety'; + +// A block theme's templates, template parts, and theme.json are only what +// WordPress renders until someone saves a copy in the Site Editor: from then +// on WordPress renders the saved copy (a wp_template or wp_template_part post) +// or applies the saved Global Styles (a wp_global_styles post) over theme.json. +// The copies are read straight from the site's SQLite database. The active +// site's facts list them, and reading or writing a file they override says so. + +type SavedPostType = 'wp_template' | 'wp_template_part' | 'wp_global_styles'; + +export interface SavedCopy { + postType: SavedPostType; + id: number; + // The template or part's slug (the file name without .html). + slug: string; + // For Global Styles: what they set, as paths such as `styles.color.background`. + sets?: string[]; +} + +const TEMPLATE_DIRS: Record< string, 'wp_template' | 'wp_template_part' > = { + templates: 'wp_template', + 'block-templates': 'wp_template', + parts: 'wp_template_part', + 'block-template-parts': 'wp_template_part', +}; + +const LABELS: Record< SavedPostType, string > = { + wp_template: 'template', + wp_template_part: 'template part', + wp_global_styles: 'Global Styles', +}; + +function tablePrefix( root: string ): string { + try { + const config = readFileSync( path.join( root, 'wp-config.php' ), 'utf8' ); + return config.match( /\$table_prefix\s*=\s*['"]([A-Za-z0-9_]+)['"]/ )?.[ 1 ] ?? 'wp_'; + } catch { + return 'wp_'; + } +} + +// Paths of what saved Global Styles set, to two levels below styles/settings. +function stylePaths( content: string ): string[] { + let data: Record< string, unknown >; + try { + data = JSON.parse( content ) as Record< string, unknown >; + } catch { + return []; + } + const paths: string[] = []; + for ( const top of [ 'styles', 'settings' ] ) { + const section = data[ top ]; + if ( ! section || typeof section !== 'object' ) continue; + for ( const [ key, value ] of Object.entries( section ) ) { + if ( value && typeof value === 'object' && ! Array.isArray( value ) ) { + paths.push( ...Object.keys( value ).map( ( child ) => `${ top }.${ key }.${ child }` ) ); + } else { + paths.push( `${ top }.${ key }` ); + } + } + } + return paths; +} + +/** + * The copies saved in the Site Editor for `theme` (a theme folder name; the + * active theme when omitted) on the WordPress install at `root`. Global Styles + * that set nothing are left out. Never throws: a site without a readable + * database has none. + */ +export async function readSavedCopies( root: string, theme?: string ): Promise< SavedCopy[] > { + const dbPath = path.join( root, 'wp-content', 'database', '.ht.sqlite' ); + if ( ! existsSync( dbPath ) ) { + return []; + } + try { + const { DatabaseSync } = await import( 'node:sqlite' ); + const db = new DatabaseSync( dbPath, { readOnly: true } ); + try { + const prefix = tablePrefix( root ); + const stylesheet = + theme ?? + ( + db + .prepare( + `SELECT option_value AS value FROM ${ prefix }options WHERE option_name = 'stylesheet'` + ) + .get() as { value: string } | undefined + )?.value; + if ( ! stylesheet ) return []; + // The latest published post per type and slug, for the theme its + // wp_theme term names. + const rows = db + .prepare( + `SELECT p.ID AS id, p.post_type AS postType, p.post_name AS slug, p.post_content AS content + FROM ${ prefix }posts p + JOIN ${ prefix }term_relationships tr ON tr.object_id = p.ID + JOIN ${ prefix }term_taxonomy tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'wp_theme' + JOIN ${ prefix }terms t ON t.term_id = tt.term_id + WHERE p.post_type IN ( 'wp_template', 'wp_template_part', 'wp_global_styles' ) + AND p.post_status = 'publish' AND t.name = ? + ORDER BY p.post_type, p.post_name, p.post_date DESC, p.ID DESC` + ) + .all( stylesheet ) as unknown as Array< SavedCopy & { content: string } >; + const copies: SavedCopy[] = []; + const seen = new Set< string >(); + for ( const { content, ...row } of rows ) { + const key = + row.postType === 'wp_global_styles' ? row.postType : `${ row.postType }/${ row.slug }`; + if ( seen.has( key ) ) continue; + seen.add( key ); + if ( row.postType !== 'wp_global_styles' ) { + copies.push( row ); + continue; + } + const sets = stylePaths( content ); + if ( sets.length ) copies.push( { ...row, sets } ); + } + return copies; + } finally { + db.close(); + } + } catch { + return []; + } +} + +/** One line for the active site's facts, or undefined when nothing is saved. */ +export function formatSavedCopies( copies: SavedCopy[] ): string | undefined { + if ( ! copies.length ) return undefined; + const items = copies.map( ( copy ) => + copy.postType === 'wp_global_styles' + ? `Global Styles (post ${ copy.id }, sets ${ copy.sets?.join( ', ' ) })` + : `${ LABELS[ copy.postType ] } \`${ copy.slug }\` (post ${ copy.id })` + ); + return `Saved in the Site Editor, so WordPress renders these instead of the theme's files: ${ items.join( + '; ' + ) }.`; +} + +/** + * The note for reading or writing `relativePath` (relative to the WordPress + * root `root`) when a copy saved in the Site Editor overrides that file. + */ +export async function getSavedOverrideNote( + root: string, + relativePath: string +): Promise< string | undefined > { + const parts = relativePath.split( '/' ); + if ( parts[ 0 ] !== 'wp-content' || parts[ 1 ] !== 'themes' || ! parts[ 2 ] ) { + return undefined; + } + const isThemeJson = parts.length === 4 && parts[ 3 ] === 'theme.json'; + const postType = + parts.length === 5 && parts[ 4 ].endsWith( '.html' ) && TEMPLATE_DIRS[ parts[ 3 ] ]; + if ( ! isThemeJson && ! postType ) { + return undefined; + } + const slug = parts[ 4 ]?.slice( 0, -'.html'.length ); + const copy = ( await readSavedCopies( root, parts[ 2 ] ) ).find( ( saved ) => + isThemeJson + ? saved.postType === 'wp_global_styles' + : saved.postType === postType && saved.slug === slug + ); + if ( ! copy ) { + return undefined; + } + if ( copy.postType === 'wp_global_styles' ) { + return `WordPress applies the Global Styles saved in the Site Editor (wp_global_styles post ${ + copy.id + }) over this theme.json, so a change here to what they set does not show on the site; a change to post ${ + copy.id + } does. They set: ${ copy.sets?.join( ', ' ) }.`; + } + return `WordPress renders the copy of this ${ + LABELS[ copy.postType ] + } saved in the Site Editor (${ copy.postType } post ${ + copy.id + }), not this file, so a change to this file does not show on the site. A change to post ${ + copy.id + } does, and keeps the edits made there.`; +} + +interface FileTool { + execute( + toolCallId: string, + params: { path?: unknown }, + ...rest: never[] + ): Promise< { content: Array< { type: string } > } >; +} + +/** Read, Write, or Edit, with the note above appended to the result when it applies. */ +export function withSavedOverrideNote< TTool extends FileTool >( tool: TTool, cwd: string ): TTool { + return { + ...tool, + async execute( toolCallId: string, params: { path?: unknown }, ...rest: never[] ) { + const result = await tool.execute( toolCallId, params, ...rest ); + const filePath = resolveToolPath( String( params.path ), cwd ); + const root = findWordPressRoot( filePath ); + const note = root + ? await getSavedOverrideNote( + root, + path.relative( root, filePath ).split( path.sep ).join( '/' ) + ) + : undefined; + return note + ? { ...result, content: [ ...result.content, { type: 'text', text: note } ] } + : result; + }, + }; +} diff --git a/apps/cli/ai/site-facts.ts b/apps/cli/ai/site-facts.ts new file mode 100644 index 0000000000..99d3133547 --- /dev/null +++ b/apps/cli/ai/site-facts.ts @@ -0,0 +1,26 @@ +import path from 'path'; +import { formatSavedCopies, readSavedCopies } from 'cli/ai/saved-overrides'; +import { getWordPressVersionFromInstallation } from 'cli/lib/dependency-management/wordpress'; +import type { SiteInfo } from 'cli/ai/types'; + +/** + * Facts about a local site read from its own files and database, one line + * each, for the Active site section: the WordPress it runs, where that + * WordPress declares its block types, and the copies saved in the Site Editor. + */ +export async function readActiveSiteFacts( site?: SiteInfo | null ): Promise< string[] > { + if ( ! site || site.remote ) { + return []; + } + const [ version, savedCopies ] = await Promise.all( [ + getWordPressVersionFromInstallation( site.path ), + readSavedCopies( site.path ), + ] ); + const blocks = path.join( site.path, 'wp-includes', 'blocks' ).split( path.sep ).join( '/' ); + return [ + version + ? `WordPress ${ version }. Its core block types, with their attributes and supports, are declared in ${ blocks }//block.json.` + : undefined, + formatSavedCopies( savedCopies ), + ].filter( ( fact ): fact is string => Boolean( fact ) ); +} diff --git a/apps/cli/ai/system-prompt.ts b/apps/cli/ai/system-prompt.ts index b1cabba6ab..0b98023462 100644 --- a/apps/cli/ai/system-prompt.ts +++ b/apps/cli/ai/system-prompt.ts @@ -24,6 +24,9 @@ export interface BuildSystemPromptOptions { // The site this turn works on. The prompt is rebuilt every turn, so this // always reflects the current site, also after compaction. activeSite?: SiteInfo | null; + // Facts about the active site read from its own data, one line each (for + // example the copies saved in the Site Editor). + activeSiteFacts?: string[]; } export function buildSystemPrompt( options: BuildSystemPromptOptions = {} ): string { @@ -44,20 +47,21 @@ ${ options.external ? '' : `${ WORKING_CADENCE }\n\n` }${ renderToolSections( options.tools ?? [] ) } -${ SKILL_ROUTING }${ buildActiveSiteSection( options.activeSite ) }${ buildUserInstructionsSection( - options.userInstructions - ) } +${ SKILL_ROUTING }${ buildActiveSiteSection( + options.activeSite, + options.activeSiteFacts + ) }${ buildUserInstructionsSection( options.userInstructions ) } `; } -function buildActiveSiteSection( site?: SiteInfo | null ): string { +function buildActiveSiteSection( site?: SiteInfo | null, facts: string[] = [] ): string { if ( ! site ) { return ''; } // A WordPress.com site is live and changes only through wpcom_request, so // its runbook comes with it rather than waiting for the agent to load it. const runbook = site.remote ? findSkill( 'edit-manage-remote-site' )?.body : undefined; - return `\n\n## Active site\n\n${ formatActiveSite( site ) }${ + return `\n\n## Active site\n\n${ [ formatActiveSite( site ), ...facts ].join( '\n' ) }${ runbook ? `\n\n${ runbook }` : '' }`; } diff --git a/apps/cli/ai/tests/saved-overrides.test.ts b/apps/cli/ai/tests/saved-overrides.test.ts new file mode 100644 index 0000000000..8edb2cc7c0 --- /dev/null +++ b/apps/cli/ai/tests/saved-overrides.test.ts @@ -0,0 +1,167 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'fs'; +import { DatabaseSync } from 'node:sqlite'; +import os from 'os'; +import path from 'path'; +import { describe, expect, it } from 'vitest'; +import { + formatSavedCopies, + getSavedOverrideNote, + readSavedCopies, + withSavedOverrideNote, +} from '../saved-overrides'; + +// A WordPress root with the tables a saved Site Editor copy lives in. +function makeSite( prefix = 'wp_' ) { + const root = mkdtempSync( path.join( os.tmpdir(), 'saved-overrides-' ) ); + mkdirSync( path.join( root, 'wp-includes' ) ); + mkdirSync( path.join( root, 'wp-content', 'database' ), { recursive: true } ); + mkdirSync( path.join( root, 'wp-content', 'themes', 'bakery', 'parts' ), { recursive: true } ); + writeFileSync( path.join( root, 'wp-config.php' ), ` { + id += 1; + db.prepare( `INSERT INTO ${ prefix }posts VALUES ( ?, ?, ?, ?, ?, '2026-10-22' )` ).run( + id, + postType, + status, + name, + content + ); + db.prepare( `INSERT INTO ${ prefix }term_relationships VALUES ( ?, ? )` ).run( id, theme ); + return id; + }; + return { root, save, close: () => db.close() }; +} + +describe( 'getSavedOverrideNote', () => { + it( 'names the saved copy that overrides a template part', async () => { + const site = makeSite(); + const id = site.save( 'wp_template_part', 'header', '' ); + site.close(); + + const note = await getSavedOverrideNote( + site.root, + 'wp-content/themes/bakery/parts/header.html' + ); + expect( note ).toContain( `wp_template_part post ${ id }` ); + expect( note ).toContain( 'not this file' ); + expect( + await getSavedOverrideNote( site.root, 'wp-content/themes/bakery/parts/footer.html' ) + ).toBeUndefined(); + } ); + + it( 'ignores copies saved for another theme, drafts, and other files', async () => { + const site = makeSite( 'wpx_' ); + site.save( 'wp_template', 'index', '', 2 ); + site.save( 'wp_template', 'home', '', 1, 'draft' ); + site.close(); + + for ( const file of [ + 'wp-content/themes/bakery/templates/index.html', + 'wp-content/themes/bakery/templates/home.html', + 'wp-content/themes/bakery/style.css', + 'wp-content/plugins/x/templates/index.html', + ] ) { + expect( await getSavedOverrideNote( site.root, file ) ).toBeUndefined(); + } + } ); + + it( 'lists what saved Global Styles set over theme.json', async () => { + const site = makeSite(); + const id = site.save( + 'wp_global_styles', + 'wp-global-styles-bakery', + JSON.stringify( { + version: 3, + isGlobalStylesUserThemeData: true, + styles: { color: { background: '#fff' }, typography: { lineHeight: '1.7' } }, + settings: {}, + } ) + ); + site.close(); + + const note = await getSavedOverrideNote( site.root, 'wp-content/themes/bakery/theme.json' ); + expect( note ).toContain( `wp_global_styles post ${ id }` ); + expect( note ).toContain( 'styles.color.background, styles.typography.lineHeight' ); + } ); + + it( 'has no note for empty saved Global Styles or a site without a database', async () => { + const site = makeSite(); + site.save( 'wp_global_styles', 'wp-global-styles-bakery', '{"version":3,"styles":{}}' ); + site.close(); + expect( + await getSavedOverrideNote( site.root, 'wp-content/themes/bakery/theme.json' ) + ).toBeUndefined(); + + const bare = mkdtempSync( path.join( os.tmpdir(), 'saved-overrides-bare-' ) ); + expect( + await getSavedOverrideNote( bare, 'wp-content/themes/bakery/parts/header.html' ) + ).toBeUndefined(); + } ); +} ); + +describe( 'withSavedOverrideNote', () => { + it( 'appends the note to the write result', async () => { + const site = makeSite(); + site.save( 'wp_template_part', 'header', '' ); + site.close(); + const tool = withSavedOverrideNote( + { + execute: async ( _id: string, _params: { path?: unknown } ) => ( { + content: [ { type: 'text', text: 'Wrote header.html' } ], + } ), + }, + site.root + ); + + const written = await tool.execute( 'call-1', { + path: 'wp-content/themes/bakery/parts/header.html', + } ); + expect( written.content ).toHaveLength( 2 ); + const other = await tool.execute( 'call-2', { + path: 'wp-content/themes/bakery/parts/footer.html', + } ); + expect( other.content ).toHaveLength( 1 ); + } ); +} ); + +describe( 'readSavedCopies', () => { + it( "lists the active theme's saved copies as one fact", async () => { + const site = makeSite(); + site.save( 'wp_template_part', 'header', '' ); + site.save( 'wp_template', 'index', '', 2 ); + site.save( + 'wp_global_styles', + 'wp-global-styles-bakery', + '{"styles":{"color":{"text":"#111"}}}' + ); + site.close(); + + const copies = await readSavedCopies( site.root ); + expect( copies.map( ( copy ) => copy.postType ) ).toEqual( [ + 'wp_global_styles', + 'wp_template_part', + ] ); + expect( formatSavedCopies( copies ) ).toBe( + "Saved in the Site Editor, so WordPress renders these instead of the theme's files: Global Styles (post 13, sets styles.color.text); template part `header` (post 11)." + ); + expect( formatSavedCopies( [] ) ).toBeUndefined(); + } ); +} ); diff --git a/apps/cli/ai/tests/site-facts.test.ts b/apps/cli/ai/tests/site-facts.test.ts new file mode 100644 index 0000000000..7a8305f0ae --- /dev/null +++ b/apps/cli/ai/tests/site-facts.test.ts @@ -0,0 +1,31 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'fs'; +import os from 'os'; +import path from 'path'; +import { describe, expect, it } from 'vitest'; +import { readActiveSiteFacts } from '../site-facts'; + +describe( 'readActiveSiteFacts', () => { + it( 'states the WordPress a local site runs and where its block types are declared', async () => { + const sitePath = mkdtempSync( path.join( os.tmpdir(), 'site-facts-' ) ); + mkdirSync( path.join( sitePath, 'wp-includes' ) ); + writeFileSync( + path.join( sitePath, 'wp-includes', 'version.php' ), + "/block.json.`, + ] ); + } ); + + it( 'has no facts for a WordPress.com site or no site', async () => { + expect( + await readActiveSiteFacts( { name: 'Live', path: '', running: false, remote: true } ) + ).toEqual( [] ); + expect( await readActiveSiteFacts( null ) ).toEqual( [] ); + } ); +} ); diff --git a/apps/cli/ai/tests/system-prompt.test.ts b/apps/cli/ai/tests/system-prompt.test.ts index 435d66d769..fc45dea741 100644 --- a/apps/cli/ai/tests/system-prompt.test.ts +++ b/apps/cli/ai/tests/system-prompt.test.ts @@ -133,6 +133,17 @@ describe( 'buildSystemPrompt', () => { expect( buildSystemPrompt( {} ) ).not.toContain( '## Active site' ); } ); + it( "lists the active site's facts under it, one per line", () => { + const prompt = buildSystemPrompt( { + activeSite: { name: 'My Site', path: '/sites/my-site', running: true }, + activeSiteFacts: [ 'Saved in the Site Editor: template part `header` (post 5).' ], + } ); + + expect( prompt ).toContain( + '"My Site" at /sites/my-site (running)\nSaved in the Site Editor: template part `header` (post 5).' + ); + } ); + it( 'states a WordPress.com active site with its ID and URL', () => { const prompt = buildSystemPrompt( { activeSite: { From 9485f252db29d3d67dd4188a6450d674661d8967 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 22 Oct 2026 17:28:56 -0400 Subject: [PATCH 2/5] Keep only the fact, drop the file-tool note, and delete the Site Editor sentence it replaces --- apps/cli/ai/runtimes/pi/index.ts | 20 +- apps/cli/ai/runtimes/pi/tool-safety.ts | 4 +- apps/cli/ai/saved-overrides.ts | 214 ------------------- apps/cli/ai/site-facts.ts | 130 ++++++++++- apps/cli/ai/skills/edit-manage-site/SKILL.md | 1 - apps/cli/ai/tests/saved-overrides.test.ts | 167 --------------- apps/cli/ai/tests/site-facts.test.ts | 91 +++++++- 7 files changed, 224 insertions(+), 403 deletions(-) delete mode 100644 apps/cli/ai/saved-overrides.ts delete mode 100644 apps/cli/ai/tests/saved-overrides.test.ts diff --git a/apps/cli/ai/runtimes/pi/index.ts b/apps/cli/ai/runtimes/pi/index.ts index f8502cc739..30c093b019 100644 --- a/apps/cli/ai/runtimes/pi/index.ts +++ b/apps/cli/ai/runtimes/pi/index.ts @@ -42,7 +42,6 @@ import { getAiTracksIdentity } from '@studio/common/ai/tracks-identity'; import { type TSchema } from 'typebox'; import { withDesignSystemPreview } from 'cli/ai/chat-artifacts'; import { isImageGenerationAvailable } from 'cli/ai/image-generation'; -import { withSavedOverrideNote } from 'cli/ai/saved-overrides'; import { readActiveSiteFacts } from 'cli/ai/site-facts'; import { buildSystemPrompt, type ToolPromptContribution } from 'cli/ai/system-prompt'; import { resolveStudioToolDefinitions } from 'cli/ai/tools'; @@ -689,22 +688,9 @@ function buildAgentTools( const designSystemPreview = < TTool extends AgentToolAny >( tool: TTool ) => chatArtifactsEnabled ? withDesignSystemPreview( tool, STUDIO_SITES_ROOT ) : tool; const piTools: AgentToolAny[] = [ - withSavedOverrideNote( - renameTool( createReadTool( STUDIO_SITES_ROOT ), 'Read' ), - STUDIO_SITES_ROOT - ), - designSystemPreview( - withSavedOverrideNote( - renameTool( createWriteTool( STUDIO_SITES_ROOT ), 'Write' ), - STUDIO_SITES_ROOT - ) - ), - designSystemPreview( - withSavedOverrideNote( - renameTool( createEditTool( STUDIO_SITES_ROOT ), 'Edit' ), - STUDIO_SITES_ROOT - ) - ), + renameTool( createReadTool( STUDIO_SITES_ROOT ), 'Read' ), + designSystemPreview( renameTool( createWriteTool( STUDIO_SITES_ROOT ), 'Write' ) ), + designSystemPreview( renameTool( createEditTool( STUDIO_SITES_ROOT ), 'Edit' ) ), renameTool( createBashTool( STUDIO_SITES_ROOT ), 'Bash' ), renameTool( createGrepTool( STUDIO_SITES_ROOT ), 'Grep' ), renameTool( createFindTool( STUDIO_SITES_ROOT ), 'Glob' ), diff --git a/apps/cli/ai/runtimes/pi/tool-safety.ts b/apps/cli/ai/runtimes/pi/tool-safety.ts index 6b9f898ef2..d313c09889 100644 --- a/apps/cli/ai/runtimes/pi/tool-safety.ts +++ b/apps/cli/ai/runtimes/pi/tool-safety.ts @@ -106,7 +106,7 @@ export function getPayloadLimitViolation( toolName: string, params: unknown ): s const CORE_ROOT_FILE = /^(index|xmlrpc|wp-(?!config\b)[a-z-]+)\.php$/; -export function resolveToolPath( filePath: string, cwd: string ): string { +function resolveToolPath( filePath: string, cwd: string ): string { const withoutAt = filePath.replace( /^@/, '' ); const expanded = withoutAt === '~' || withoutAt.startsWith( '~/' ) @@ -116,7 +116,7 @@ export function resolveToolPath( filePath: string, cwd: string ): string { } /** The WordPress install a file is in, if any: the nearest folder with wp-includes/ and wp-content/. */ -export function findWordPressRoot( filePath: string ): string | undefined { +function findWordPressRoot( filePath: string ): string | undefined { for ( let dir = path.dirname( filePath ); dir !== path.dirname( dir ); diff --git a/apps/cli/ai/saved-overrides.ts b/apps/cli/ai/saved-overrides.ts deleted file mode 100644 index 7627f6bdc8..0000000000 --- a/apps/cli/ai/saved-overrides.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { existsSync, readFileSync } from 'fs'; -import path from 'path'; -import { findWordPressRoot, resolveToolPath } from 'cli/ai/runtimes/pi/tool-safety'; - -// A block theme's templates, template parts, and theme.json are only what -// WordPress renders until someone saves a copy in the Site Editor: from then -// on WordPress renders the saved copy (a wp_template or wp_template_part post) -// or applies the saved Global Styles (a wp_global_styles post) over theme.json. -// The copies are read straight from the site's SQLite database. The active -// site's facts list them, and reading or writing a file they override says so. - -type SavedPostType = 'wp_template' | 'wp_template_part' | 'wp_global_styles'; - -export interface SavedCopy { - postType: SavedPostType; - id: number; - // The template or part's slug (the file name without .html). - slug: string; - // For Global Styles: what they set, as paths such as `styles.color.background`. - sets?: string[]; -} - -const TEMPLATE_DIRS: Record< string, 'wp_template' | 'wp_template_part' > = { - templates: 'wp_template', - 'block-templates': 'wp_template', - parts: 'wp_template_part', - 'block-template-parts': 'wp_template_part', -}; - -const LABELS: Record< SavedPostType, string > = { - wp_template: 'template', - wp_template_part: 'template part', - wp_global_styles: 'Global Styles', -}; - -function tablePrefix( root: string ): string { - try { - const config = readFileSync( path.join( root, 'wp-config.php' ), 'utf8' ); - return config.match( /\$table_prefix\s*=\s*['"]([A-Za-z0-9_]+)['"]/ )?.[ 1 ] ?? 'wp_'; - } catch { - return 'wp_'; - } -} - -// Paths of what saved Global Styles set, to two levels below styles/settings. -function stylePaths( content: string ): string[] { - let data: Record< string, unknown >; - try { - data = JSON.parse( content ) as Record< string, unknown >; - } catch { - return []; - } - const paths: string[] = []; - for ( const top of [ 'styles', 'settings' ] ) { - const section = data[ top ]; - if ( ! section || typeof section !== 'object' ) continue; - for ( const [ key, value ] of Object.entries( section ) ) { - if ( value && typeof value === 'object' && ! Array.isArray( value ) ) { - paths.push( ...Object.keys( value ).map( ( child ) => `${ top }.${ key }.${ child }` ) ); - } else { - paths.push( `${ top }.${ key }` ); - } - } - } - return paths; -} - -/** - * The copies saved in the Site Editor for `theme` (a theme folder name; the - * active theme when omitted) on the WordPress install at `root`. Global Styles - * that set nothing are left out. Never throws: a site without a readable - * database has none. - */ -export async function readSavedCopies( root: string, theme?: string ): Promise< SavedCopy[] > { - const dbPath = path.join( root, 'wp-content', 'database', '.ht.sqlite' ); - if ( ! existsSync( dbPath ) ) { - return []; - } - try { - const { DatabaseSync } = await import( 'node:sqlite' ); - const db = new DatabaseSync( dbPath, { readOnly: true } ); - try { - const prefix = tablePrefix( root ); - const stylesheet = - theme ?? - ( - db - .prepare( - `SELECT option_value AS value FROM ${ prefix }options WHERE option_name = 'stylesheet'` - ) - .get() as { value: string } | undefined - )?.value; - if ( ! stylesheet ) return []; - // The latest published post per type and slug, for the theme its - // wp_theme term names. - const rows = db - .prepare( - `SELECT p.ID AS id, p.post_type AS postType, p.post_name AS slug, p.post_content AS content - FROM ${ prefix }posts p - JOIN ${ prefix }term_relationships tr ON tr.object_id = p.ID - JOIN ${ prefix }term_taxonomy tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'wp_theme' - JOIN ${ prefix }terms t ON t.term_id = tt.term_id - WHERE p.post_type IN ( 'wp_template', 'wp_template_part', 'wp_global_styles' ) - AND p.post_status = 'publish' AND t.name = ? - ORDER BY p.post_type, p.post_name, p.post_date DESC, p.ID DESC` - ) - .all( stylesheet ) as unknown as Array< SavedCopy & { content: string } >; - const copies: SavedCopy[] = []; - const seen = new Set< string >(); - for ( const { content, ...row } of rows ) { - const key = - row.postType === 'wp_global_styles' ? row.postType : `${ row.postType }/${ row.slug }`; - if ( seen.has( key ) ) continue; - seen.add( key ); - if ( row.postType !== 'wp_global_styles' ) { - copies.push( row ); - continue; - } - const sets = stylePaths( content ); - if ( sets.length ) copies.push( { ...row, sets } ); - } - return copies; - } finally { - db.close(); - } - } catch { - return []; - } -} - -/** One line for the active site's facts, or undefined when nothing is saved. */ -export function formatSavedCopies( copies: SavedCopy[] ): string | undefined { - if ( ! copies.length ) return undefined; - const items = copies.map( ( copy ) => - copy.postType === 'wp_global_styles' - ? `Global Styles (post ${ copy.id }, sets ${ copy.sets?.join( ', ' ) })` - : `${ LABELS[ copy.postType ] } \`${ copy.slug }\` (post ${ copy.id })` - ); - return `Saved in the Site Editor, so WordPress renders these instead of the theme's files: ${ items.join( - '; ' - ) }.`; -} - -/** - * The note for reading or writing `relativePath` (relative to the WordPress - * root `root`) when a copy saved in the Site Editor overrides that file. - */ -export async function getSavedOverrideNote( - root: string, - relativePath: string -): Promise< string | undefined > { - const parts = relativePath.split( '/' ); - if ( parts[ 0 ] !== 'wp-content' || parts[ 1 ] !== 'themes' || ! parts[ 2 ] ) { - return undefined; - } - const isThemeJson = parts.length === 4 && parts[ 3 ] === 'theme.json'; - const postType = - parts.length === 5 && parts[ 4 ].endsWith( '.html' ) && TEMPLATE_DIRS[ parts[ 3 ] ]; - if ( ! isThemeJson && ! postType ) { - return undefined; - } - const slug = parts[ 4 ]?.slice( 0, -'.html'.length ); - const copy = ( await readSavedCopies( root, parts[ 2 ] ) ).find( ( saved ) => - isThemeJson - ? saved.postType === 'wp_global_styles' - : saved.postType === postType && saved.slug === slug - ); - if ( ! copy ) { - return undefined; - } - if ( copy.postType === 'wp_global_styles' ) { - return `WordPress applies the Global Styles saved in the Site Editor (wp_global_styles post ${ - copy.id - }) over this theme.json, so a change here to what they set does not show on the site; a change to post ${ - copy.id - } does. They set: ${ copy.sets?.join( ', ' ) }.`; - } - return `WordPress renders the copy of this ${ - LABELS[ copy.postType ] - } saved in the Site Editor (${ copy.postType } post ${ - copy.id - }), not this file, so a change to this file does not show on the site. A change to post ${ - copy.id - } does, and keeps the edits made there.`; -} - -interface FileTool { - execute( - toolCallId: string, - params: { path?: unknown }, - ...rest: never[] - ): Promise< { content: Array< { type: string } > } >; -} - -/** Read, Write, or Edit, with the note above appended to the result when it applies. */ -export function withSavedOverrideNote< TTool extends FileTool >( tool: TTool, cwd: string ): TTool { - return { - ...tool, - async execute( toolCallId: string, params: { path?: unknown }, ...rest: never[] ) { - const result = await tool.execute( toolCallId, params, ...rest ); - const filePath = resolveToolPath( String( params.path ), cwd ); - const root = findWordPressRoot( filePath ); - const note = root - ? await getSavedOverrideNote( - root, - path.relative( root, filePath ).split( path.sep ).join( '/' ) - ) - : undefined; - return note - ? { ...result, content: [ ...result.content, { type: 'text', text: note } ] } - : result; - }, - }; -} diff --git a/apps/cli/ai/site-facts.ts b/apps/cli/ai/site-facts.ts index 99d3133547..0174ef2267 100644 --- a/apps/cli/ai/site-facts.ts +++ b/apps/cli/ai/site-facts.ts @@ -1,8 +1,136 @@ +import { existsSync, readFileSync } from 'fs'; import path from 'path'; -import { formatSavedCopies, readSavedCopies } from 'cli/ai/saved-overrides'; import { getWordPressVersionFromInstallation } from 'cli/lib/dependency-management/wordpress'; import type { SiteInfo } from 'cli/ai/types'; +// A block theme's templates, template parts, and theme.json are only what +// WordPress renders until someone saves a copy in the Site Editor: from then +// on WordPress renders the saved copy (a wp_template or wp_template_part post) +// or applies the saved Global Styles (a wp_global_styles post) over theme.json. +// The copies are read straight from the site's SQLite database, for the +// active site's facts. + +type SavedPostType = 'wp_template' | 'wp_template_part' | 'wp_global_styles'; + +export interface SavedCopy { + postType: SavedPostType; + id: number; + // The template or part's slug (the file name without .html). + slug: string; + // For Global Styles: what they set, as paths such as `styles.color.background`. + sets?: string[]; +} + +function tablePrefix( root: string ): string { + try { + const config = readFileSync( path.join( root, 'wp-config.php' ), 'utf8' ); + return config.match( /\$table_prefix\s*=\s*['"]([A-Za-z0-9_]+)['"]/ )?.[ 1 ] ?? 'wp_'; + } catch { + return 'wp_'; + } +} + +// Paths of what saved Global Styles set, to two levels below styles/settings. +function stylePaths( content: string ): string[] { + let data: Record< string, unknown >; + try { + data = JSON.parse( content ) as Record< string, unknown >; + } catch { + return []; + } + const paths: string[] = []; + for ( const top of [ 'styles', 'settings' ] ) { + const section = data[ top ]; + if ( ! section || typeof section !== 'object' ) continue; + for ( const [ key, value ] of Object.entries( section ) ) { + if ( value && typeof value === 'object' && ! Array.isArray( value ) ) { + paths.push( ...Object.keys( value ).map( ( child ) => `${ top }.${ key }.${ child }` ) ); + } else { + paths.push( `${ top }.${ key }` ); + } + } + } + return paths; +} + +/** + * The copies saved in the Site Editor for the active theme of the WordPress + * install at `root`. Global Styles that set nothing are left out. Never + * throws: a site without a readable database has none. + */ +export async function readSavedCopies( root: string ): Promise< SavedCopy[] > { + const dbPath = path.join( root, 'wp-content', 'database', '.ht.sqlite' ); + if ( ! existsSync( dbPath ) ) { + return []; + } + try { + const { DatabaseSync } = await import( 'node:sqlite' ); + const db = new DatabaseSync( dbPath, { readOnly: true } ); + try { + const prefix = tablePrefix( root ); + const stylesheet = ( + db + .prepare( + `SELECT option_value AS value FROM ${ prefix }options WHERE option_name = 'stylesheet'` + ) + .get() as { value: string } | undefined + )?.value; + if ( ! stylesheet ) return []; + // The latest published post per type and slug, for the theme its + // wp_theme term names. + const rows = db + .prepare( + `SELECT p.ID AS id, p.post_type AS postType, p.post_name AS slug, p.post_content AS content + FROM ${ prefix }posts p + JOIN ${ prefix }term_relationships tr ON tr.object_id = p.ID + JOIN ${ prefix }term_taxonomy tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'wp_theme' + JOIN ${ prefix }terms t ON t.term_id = tt.term_id + WHERE p.post_type IN ( 'wp_template', 'wp_template_part', 'wp_global_styles' ) + AND p.post_status = 'publish' AND t.name = ? + ORDER BY p.post_type, p.post_name, p.post_date DESC, p.ID DESC` + ) + .all( stylesheet ) as unknown as Array< SavedCopy & { content: string } >; + const copies: SavedCopy[] = []; + const seen = new Set< string >(); + for ( const { content, ...row } of rows ) { + const key = + row.postType === 'wp_global_styles' ? row.postType : `${ row.postType }/${ row.slug }`; + if ( seen.has( key ) ) continue; + seen.add( key ); + if ( row.postType !== 'wp_global_styles' ) { + copies.push( row ); + continue; + } + const sets = stylePaths( content ); + if ( sets.length ) copies.push( { ...row, sets } ); + } + return copies; + } finally { + db.close(); + } + } catch { + return []; + } +} + +/** One line for the active site's facts, or undefined when nothing is saved. */ +export function formatSavedCopies( copies: SavedCopy[] ): string | undefined { + if ( ! copies.length ) return undefined; + const items = copies.map( ( copy ) => { + switch ( copy.postType ) { + case 'wp_global_styles': + return `Global Styles (post ${ + copy.id + }), which WordPress applies over theme.json; they set ${ copy.sets?.join( ', ' ) }`; + case 'wp_template': + return `template \`${ copy.slug }\` (post ${ copy.id }), which WordPress renders instead of templates/${ copy.slug }.html`; + case 'wp_template_part': + return `template part \`${ copy.slug }\` (post ${ copy.id }), which WordPress renders instead of parts/${ copy.slug }.html`; + } + } ); + return `Saved in the Site Editor: ${ items.join( '; ' ) }.`; +} + /** * Facts about a local site read from its own files and database, one line * each, for the Active site section: the WordPress it runs, where that diff --git a/apps/cli/ai/skills/edit-manage-site/SKILL.md b/apps/cli/ai/skills/edit-manage-site/SKILL.md index be57178b50..f59027f7bc 100644 --- a/apps/cli/ai/skills/edit-manage-site/SKILL.md +++ b/apps/cli/ai/skills/edit-manage-site/SKILL.md @@ -16,7 +16,6 @@ Work on the one site the request is about, and start it with site_start if it is For any change to content, templates, or the theme, load the `visual-design` skill first, along with the other skills the change calls for (Skill routing names them). Follow the site's existing look — its DESIGN.md and active theme — rather than redesigning it: the catalogs and design picks in `visual-design` are for builds. Then: -- Before editing an existing theme's template or part, check for a copy saved in the Site Editor (`post list --post_type=wp_template,wp_template_part`): WordPress renders that saved copy instead of the file, so if one exists, make the change in the saved copy too, keep the user's other edits, and tell the user. - Change an installed third-party theme only through a child theme, following the `block-content` skill's theme-file rules. - Validate every template, part, and piece of content you write with validate_blocks before it reaches the site. diff --git a/apps/cli/ai/tests/saved-overrides.test.ts b/apps/cli/ai/tests/saved-overrides.test.ts deleted file mode 100644 index 8edb2cc7c0..0000000000 --- a/apps/cli/ai/tests/saved-overrides.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from 'fs'; -import { DatabaseSync } from 'node:sqlite'; -import os from 'os'; -import path from 'path'; -import { describe, expect, it } from 'vitest'; -import { - formatSavedCopies, - getSavedOverrideNote, - readSavedCopies, - withSavedOverrideNote, -} from '../saved-overrides'; - -// A WordPress root with the tables a saved Site Editor copy lives in. -function makeSite( prefix = 'wp_' ) { - const root = mkdtempSync( path.join( os.tmpdir(), 'saved-overrides-' ) ); - mkdirSync( path.join( root, 'wp-includes' ) ); - mkdirSync( path.join( root, 'wp-content', 'database' ), { recursive: true } ); - mkdirSync( path.join( root, 'wp-content', 'themes', 'bakery', 'parts' ), { recursive: true } ); - writeFileSync( path.join( root, 'wp-config.php' ), ` { - id += 1; - db.prepare( `INSERT INTO ${ prefix }posts VALUES ( ?, ?, ?, ?, ?, '2026-10-22' )` ).run( - id, - postType, - status, - name, - content - ); - db.prepare( `INSERT INTO ${ prefix }term_relationships VALUES ( ?, ? )` ).run( id, theme ); - return id; - }; - return { root, save, close: () => db.close() }; -} - -describe( 'getSavedOverrideNote', () => { - it( 'names the saved copy that overrides a template part', async () => { - const site = makeSite(); - const id = site.save( 'wp_template_part', 'header', '' ); - site.close(); - - const note = await getSavedOverrideNote( - site.root, - 'wp-content/themes/bakery/parts/header.html' - ); - expect( note ).toContain( `wp_template_part post ${ id }` ); - expect( note ).toContain( 'not this file' ); - expect( - await getSavedOverrideNote( site.root, 'wp-content/themes/bakery/parts/footer.html' ) - ).toBeUndefined(); - } ); - - it( 'ignores copies saved for another theme, drafts, and other files', async () => { - const site = makeSite( 'wpx_' ); - site.save( 'wp_template', 'index', '', 2 ); - site.save( 'wp_template', 'home', '', 1, 'draft' ); - site.close(); - - for ( const file of [ - 'wp-content/themes/bakery/templates/index.html', - 'wp-content/themes/bakery/templates/home.html', - 'wp-content/themes/bakery/style.css', - 'wp-content/plugins/x/templates/index.html', - ] ) { - expect( await getSavedOverrideNote( site.root, file ) ).toBeUndefined(); - } - } ); - - it( 'lists what saved Global Styles set over theme.json', async () => { - const site = makeSite(); - const id = site.save( - 'wp_global_styles', - 'wp-global-styles-bakery', - JSON.stringify( { - version: 3, - isGlobalStylesUserThemeData: true, - styles: { color: { background: '#fff' }, typography: { lineHeight: '1.7' } }, - settings: {}, - } ) - ); - site.close(); - - const note = await getSavedOverrideNote( site.root, 'wp-content/themes/bakery/theme.json' ); - expect( note ).toContain( `wp_global_styles post ${ id }` ); - expect( note ).toContain( 'styles.color.background, styles.typography.lineHeight' ); - } ); - - it( 'has no note for empty saved Global Styles or a site without a database', async () => { - const site = makeSite(); - site.save( 'wp_global_styles', 'wp-global-styles-bakery', '{"version":3,"styles":{}}' ); - site.close(); - expect( - await getSavedOverrideNote( site.root, 'wp-content/themes/bakery/theme.json' ) - ).toBeUndefined(); - - const bare = mkdtempSync( path.join( os.tmpdir(), 'saved-overrides-bare-' ) ); - expect( - await getSavedOverrideNote( bare, 'wp-content/themes/bakery/parts/header.html' ) - ).toBeUndefined(); - } ); -} ); - -describe( 'withSavedOverrideNote', () => { - it( 'appends the note to the write result', async () => { - const site = makeSite(); - site.save( 'wp_template_part', 'header', '' ); - site.close(); - const tool = withSavedOverrideNote( - { - execute: async ( _id: string, _params: { path?: unknown } ) => ( { - content: [ { type: 'text', text: 'Wrote header.html' } ], - } ), - }, - site.root - ); - - const written = await tool.execute( 'call-1', { - path: 'wp-content/themes/bakery/parts/header.html', - } ); - expect( written.content ).toHaveLength( 2 ); - const other = await tool.execute( 'call-2', { - path: 'wp-content/themes/bakery/parts/footer.html', - } ); - expect( other.content ).toHaveLength( 1 ); - } ); -} ); - -describe( 'readSavedCopies', () => { - it( "lists the active theme's saved copies as one fact", async () => { - const site = makeSite(); - site.save( 'wp_template_part', 'header', '' ); - site.save( 'wp_template', 'index', '', 2 ); - site.save( - 'wp_global_styles', - 'wp-global-styles-bakery', - '{"styles":{"color":{"text":"#111"}}}' - ); - site.close(); - - const copies = await readSavedCopies( site.root ); - expect( copies.map( ( copy ) => copy.postType ) ).toEqual( [ - 'wp_global_styles', - 'wp_template_part', - ] ); - expect( formatSavedCopies( copies ) ).toBe( - "Saved in the Site Editor, so WordPress renders these instead of the theme's files: Global Styles (post 13, sets styles.color.text); template part `header` (post 11)." - ); - expect( formatSavedCopies( [] ) ).toBeUndefined(); - } ); -} ); diff --git a/apps/cli/ai/tests/site-facts.test.ts b/apps/cli/ai/tests/site-facts.test.ts index 7a8305f0ae..afbc2ecf32 100644 --- a/apps/cli/ai/tests/site-facts.test.ts +++ b/apps/cli/ai/tests/site-facts.test.ts @@ -1,8 +1,97 @@ import { mkdirSync, mkdtempSync, writeFileSync } from 'fs'; +import { DatabaseSync } from 'node:sqlite'; import os from 'os'; import path from 'path'; import { describe, expect, it } from 'vitest'; -import { readActiveSiteFacts } from '../site-facts'; +import { formatSavedCopies, readActiveSiteFacts, readSavedCopies } from '../site-facts'; + +// A WordPress root with the tables a saved Site Editor copy lives in. +function makeSite( prefix = 'wp_' ) { + const root = mkdtempSync( path.join( os.tmpdir(), 'saved-overrides-' ) ); + mkdirSync( path.join( root, 'wp-includes' ) ); + mkdirSync( path.join( root, 'wp-content', 'database' ), { recursive: true } ); + writeFileSync( path.join( root, 'wp-config.php' ), ` { + id += 1; + db.prepare( `INSERT INTO ${ prefix }posts VALUES ( ?, ?, ?, ?, ?, '2026-10-22' )` ).run( + id, + postType, + status, + name, + content + ); + db.prepare( `INSERT INTO ${ prefix }term_relationships VALUES ( ?, ? )` ).run( id, theme ); + return id; + }; + return { root, save, close: () => db.close() }; +} + +describe( 'readSavedCopies', () => { + it( "states the active theme's saved copies and what each overrides", async () => { + const site = makeSite(); + site.save( 'wp_template_part', 'header', '' ); + site.save( 'wp_template', 'index', '' ); + site.save( + 'wp_global_styles', + 'wp-global-styles-bakery', + JSON.stringify( { + version: 3, + styles: { color: { background: '#fff' }, typography: { lineHeight: '1.7' } }, + settings: {}, + } ) + ); + site.close(); + + expect( formatSavedCopies( await readSavedCopies( site.root ) ) ).toBe( + 'Saved in the Site Editor: ' + + 'Global Styles (post 13), which WordPress applies over theme.json; they set styles.color.background, styles.typography.lineHeight; ' + + 'template `index` (post 12), which WordPress renders instead of templates/index.html; ' + + 'template part `header` (post 11), which WordPress renders instead of parts/header.html.' + ); + } ); + + it( 'takes the latest published copy per slug, for the active theme only', async () => { + const site = makeSite( 'wpx_' ); + site.save( 'wp_template', 'index', '', 2 ); + site.save( 'wp_template', 'home', '', 1, 'draft' ); + site.save( 'wp_template_part', 'header', '' ); + site.save( 'wp_template_part', 'header', '' ); + site.close(); + + expect( await readSavedCopies( site.root ) ).toEqual( [ + { postType: 'wp_template_part', id: 14, slug: 'header' }, + ] ); + } ); + + it( 'has nothing for empty Global Styles or a site without a database', async () => { + const site = makeSite(); + site.save( 'wp_global_styles', 'wp-global-styles-bakery', '{"version":3,"styles":{}}' ); + site.close(); + expect( await readSavedCopies( site.root ) ).toEqual( [] ); + expect( + await readSavedCopies( mkdtempSync( path.join( os.tmpdir(), 'saved-overrides-bare-' ) ) ) + ).toEqual( [] ); + expect( formatSavedCopies( [] ) ).toBeUndefined(); + } ); +} ); describe( 'readActiveSiteFacts', () => { it( 'states the WordPress a local site runs and where its block types are declared', async () => { From a5d9673bf62ea8b86217b84255dadf6f96fd770c Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 22 Oct 2026 19:16:47 -0400 Subject: [PATCH 3/5] Group the saved copies, mark those only in the Site Editor, and summarize Global Styles by area --- apps/cli/ai/site-facts.ts | 121 +++++++++++++++++---------- apps/cli/ai/tests/site-facts.test.ts | 35 ++++++-- 2 files changed, 104 insertions(+), 52 deletions(-) diff --git a/apps/cli/ai/site-facts.ts b/apps/cli/ai/site-facts.ts index 0174ef2267..72ea69f0c9 100644 --- a/apps/cli/ai/site-facts.ts +++ b/apps/cli/ai/site-facts.ts @@ -17,10 +17,18 @@ export interface SavedCopy { id: number; // The template or part's slug (the file name without .html). slug: string; - // For Global Styles: what they set, as paths such as `styles.color.background`. + // For a template or part: whether the theme (or its parent) has a file for it. + hasThemeFile?: boolean; + // For Global Styles: the areas they set, such as `color` or `14 block styles`. sets?: string[]; } +// The theme folders WordPress reads a template or part's file from. +const THEME_FILE_DIRS: Record< 'wp_template' | 'wp_template_part', string[] > = { + wp_template: [ 'templates', 'block-templates' ], + wp_template_part: [ 'parts', 'block-template-parts' ], +}; + function tablePrefix( root: string ): string { try { const config = readFileSync( path.join( root, 'wp-config.php' ), 'utf8' ); @@ -30,27 +38,22 @@ function tablePrefix( root: string ): string { } } -// Paths of what saved Global Styles set, to two levels below styles/settings. -function stylePaths( content: string ): string[] { - let data: Record< string, unknown >; +// The areas saved Global Styles set: each style and setting they hold, with +// per-block styles counted. +function styleAreas( content: string ): string[] { + let data: { styles?: Record< string, unknown >; settings?: Record< string, unknown > }; try { - data = JSON.parse( content ) as Record< string, unknown >; + data = JSON.parse( content ); } catch { return []; } - const paths: string[] = []; - for ( const top of [ 'styles', 'settings' ] ) { - const section = data[ top ]; - if ( ! section || typeof section !== 'object' ) continue; - for ( const [ key, value ] of Object.entries( section ) ) { - if ( value && typeof value === 'object' && ! Array.isArray( value ) ) { - paths.push( ...Object.keys( value ).map( ( child ) => `${ top }.${ key }.${ child }` ) ); - } else { - paths.push( `${ top }.${ key }` ); - } - } - } - return paths; + const { blocks, ...styles } = data.styles ?? {}; + const blockCount = blocks && typeof blocks === 'object' ? Object.keys( blocks ).length : 0; + return [ + ...Object.keys( styles ), + ...( blockCount ? [ `${ blockCount } block style${ blockCount === 1 ? '' : 's' }` ] : [] ), + ...Object.keys( data.settings ?? {} ).map( ( key ) => `${ key } settings` ), + ]; } /** @@ -68,14 +71,17 @@ export async function readSavedCopies( root: string ): Promise< SavedCopy[] > { const db = new DatabaseSync( dbPath, { readOnly: true } ); try { const prefix = tablePrefix( root ); - const stylesheet = ( - db - .prepare( - `SELECT option_value AS value FROM ${ prefix }options WHERE option_name = 'stylesheet'` - ) - .get() as { value: string } | undefined - )?.value; + const option = ( name: string ) => + ( + db + .prepare( `SELECT option_value AS value FROM ${ prefix }options WHERE option_name = ?` ) + .get( name ) as { value: string } | undefined + )?.value; + const stylesheet = option( 'stylesheet' ); if ( ! stylesheet ) return []; + const themeDirs = [ stylesheet, option( 'template' ) ?? stylesheet ].map( ( theme ) => + path.join( root, 'wp-content', 'themes', theme ) + ); // The latest published post per type and slug, for the theme its // wp_theme term names. const rows = db @@ -97,12 +103,17 @@ export async function readSavedCopies( root: string ): Promise< SavedCopy[] > { row.postType === 'wp_global_styles' ? row.postType : `${ row.postType }/${ row.slug }`; if ( seen.has( key ) ) continue; seen.add( key ); - if ( row.postType !== 'wp_global_styles' ) { - copies.push( row ); + if ( row.postType === 'wp_global_styles' ) { + const sets = styleAreas( content ); + if ( sets.length ) copies.push( { ...row, sets } ); continue; } - const sets = stylePaths( content ); - if ( sets.length ) copies.push( { ...row, sets } ); + const hasThemeFile = themeDirs.some( ( themeDir ) => + THEME_FILE_DIRS[ row.postType as 'wp_template' | 'wp_template_part' ].some( ( dir ) => + existsSync( path.join( themeDir, dir, `${ row.slug }.html` ) ) + ) + ); + copies.push( { ...row, hasThemeFile } ); } return copies; } finally { @@ -113,22 +124,46 @@ export async function readSavedCopies( root: string ): Promise< SavedCopy[] > { } } +// "templates index (post 8), single (9); template parts header (5)" +function listByType( copies: SavedCopy[] ): string { + const groups = ( + [ + [ 'wp_template', 'template', 'templates' ], + [ 'wp_template_part', 'template part', 'template parts' ], + ] as const + ) + .map( ( [ postType, one, many ] ) => { + const ofType = copies.filter( ( copy ) => copy.postType === postType ); + const items = ofType.map( + ( copy, i ) => `${ copy.slug } (${ i === 0 ? 'post ' : '' }${ copy.id })` + ); + return ofType.length ? `${ ofType.length === 1 ? one : many } ${ items.join( ', ' ) }` : ''; + } ) + .filter( Boolean ); + return groups.join( '; ' ); +} + /** One line for the active site's facts, or undefined when nothing is saved. */ export function formatSavedCopies( copies: SavedCopy[] ): string | undefined { - if ( ! copies.length ) return undefined; - const items = copies.map( ( copy ) => { - switch ( copy.postType ) { - case 'wp_global_styles': - return `Global Styles (post ${ - copy.id - }), which WordPress applies over theme.json; they set ${ copy.sets?.join( ', ' ) }`; - case 'wp_template': - return `template \`${ copy.slug }\` (post ${ copy.id }), which WordPress renders instead of templates/${ copy.slug }.html`; - case 'wp_template_part': - return `template part \`${ copy.slug }\` (post ${ copy.id }), which WordPress renders instead of parts/${ copy.slug }.html`; - } - } ); - return `Saved in the Site Editor: ${ items.join( '; ' ) }.`; + const overriding = listByType( copies.filter( ( copy ) => copy.hasThemeFile ) ); + const editorOnly = listByType( copies.filter( ( copy ) => copy.hasThemeFile === false ) ); + const styles = copies.find( ( copy ) => copy.postType === 'wp_global_styles' ); + const sentences = [ + overriding && + `Saved in the Site Editor, which WordPress uses instead of the theme's files: ${ overriding }.`, + editorOnly && `Only in the Site Editor: ${ editorOnly }.`, + styles && + `Global Styles (post ${ styles.id }), applied over theme.json, set ${ joinList( + styles.sets ?? [] + ) }.`, + ].filter( Boolean ); + return sentences.length ? sentences.join( ' ' ) : undefined; +} + +function joinList( items: string[] ): string { + return items.length < 3 + ? items.join( ' and ' ) + : `${ items.slice( 0, -1 ).join( ', ' ) }, and ${ items[ items.length - 1 ] }`; } /** diff --git a/apps/cli/ai/tests/site-facts.test.ts b/apps/cli/ai/tests/site-facts.test.ts index afbc2ecf32..6417434060 100644 --- a/apps/cli/ai/tests/site-facts.test.ts +++ b/apps/cli/ai/tests/site-facts.test.ts @@ -11,6 +11,17 @@ function makeSite( prefix = 'wp_' ) { mkdirSync( path.join( root, 'wp-includes' ) ); mkdirSync( path.join( root, 'wp-content', 'database' ), { recursive: true } ); writeFileSync( path.join( root, 'wp-config.php' ), ` { + mkdirSync( path.dirname( path.join( root, 'wp-content', 'themes', file ) ), { + recursive: true, + } ); + writeFileSync( path.join( root, 'wp-content', 'themes', file ), '' ); + }; + themeFile( 'bakery/parts/header.html' ); + themeFile( 'bakery-parent/templates/index.html' ); + themeFile( 'bakery-parent/templates/single.html' ); const db = new DatabaseSync( path.join( root, 'wp-content', 'database', '.ht.sqlite' ) ); db.exec( ` CREATE TABLE ${ prefix }posts ( ID INTEGER PRIMARY KEY, post_type TEXT, post_status TEXT, post_name TEXT, post_content TEXT, post_date TEXT ); @@ -18,7 +29,7 @@ function makeSite( prefix = 'wp_' ) { CREATE TABLE ${ prefix }term_taxonomy ( term_taxonomy_id INTEGER PRIMARY KEY, term_id INTEGER, taxonomy TEXT ); CREATE TABLE ${ prefix }term_relationships ( object_id INTEGER, term_taxonomy_id INTEGER ); CREATE TABLE ${ prefix }options ( option_name TEXT, option_value TEXT ); - INSERT INTO ${ prefix }options VALUES ( 'stylesheet', 'bakery' ); + INSERT INTO ${ prefix }options VALUES ( 'stylesheet', 'bakery' ), ( 'template', 'bakery-parent' ); INSERT INTO ${ prefix }terms VALUES ( 1, 'bakery' ), ( 2, 'other-theme' ); INSERT INTO ${ prefix }term_taxonomy VALUES ( 1, 1, 'wp_theme' ), ( 2, 2, 'wp_theme' ); ` ); @@ -45,26 +56,32 @@ function makeSite( prefix = 'wp_' ) { } describe( 'readSavedCopies', () => { - it( "states the active theme's saved copies and what each overrides", async () => { + it( "states the active theme's saved copies, grouped, and which replace a theme file", async () => { const site = makeSite(); site.save( 'wp_template_part', 'header', '' ); site.save( 'wp_template', 'index', '' ); + site.save( 'wp_template', 'single', '' ); + site.save( 'wp_template', 'page-about', '' ); + site.save( 'wp_template_part', 'promo', '' ); site.save( 'wp_global_styles', 'wp-global-styles-bakery', JSON.stringify( { version: 3, - styles: { color: { background: '#fff' }, typography: { lineHeight: '1.7' } }, - settings: {}, + styles: { + color: { background: '#fff' }, + typography: { lineHeight: '1.7' }, + blocks: { 'core/button': {}, 'core/quote': {} }, + }, + settings: { color: { palette: {} } }, } ) ); site.close(); expect( formatSavedCopies( await readSavedCopies( site.root ) ) ).toBe( - 'Saved in the Site Editor: ' + - 'Global Styles (post 13), which WordPress applies over theme.json; they set styles.color.background, styles.typography.lineHeight; ' + - 'template `index` (post 12), which WordPress renders instead of templates/index.html; ' + - 'template part `header` (post 11), which WordPress renders instead of parts/header.html.' + "Saved in the Site Editor, which WordPress uses instead of the theme's files: templates index (post 12), single (13); template part header (post 11). " + + 'Only in the Site Editor: template page-about (post 14); template part promo (post 15). ' + + 'Global Styles (post 16), applied over theme.json, set color, typography, 2 block styles, and color settings.' ); } ); @@ -77,7 +94,7 @@ describe( 'readSavedCopies', () => { site.close(); expect( await readSavedCopies( site.root ) ).toEqual( [ - { postType: 'wp_template_part', id: 14, slug: 'header' }, + { postType: 'wp_template_part', id: 14, slug: 'header', hasThemeFile: true }, ] ); } ); From f419a7aab34542e032184fde89946c6556378cd7 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 22 Oct 2026 22:29:06 -0400 Subject: [PATCH 4/5] Studio Code: Drop the block.json pointer from Active site facts, now shown with the blocks --- apps/cli/ai/site-facts.ts | 9 +++------ apps/cli/ai/tests/site-facts.test.ts | 6 +----- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/apps/cli/ai/site-facts.ts b/apps/cli/ai/site-facts.ts index 72ea69f0c9..569e6bf3c8 100644 --- a/apps/cli/ai/site-facts.ts +++ b/apps/cli/ai/site-facts.ts @@ -168,8 +168,8 @@ function joinList( items: string[] ): string { /** * Facts about a local site read from its own files and database, one line - * each, for the Active site section: the WordPress it runs, where that - * WordPress declares its block types, and the copies saved in the Site Editor. + * each, for the Active site section: the WordPress version it runs and the + * copies saved in the Site Editor. */ export async function readActiveSiteFacts( site?: SiteInfo | null ): Promise< string[] > { if ( ! site || site.remote ) { @@ -179,11 +179,8 @@ export async function readActiveSiteFacts( site?: SiteInfo | null ): Promise< st getWordPressVersionFromInstallation( site.path ), readSavedCopies( site.path ), ] ); - const blocks = path.join( site.path, 'wp-includes', 'blocks' ).split( path.sep ).join( '/' ); return [ - version - ? `WordPress ${ version }. Its core block types, with their attributes and supports, are declared in ${ blocks }//block.json.` - : undefined, + version ? `WordPress ${ version }.` : undefined, formatSavedCopies( savedCopies ), ].filter( ( fact ): fact is string => Boolean( fact ) ); } diff --git a/apps/cli/ai/tests/site-facts.test.ts b/apps/cli/ai/tests/site-facts.test.ts index 6417434060..346e39e871 100644 --- a/apps/cli/ai/tests/site-facts.test.ts +++ b/apps/cli/ai/tests/site-facts.test.ts @@ -121,11 +121,7 @@ describe( 'readActiveSiteFacts', () => { const facts = await readActiveSiteFacts( { name: 'Site', path: sitePath, running: true } ); - expect( facts ).toEqual( [ - `WordPress 6.9.1. Its core block types, with their attributes and supports, are declared in ${ sitePath - .split( path.sep ) - .join( '/' ) }/wp-includes/blocks//block.json.`, - ] ); + expect( facts ).toEqual( [ 'WordPress 6.9.1.' ] ); } ); it( 'has no facts for a WordPress.com site or no site', async () => { From b5e5d7edba855cc6abd919fbc338e71401bd385f Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 22 Oct 2026 22:36:13 -0400 Subject: [PATCH 5/5] Studio Code: Use WordPress 7.1.3 in the Active site facts version test --- apps/cli/ai/tests/site-facts.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cli/ai/tests/site-facts.test.ts b/apps/cli/ai/tests/site-facts.test.ts index 346e39e871..02666298dc 100644 --- a/apps/cli/ai/tests/site-facts.test.ts +++ b/apps/cli/ai/tests/site-facts.test.ts @@ -116,12 +116,12 @@ describe( 'readActiveSiteFacts', () => { mkdirSync( path.join( sitePath, 'wp-includes' ) ); writeFileSync( path.join( sitePath, 'wp-includes', 'version.php' ), - " {