From ab860c8c76d6578540b5949d90c27fa484dcff77 Mon Sep 17 00:00:00 2001 From: Andrei Draganescu Date: Tue, 2 Jun 2026 21:31:20 +0300 Subject: [PATCH] =?UTF-8?q?feat(cli):=20wordpress-site-generator=20?= =?UTF-8?q?=E2=80=94=20theme=20+=20companion-plugin=20generation=20for=20s?= =?UTF-8?q?tudio=20ai?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an end-to-end WordPress site generator to the `studio ai`/`studio code` agent: generation tools plus knowledge/runbook skills that produce a pure-presentation block theme and a companion plugin (CPTs, REST routes, build-less plain-JS blocks), seed content into the live DB, and fill AI imagery. Tools (registered in tools/index.ts): generate_design_previews, generate_theme (parallel per-file generation), generate_companion_plugin, seed_content, generate_image. Orchestrated by the `site-generator` skill (/site-generator slash command) alongside theme-architecture, companion-plugin, layout-patterns, wp-best-practices, and data-persistence knowledge skills + bundled generator prompt fragments adapted from Telex. Generation core (apps/cli/ai/generation/): non-streaming parallel Anthropic client reusing the wpcom provider env, transient-error retry/backoff, robust JSON extraction, manifest schema, AI_IMAGE handling, path/WP-CLI helpers. Key correctness fixes from live testing: - seed_content writes large post content to a host file the WP filesystem reads (wp post create/update ) instead of a giant --post_content arg that the daemon IPC bus truncates (25KB -> 16 bytes). - CPT entries generated with their meta fields from the companion plugin's post types, so collections actually populate. - AI images use the authorized OpenAI images route (gpt-image-1 under the studio-assistant feature slug); Telex's Imagen slug is 403 for Studio accounts. Placeholders are stripped when not logged into WordPress.com. Adds unit tests (apps/cli/ai/tests/wsg-generation.test.ts, 22 passing). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/cli/ai/generation/generators.ts | 120 ++++ apps/cli/ai/generation/images.ts | 131 ++++ apps/cli/ai/generation/llm.ts | 230 +++++++ apps/cli/ai/generation/manifest.ts | 239 +++++++ apps/cli/ai/generation/paths.ts | 50 ++ apps/cli/ai/generation/site-wp.ts | 37 ++ apps/cli/ai/generation/wpcom-image.ts | 149 +++++ apps/cli/ai/skills.ts | 13 +- apps/cli/ai/skills/companion-plugin/SKILL.md | 441 ++++++++++++ apps/cli/ai/skills/data-persistence/SKILL.md | 463 +++++++++++++ apps/cli/ai/skills/layout-patterns/SKILL.md | 627 ++++++++++++++++++ apps/cli/ai/skills/site-generator/SKILL.md | 110 +++ .../site-generator/generators/_shared.md | 64 ++ .../skills/site-generator/generators/block.md | 223 +++++++ .../generators/companion-plugin.md | 188 ++++++ .../generators/design-direction.md | 111 ++++ .../site-generator/generators/manifest.md | 138 ++++ .../site-generator/generators/page-content.md | 88 +++ .../site-generator/generators/style-css.md | 197 ++++++ .../generators/template-part.md | 199 ++++++ .../site-generator/generators/template.md | 239 +++++++ .../site-generator/generators/theme-json.md | 134 ++++ .../cli/ai/skills/theme-architecture/SKILL.md | 243 +++++++ apps/cli/ai/skills/wp-best-practices/SKILL.md | 393 +++++++++++ apps/cli/ai/system-prompt.ts | 2 + apps/cli/ai/tests/wsg-generation.test.ts | 211 ++++++ .../cli/ai/tools/generate-companion-plugin.ts | 175 +++++ apps/cli/ai/tools/generate-design-previews.ts | 118 ++++ apps/cli/ai/tools/generate-image.ts | 155 +++++ apps/cli/ai/tools/generate-theme.ts | 256 +++++++ apps/cli/ai/tools/index.ts | 10 + apps/cli/ai/tools/seed-content.ts | 493 ++++++++++++++ tools/common/ai/slash-commands.ts | 6 + 33 files changed, 6252 insertions(+), 1 deletion(-) create mode 100644 apps/cli/ai/generation/generators.ts create mode 100644 apps/cli/ai/generation/images.ts create mode 100644 apps/cli/ai/generation/llm.ts create mode 100644 apps/cli/ai/generation/manifest.ts create mode 100644 apps/cli/ai/generation/paths.ts create mode 100644 apps/cli/ai/generation/site-wp.ts create mode 100644 apps/cli/ai/generation/wpcom-image.ts create mode 100644 apps/cli/ai/skills/companion-plugin/SKILL.md create mode 100644 apps/cli/ai/skills/data-persistence/SKILL.md create mode 100644 apps/cli/ai/skills/layout-patterns/SKILL.md create mode 100644 apps/cli/ai/skills/site-generator/SKILL.md create mode 100644 apps/cli/ai/skills/site-generator/generators/_shared.md create mode 100644 apps/cli/ai/skills/site-generator/generators/block.md create mode 100644 apps/cli/ai/skills/site-generator/generators/companion-plugin.md create mode 100644 apps/cli/ai/skills/site-generator/generators/design-direction.md create mode 100644 apps/cli/ai/skills/site-generator/generators/manifest.md create mode 100644 apps/cli/ai/skills/site-generator/generators/page-content.md create mode 100644 apps/cli/ai/skills/site-generator/generators/style-css.md create mode 100644 apps/cli/ai/skills/site-generator/generators/template-part.md create mode 100644 apps/cli/ai/skills/site-generator/generators/template.md create mode 100644 apps/cli/ai/skills/site-generator/generators/theme-json.md create mode 100644 apps/cli/ai/skills/theme-architecture/SKILL.md create mode 100644 apps/cli/ai/skills/wp-best-practices/SKILL.md create mode 100644 apps/cli/ai/tests/wsg-generation.test.ts create mode 100644 apps/cli/ai/tools/generate-companion-plugin.ts create mode 100644 apps/cli/ai/tools/generate-design-previews.ts create mode 100644 apps/cli/ai/tools/generate-image.ts create mode 100644 apps/cli/ai/tools/generate-theme.ts create mode 100644 apps/cli/ai/tools/seed-content.ts diff --git a/apps/cli/ai/generation/generators.ts b/apps/cli/ai/generation/generators.ts new file mode 100644 index 0000000000..0d97b5b146 --- /dev/null +++ b/apps/cli/ai/generation/generators.ts @@ -0,0 +1,120 @@ +import fs from 'fs'; +import path from 'path'; +import { getSkillsRoot } from 'cli/ai/skills'; +import { completeText, extractJson, stripCodeFences } from './llm'; +import { parseManifest, type SiteManifest } from './manifest'; + +/** + * Loads and runs the generator prompt fragments bundled under + * `skills/site-generator/generators/`. Each fragment is a system-prompt body + * adapted from a Telex generator; the tool appends the site spec + chosen + * design + a per-call task. `_shared` is prepended to every call. + */ + +function generatorsDir(): string { + return path.join( getSkillsRoot(), 'site-generator', 'generators' ); +} + +const fragmentCache = new Map< string, string >(); + +function loadFragment( name: string ): string { + const cached = fragmentCache.get( name ); + if ( cached !== undefined ) { + return cached; + } + const file = path.join( generatorsDir(), `${ name }.md` ); + if ( ! fs.existsSync( file ) ) { + throw new Error( + `Generator prompt fragment not found: ${ name } (${ file }). Is the wordpress-site-generator bundle installed?` + ); + } + const content = fs.readFileSync( file, 'utf8' ); + fragmentCache.set( name, content ); + return content; +} + +export interface RunGeneratorInput { + name: string; + specJson: string; + design?: string; + task?: string; + maxTokens?: number; + temperature?: number; +} + +export async function runGenerator( input: RunGeneratorInput ): Promise< string > { + const shared = loadFragment( '_shared' ); + const fragment = loadFragment( input.name ); + const system = `${ shared }\n\n----------\n\n${ fragment }`; + + let user = `SITE SPEC (JSON):\n${ input.specJson }`; + if ( input.design ) { + user += `\n\nCHOSEN DESIGN DIRECTION:\n${ input.design }`; + } + if ( input.task ) { + user += `\n\nTASK:\n${ input.task }`; + } + + return stripCodeFences( + await completeText( { + system, + user, + maxTokens: input.maxTokens, + temperature: input.temperature, + } ) + ); +} + +export async function runManifest( specJson: string ): Promise< SiteManifest > { + // Generous budget: the manifest carries a cinematic composition brief per + // page, so a small cap truncates the JSON mid-stream (no closing fence/brace) + // and the parse fails. + const raw = await runGenerator( { + name: 'manifest', + specJson, + maxTokens: 16_000, + temperature: 0.2, + } ); + return parseManifest( raw ); +} + +export interface GeneratedBlockFiles { + files: Record< string, string >; +} + +export async function runBlockGenerator( + specJson: string, + blockTask: string +): Promise< GeneratedBlockFiles > { + const raw = await runGenerator( { + name: 'block', + specJson, + task: blockTask, + maxTokens: 16_000, + temperature: 0.3, + } ); + let data: unknown; + try { + data = JSON.parse( extractJson( raw ) ); + } catch ( error ) { + throw new Error( + `Block generator did not return valid JSON: ${ + error instanceof Error ? error.message : String( error ) + }` + ); + } + const filesValue = ( data as { files?: unknown } )?.files; + if ( ! filesValue || typeof filesValue !== 'object' ) { + throw new Error( 'Block generator JSON is missing a "files" object.' ); + } + const files: Record< string, string > = {}; + for ( const [ key, value ] of Object.entries( filesValue as Record< string, unknown > ) ) { + if ( typeof value === 'string' ) { + files[ key ] = value; + } + } + if ( ! Object.keys( files ).length ) { + throw new Error( 'Block generator produced no files.' ); + } + return { files }; +} diff --git a/apps/cli/ai/generation/images.ts b/apps/cli/ai/generation/images.ts new file mode 100644 index 0000000000..11fbea4915 --- /dev/null +++ b/apps/cli/ai/generation/images.ts @@ -0,0 +1,131 @@ +import { aspectFromHint, generateImageBytes, type ImageAspectRatio } from './wpcom-image'; + +/** + * Resolves the `AI_IMAGE:` placeholder convention. Generated markup uses + * AI_IMAGE: <description> | <style> | <aspect> + * so imagery can be filled in after structure is generated. These helpers find + * those placeholders, generate the image, hand the bytes to a caller-supplied + * persistence strategy (theme assets dir, or the media uploads dir), and + * rewrite the tag's `src`/`alt` in place. + */ + +export interface AiImageMatch { + tag: string; + description: string; + style?: string; + aspect?: string; +} + +const IMG_TAG_RE = /]*>/gi; + +export function getAttr( tag: string, name: string ): string | undefined { + const match = + tag.match( new RegExp( `\\b${ name }\\s*=\\s*"([^"]*)"`, 'i' ) ) ?? + tag.match( new RegExp( `\\b${ name }\\s*=\\s*'([^']*)'`, 'i' ) ); + return match ? match[ 1 ] : undefined; +} + +function setAttr( tag: string, name: string, value: string ): string { + const escaped = value.replace( /&/g, '&' ).replace( /"/g, '"' ); + const re = new RegExp( `\\s${ name }\\s*=\\s*("[^"]*"|'[^']*')`, 'i' ); + if ( re.test( tag ) ) { + return tag.replace( re, ` ${ name }="${ escaped }"` ); + } + return tag.replace( /(\s*\/?>)\s*$/, ` ${ name }="${ escaped }"$1` ); +} + +export function parseAiImageAlt( + alt: string +): { description: string; style?: string; aspect?: string } | null { + const match = alt.match( /^\s*AI_IMAGE:\s*(.+)$/i ); + if ( ! match ) { + return null; + } + const parts = match[ 1 ].split( '|' ).map( ( s ) => s.trim() ); + return { + description: parts[ 0 ] || 'abstract textured background', + style: parts[ 1 ], + aspect: parts[ 2 ], + }; +} + +export function findAiImages( html: string ): AiImageMatch[] { + const out: AiImageMatch[] = []; + for ( const tag of html.match( IMG_TAG_RE ) ?? [] ) { + const alt = getAttr( tag, 'alt' ); + if ( ! alt ) { + continue; + } + const parsed = parseAiImageAlt( alt ); + if ( parsed ) { + out.push( { tag, ...parsed } ); + } + } + return out; +} + +// Removes AI_IMAGE placeholder tags (used when imagery can't be +// generated) while leaving real tags intact, so a page renders as +// intentional sections rather than broken images. +export function stripAiImagePlaceholders( html: string ): string { + return html.replace( /]*\bAI_IMAGE:[^>]*>/gi, '' ); +} + +export function buildImagePrompt( description: string, style?: string ): string { + const styleClause = style ? ` Style: ${ style }.` : ''; + return `${ description }.${ styleClause } High quality, professional, clean composition. No text, no lettering, no watermark, no logo.`; +} + +export interface PersistImageContext { + description: string; + aspect: ImageAspectRatio; + index: number; +} + +export type PersistImage = ( bytes: Buffer, ctx: PersistImageContext ) => Promise< string | null >; + +export interface ImageResolution { + html: string; + generated: number; + failed: number; + total: number; +} + +/** + * Best-effort: any image that fails to generate or persist is left as-is (the + * placeholder stays) so a single failure never aborts the whole build. + */ +export async function resolveAiImagesInHtml( + html: string, + persist: PersistImage +): Promise< ImageResolution > { + const matches = findAiImages( html ); + let out = html; + let generated = 0; + let failed = 0; + let index = 0; + + for ( const match of matches ) { + index++; + try { + const aspect = aspectFromHint( match.aspect ); + const bytes = await generateImageBytes( + buildImagePrompt( match.description, match.style ), + aspect + ); + const url = await persist( bytes, { description: match.description, aspect, index } ); + if ( ! url ) { + failed++; + continue; + } + let newTag = setAttr( match.tag, 'src', url ); + newTag = setAttr( newTag, 'alt', match.description ); + out = out.replace( match.tag, newTag ); + generated++; + } catch { + failed++; + } + } + + return { html: out, generated, failed, total: matches.length }; +} diff --git a/apps/cli/ai/generation/llm.ts b/apps/cli/ai/generation/llm.ts new file mode 100644 index 0000000000..b73cc06b86 --- /dev/null +++ b/apps/cli/ai/generation/llm.ts @@ -0,0 +1,230 @@ +import Anthropic from '@anthropic-ai/sdk'; +import { AI_MODELS, DEFAULT_MODEL, type AiModelId } from '@studio/common/ai/models'; +import { resolveAiEnvironment, resolveInitialAiProvider } from 'cli/ai/auth'; + +/** + * Generation LLM client used by the wordpress-site-generator tools. + * + * The agent runtime (runtimes/pi) builds its own streaming model from a + * resolved provider environment. The generation tools run their own + * (non-streaming, parallel) model calls inside tool handlers, so they resolve + * the same provider environment independently and construct a plain + * `@anthropic-ai/sdk` client from it — mirroring `createWpcomAnthropicProviderConfig` + * in runtimes/pi. This works for both the wpcom proxy (bearer auth + + * X-WPCOM-AI-Feature header) and a direct Anthropic API key. + */ + +const GENERATION_MAX_TOKENS = 16_000; + +// ANTHROPIC_CUSTOM_HEADERS is a newline-delimited `Name: value` string (see +// providers.ts / runtimes/pi parseAnthropicHeaderEnv). Reproduced here so the +// generation client sends the same X-WPCOM-AI-Feature header as the agent. +function parseAnthropicHeaderEnv( + value: string | undefined +): Record< string, string > | undefined { + if ( ! value ) { + return undefined; + } + const out: Record< string, string > = {}; + for ( const line of value.split( '\n' ) ) { + const idx = line.indexOf( ':' ); + if ( idx <= 0 ) { + continue; + } + const name = line.slice( 0, idx ).trim(); + const v = line.slice( idx + 1 ).trim(); + if ( name && v ) { + out[ name ] = v; + } + } + return Object.keys( out ).length ? out : undefined; +} + +interface GenerationClient { + client: Anthropic; + model: AiModelId; +} + +let cached: GenerationClient | null = null; + +function resolveGenerationModel(): AiModelId { + const override = process.env.STUDIO_WSG_MODEL?.trim(); + if ( override && AI_MODELS.some( ( m ) => m.id === override && m.family === 'anthropic' ) ) { + return override as AiModelId; + } + return DEFAULT_MODEL; +} + +async function getClient(): Promise< GenerationClient > { + if ( cached ) { + return cached; + } + + const provider = await resolveInitialAiProvider(); + const env = await resolveAiEnvironment( provider ); + + const authToken = env.ANTHROPIC_AUTH_TOKEN?.trim(); + const apiKey = env.ANTHROPIC_API_KEY?.trim(); + if ( ! authToken && ! apiKey ) { + throw new Error( + 'WordPress.com login required for site generation. Run /login to authenticate, or switch to the Anthropic API key provider with /provider.' + ); + } + + const baseURL = env.ANTHROPIC_BASE_URL?.trim() || 'https://api.anthropic.com'; + const defaultHeaders = parseAnthropicHeaderEnv( env.ANTHROPIC_CUSTOM_HEADERS ); + + const client = new Anthropic( { + apiKey: authToken ? null : apiKey ?? null, + authToken: authToken ?? undefined, + baseURL, + defaultHeaders, + dangerouslyAllowBrowser: true, + } ); + + // Only cache on success so a pre-login failure doesn't pin a broken client. + cached = { client, model: resolveGenerationModel() }; + return cached; +} + +export interface CompleteTextOptions { + system: string; + user: string; + maxTokens?: number; + temperature?: number; +} + +// Statuses worth retrying: rate limits, gateway/proxy hiccups, and Anthropic's +// 529 "overloaded". The WordPress.com AI proxy occasionally returns a 503 HTML +// page on transient load; without a retry that aborts a whole long-running +// generation (e.g. seeding many pages). +const TRANSIENT_STATUSES = new Set( [ 408, 409, 425, 429, 500, 502, 503, 504, 529 ] ); +const TRANSIENT_MESSAGE_RE = + /\b(429|500|502|503|504|529)\b|overloaded|rate.?limit|temporarily unavailable|service unavailable|gateway|timeout|ETIMEDOUT|ECONNRESET|ECONNREFUSED|EAI_AGAIN/i; + +export function isTransientError( error: unknown ): boolean { + const status = ( error as { status?: number } )?.status; + if ( typeof status === 'number' && TRANSIENT_STATUSES.has( status ) ) { + return true; + } + const message = error instanceof Error ? error.message : String( error ); + return TRANSIENT_MESSAGE_RE.test( message ); +} + +function sleep( ms: number ): Promise< void > { + return new Promise( ( resolve ) => setTimeout( resolve, ms ) ); +} + +export async function withTransientRetry< T >( fn: () => Promise< T > ): Promise< T > { + const maxAttempts = 5; + let lastError: unknown; + for ( let attempt = 1; attempt <= maxAttempts; attempt++ ) { + try { + return await fn(); + } catch ( error ) { + lastError = error; + if ( attempt === maxAttempts || ! isTransientError( error ) ) { + throw error; + } + // Exponential backoff with jitter (1.5s, 3s, 6s, 12s, capped at 20s) + // to ride out a transient proxy outage without a thundering herd. + const backoff = Math.min( 1500 * 2 ** ( attempt - 1 ), 20_000 ); + await sleep( backoff + Math.floor( Math.random() * 500 ) ); + } + } + throw lastError; +} + +export async function completeText( opts: CompleteTextOptions ): Promise< string > { + const { client, model } = await getClient(); + const response = await withTransientRetry( () => + client.messages.create( { + model, + max_tokens: opts.maxTokens ?? GENERATION_MAX_TOKENS, + temperature: opts.temperature ?? 0.7, + system: opts.system, + messages: [ { role: 'user', content: opts.user } ], + } ) + ); + + return response.content + .map( ( block ) => ( block.type === 'text' ? block.text : '' ) ) + .join( '' ) + .trim(); +} + +/** + * Run async tasks with a bounded concurrency. The site-generator fans out one + * model call per theme file; a small pool keeps us well under provider rate + * limits while still parallelising the bulk of the work. + */ +export async function runPooled< T >( + tasks: Array< () => Promise< T > >, + concurrency = 4 +): Promise< T[] > { + const results: T[] = new Array( tasks.length ); + let next = 0; + + async function worker(): Promise< void > { + for (;;) { + const index = next++; + if ( index >= tasks.length ) { + return; + } + results[ index ] = await tasks[ index ](); + } + } + + const workerCount = Math.max( 1, Math.min( concurrency, tasks.length ) ); + await Promise.all( Array.from( { length: workerCount }, () => worker() ) ); + return results; +} + +/** + * Models occasionally wrap output in a Markdown code fence despite being told + * not to. Strip a single outer fence so the raw file content is written as-is. + */ +export function stripCodeFences( text: string ): string { + let trimmed = text.trim(); + // Tolerate a closing fence with no preceding newline (e.g. "```json\n{...}```") + // and trailing whitespace, which models emit on the JSON paths. + const full = trimmed.match( /^```[a-zA-Z0-9_-]*\n([\s\S]*?)\n?```$/ ); + if ( full ) { + return full[ 1 ].trim(); + } + // Opening fence with no (or a truncated) closing fence: drop the leading + // ```lang line and any dangling closing fence so the inner content survives. + trimmed = trimmed.replace( /^```[a-zA-Z0-9_-]*\n/, '' ).replace( /\n?```$/, '' ); + return trimmed.trim(); +} + +/** + * Extracts a JSON value (object or array) from model output that may be wrapped + * in a code fence and/or surrounded by stray prose. Slices from the first + * opening bracket to the last matching closing bracket — robust to fences the + * model added or a leading sentence like "Here is the manifest:". + */ +export function extractJson( text: string ): string { + const unfenced = stripCodeFences( text ); + const firstObj = unfenced.indexOf( '{' ); + const firstArr = unfenced.indexOf( '[' ); + if ( firstObj === -1 && firstArr === -1 ) { + return unfenced; + } + let start: number; + let close: string; + if ( firstArr === -1 || ( firstObj !== -1 && firstObj < firstArr ) ) { + start = firstObj; + close = '}'; + } else { + start = firstArr; + close = ']'; + } + const end = unfenced.lastIndexOf( close ); + return end > start ? unfenced.slice( start, end + 1 ) : unfenced; +} + +// Test seam: lets unit tests reset the memoised client between cases. +export function __resetGenerationClientForTests(): void { + cached = null; +} diff --git a/apps/cli/ai/generation/manifest.ts b/apps/cli/ai/generation/manifest.ts new file mode 100644 index 0000000000..d1b6239193 --- /dev/null +++ b/apps/cli/ai/generation/manifest.ts @@ -0,0 +1,239 @@ +import { extractJson } from './llm'; +import { deriveSlug, isValidSlug } from './paths'; + +/** + * The file manifest the `manifest` generator produces from a site spec. It + * drives the whole pipeline: which theme files to generate, whether a + * companion plugin is needed, and what content to seed. + */ + +export type LayoutMode = + | 'vertical-stack' + | 'sidebar-left' + | 'sidebar-right' + | 'dual-sidebar' + | 'landing-page' + | 'magazine-grid' + | 'canvas-floating-chrome'; + +export type ContentMode = 'homepage-and-pages' | 'blog-first' | 'index-only'; + +export interface PagePlan { + slug: string; + title: string; + brief: string; +} + +export interface PostTypeField { + key: string; + type: 'string' | 'number' | 'boolean'; +} + +export interface PostTypePlan { + slug: string; + name: string; + fields: PostTypeField[]; +} + +export interface RestRoutePlan { + path: string; + purpose: string; +} + +export interface BlockPlan { + slug: string; + title: string; + purpose: string; +} + +export interface CompanionPluginPlan { + needed: boolean; + slug: string; + name: string; + postTypes: PostTypePlan[]; + restRoutes: RestRoutePlan[]; + blocks: BlockPlan[]; +} + +export interface SeedItem { + type: string; + slug: string; + title: string; +} + +export interface SiteManifest { + themeSlug: string; + themeName: string; + layoutMode: LayoutMode; + contentMode: ContentMode; + parts: string[]; + templates: string[]; + pages: PagePlan[]; + patterns: string[]; + companionPlugin: CompanionPluginPlan; + seed: SeedItem[]; +} + +const LAYOUT_MODES: LayoutMode[] = [ + 'vertical-stack', + 'sidebar-left', + 'sidebar-right', + 'dual-sidebar', + 'landing-page', + 'magazine-grid', + 'canvas-floating-chrome', +]; + +const CONTENT_MODES: ContentMode[] = [ 'homepage-and-pages', 'blog-first', 'index-only' ]; + +function asString( value: unknown, fallback = '' ): string { + return typeof value === 'string' && value.trim() ? value.trim() : fallback; +} + +function asStringArray( value: unknown ): string[] { + if ( ! Array.isArray( value ) ) { + return []; + } + return value + .filter( ( v ): v is string => typeof v === 'string' && v.trim().length > 0 ) + .map( ( v ) => v.trim() ); +} + +function normalizeSlug( value: unknown, fallback: string ): string { + const candidate = deriveSlug( asString( value, fallback ) ); + return candidate && isValidSlug( candidate ) ? candidate : fallback; +} + +function normalizePages( value: unknown ): PagePlan[] { + if ( ! Array.isArray( value ) ) { + return []; + } + const pages: PagePlan[] = []; + for ( const raw of value ) { + if ( ! raw || typeof raw !== 'object' ) { + continue; + } + const obj = raw as Record< string, unknown >; + const title = asString( obj.title ); + const slug = normalizeSlug( obj.slug ?? title, deriveSlug( title || 'page' ) || 'page' ); + if ( ! title ) { + continue; + } + pages.push( { slug, title, brief: asString( obj.brief ) } ); + } + return pages; +} + +function normalizeCompanionPlugin( value: unknown, themeSlug: string ): CompanionPluginPlan { + const obj = ( value && typeof value === 'object' ? value : {} ) as Record< string, unknown >; + const needed = obj.needed === true; + const slug = normalizeSlug( + obj.slug ?? `${ themeSlug }-functionality`, + `${ themeSlug }-functionality` + ); + + const postTypes: PostTypePlan[] = Array.isArray( obj.postTypes ) + ? ( obj.postTypes as unknown[] ) + .filter( ( p ): p is Record< string, unknown > => !! p && typeof p === 'object' ) + .map( ( p ) => ( { + slug: normalizeSlug( p.slug, deriveSlug( asString( p.name, 'item' ) ) || 'item' ), + name: asString( p.name, 'Item' ), + fields: Array.isArray( p.fields ) + ? ( p.fields as unknown[] ) + .filter( ( f ): f is Record< string, unknown > => !! f && typeof f === 'object' ) + .map( ( f ) => ( { + key: asString( f.key, 'field' ), + type: ( [ 'string', 'number', 'boolean' ].includes( asString( f.type ) ) + ? asString( f.type ) + : 'string' ) as PostTypeField[ 'type' ], + } ) ) + : [], + } ) ) + : []; + + const restRoutes: RestRoutePlan[] = Array.isArray( obj.restRoutes ) + ? ( obj.restRoutes as unknown[] ) + .filter( ( r ): r is Record< string, unknown > => !! r && typeof r === 'object' ) + .map( ( r ) => ( { path: asString( r.path ), purpose: asString( r.purpose ) } ) ) + .filter( ( r ) => r.path ) + : []; + + const blocks: BlockPlan[] = Array.isArray( obj.blocks ) + ? ( obj.blocks as unknown[] ) + .filter( ( b ): b is Record< string, unknown > => !! b && typeof b === 'object' ) + .map( ( b ) => ( { + slug: normalizeSlug( b.slug, deriveSlug( asString( b.title, 'block' ) ) || 'block' ), + title: asString( b.title, 'Block' ), + purpose: asString( b.purpose ), + } ) ) + : []; + + return { + needed, + slug, + name: asString( obj.name, 'Site Functionality' ), + postTypes, + restRoutes, + blocks, + }; +} + +function normalizeSeed( value: unknown ): SeedItem[] { + if ( ! Array.isArray( value ) ) { + return []; + } + return ( value as unknown[] ) + .filter( ( s ): s is Record< string, unknown > => !! s && typeof s === 'object' ) + .map( ( s ) => ( { + type: asString( s.type, 'page' ), + slug: normalizeSlug( s.slug ?? s.title, deriveSlug( asString( s.title, 'item' ) ) || 'item' ), + title: asString( s.title ), + } ) ) + .filter( ( s ) => s.title ); +} + +export function parseManifest( raw: string ): SiteManifest { + let data: Record< string, unknown >; + try { + data = JSON.parse( extractJson( raw ) ) as Record< string, unknown >; + } catch ( error ) { + throw new Error( + `The manifest generator did not return valid JSON: ${ + error instanceof Error ? error.message : String( error ) + }` + ); + } + + const themeName = asString( data.themeName, 'Generated Site' ); + const themeSlug = normalizeSlug( + data.themeSlug ?? themeName, + deriveSlug( themeName ) || 'generated-site' + ); + + const layoutMode = ( + LAYOUT_MODES.includes( asString( data.layoutMode ) as LayoutMode ) + ? asString( data.layoutMode ) + : 'vertical-stack' + ) as LayoutMode; + const contentMode = ( + CONTENT_MODES.includes( asString( data.contentMode ) as ContentMode ) + ? asString( data.contentMode ) + : 'homepage-and-pages' + ) as ContentMode; + + const parts = asStringArray( data.parts ); + const templates = asStringArray( data.templates ); + + return { + themeSlug, + themeName, + layoutMode, + contentMode, + parts: parts.length ? parts : [ 'header', 'footer' ], + templates: templates.length ? templates : [ 'index', 'page' ], + pages: normalizePages( data.pages ), + patterns: asStringArray( data.patterns ), + companionPlugin: normalizeCompanionPlugin( data.companionPlugin, themeSlug ), + seed: normalizeSeed( data.seed ), + }; +} diff --git a/apps/cli/ai/generation/paths.ts b/apps/cli/ai/generation/paths.ts new file mode 100644 index 0000000000..f3c9c66f69 --- /dev/null +++ b/apps/cli/ai/generation/paths.ts @@ -0,0 +1,50 @@ +import { mkdir, writeFile } from 'fs/promises'; +import path from 'path'; + +export function deriveSlug( input: string ): string { + return input + .toLowerCase() + .replace( /[^a-z0-9]+/g, '-' ) + .replace( /^-+|-+$/g, '' ); +} + +export function isValidSlug( slug: string ): boolean { + return /^[a-z0-9][a-z0-9-]*$/.test( slug ); +} + +export function themeDir( sitePath: string, slug: string ): string { + return path.join( sitePath, 'wp-content', 'themes', slug ); +} + +export function pluginDir( sitePath: string, slug: string ): string { + return path.join( sitePath, 'wp-content', 'plugins', slug ); +} + +export function uploadsDir( sitePath: string, subdir = 'wsg' ): string { + return path.join( sitePath, 'wp-content', 'uploads', subdir ); +} + +/** + * Guards every generated write so a model-supplied relative path can never + * escape the package directory (theme/plugin). Returns the resolved absolute + * path on success; throws otherwise. + */ +export function assertInside( baseDir: string, relPath: string ): string { + const base = path.resolve( baseDir ); + const resolved = path.resolve( base, relPath ); + if ( resolved !== base && ! resolved.startsWith( base + path.sep ) ) { + throw new Error( `Refusing to write outside the package directory: ${ relPath }` ); + } + return resolved; +} + +export async function writePackageFile( + baseDir: string, + relPath: string, + content: string +): Promise< string > { + const full = assertInside( baseDir, relPath ); + await mkdir( path.dirname( full ), { recursive: true } ); + await writeFile( full, content, 'utf8' ); + return full; +} diff --git a/apps/cli/ai/generation/site-wp.ts b/apps/cli/ai/generation/site-wp.ts new file mode 100644 index 0000000000..685cbf49e0 --- /dev/null +++ b/apps/cli/ai/generation/site-wp.ts @@ -0,0 +1,37 @@ +import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; +import { isServerRunning, sendWpCliCommand } from 'cli/lib/wordpress-server-manager'; + +/** + * Thin WP-CLI helpers for the generation tools. Unlike the `wp_cli` agent tool + * (which parses a shell-ish string and connects per call), the generation + * pipeline issues many commands with already-split argv, so it connects to the + * daemon once via `withDaemon` and reuses the connection. + */ + +export interface WpCliResult { + exitCode: number; + stdout: string; + stderr: string; +} + +export async function withDaemon< T >( fn: () => Promise< T > ): Promise< T > { + await connectToDaemon(); + try { + return await fn(); + } finally { + await disconnectFromDaemon(); + } +} + +export async function isSiteRunning( siteId: string ): Promise< boolean > { + return Boolean( await isServerRunning( siteId ) ); +} + +export async function wpCli( siteId: string, args: string[] ): Promise< WpCliResult > { + const result = await sendWpCliCommand( siteId, args ); + return { + exitCode: result.exitCode, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + }; +} diff --git a/apps/cli/ai/generation/wpcom-image.ts b/apps/cli/ai/generation/wpcom-image.ts new file mode 100644 index 0000000000..a1e59169a0 --- /dev/null +++ b/apps/cli/ai/generation/wpcom-image.ts @@ -0,0 +1,149 @@ +import { readAuthToken } from '@studio/common/lib/shared-config'; +import { withTransientRetry } from './llm'; + +/** + * AI image generation through the WordPress.com AI API proxy. + * + * Uses the OpenAI images endpoint (`/v1/images/generations`, model + * `gpt-image-1`) under the `studio-assistant` feature slug — the same slug + * Studio already uses for OpenAI text and which is authorized for logged-in + * WordPress.com users. (Telex's `telex-theme-image` Imagen feature returns 403 + * for non-Telex accounts, so it can't be reused here.) Auth reuses the user's + * WordPress.com OAuth token, so a logged-in user gets imagery with no extra + * credentials. + */ + +const DEFAULT_PROXY = 'https://public-api.wordpress.com/wpcom/v2/ai-api-proxy'; +const DEFAULT_IMAGE_MODEL = 'gpt-image-1'; +const DEFAULT_IMAGE_FEATURE = 'studio-assistant'; + +export type ImageAspectRatio = '1:1' | '3:4' | '4:3' | '9:16' | '16:9'; + +// gpt-image-1 only accepts a fixed set of sizes. Map our aspect ratios to the +// nearest supported size (square / landscape / portrait). +function sizeForAspect( aspect: ImageAspectRatio ): string { + switch ( aspect ) { + case '16:9': + case '4:3': + return '1536x1024'; + case '9:16': + case '3:4': + return '1024x1536'; + default: + return '1024x1024'; + } +} + +/** + * Map a free-form hint (an AI_IMAGE aspect token like "16:9", a keyword like + * "hero"/"portrait", or a "1792x1024" dimension string) to a supported aspect + * ratio. + */ +export function aspectFromHint( hint?: string ): ImageAspectRatio { + const h = ( hint ?? '' ).toLowerCase().trim(); + + const dims = h.match( /(\d+)\s*[x×]\s*(\d+)/ ); + if ( dims ) { + const ratio = Number( dims[ 1 ] ) / Number( dims[ 2 ] ); + if ( ratio > 1.6 ) { + return '16:9'; + } + if ( ratio > 1.2 ) { + return '4:3'; + } + if ( ratio < 0.6 ) { + return '9:16'; + } + if ( ratio < 0.85 ) { + return '3:4'; + } + return '1:1'; + } + + if ( /16\s*[:x]\s*9|wide|landscape|hero|banner|cover/.test( h ) ) { + return '16:9'; + } + if ( /4\s*[:x]\s*3/.test( h ) ) { + return '4:3'; + } + if ( /9\s*[:x]\s*16|tall/.test( h ) ) { + return '9:16'; + } + if ( /3\s*[:x]\s*4|portrait/.test( h ) ) { + return '3:4'; + } + if ( /1\s*[:x]\s*1|square|avatar|icon/.test( h ) ) { + return '1:1'; + } + return '16:9'; +} + +async function resolveWpcomToken(): Promise< string > { + const inline = process.env.STUDIO_WPCOM_TOKEN?.trim(); + if ( inline ) { + return inline; + } + const token = await readAuthToken(); + if ( ! token?.accessToken ) { + throw new Error( + 'WordPress.com login required for AI image generation. Run `studio auth login`.' + ); + } + return token.accessToken; +} + +interface OpenAiImageResponse { + data?: Array< { b64_json?: string } >; +} + +export async function generateImageBytes( + prompt: string, + aspectRatio: ImageAspectRatio = '16:9' +): Promise< Buffer > { + const token = await resolveWpcomToken(); + const proxy = ( process.env.WPCOM_AI_PROXY_BASE_URL?.trim() || DEFAULT_PROXY ).replace( + /\/+$/, + '' + ); + const model = process.env.STUDIO_WSG_IMAGE_MODEL?.trim() || DEFAULT_IMAGE_MODEL; + const feature = process.env.STUDIO_WSG_IMAGE_FEATURE?.trim() || DEFAULT_IMAGE_FEATURE; + const endpoint = `${ proxy }/v1/images/generations`; + + // Retry transient proxy hiccups (503/502/504/429). The thrown messages embed + // the HTTP status so isTransientError classifies them correctly. + const base64 = await withTransientRetry( async () => { + const response = await fetch( endpoint, { + method: 'POST', + headers: { + Authorization: `Bearer ${ token }`, + 'X-WPCOM-AI-Feature': feature, + 'content-type': 'application/json', + }, + body: JSON.stringify( { + model, + prompt, + n: 1, + size: sizeForAspect( aspectRatio ), + } ), + } ); + + if ( ! response.ok ) { + const detail = await response.text().catch( () => '' ); + throw new Error( + `WordPress.com image API request failed (HTTP ${ response.status }): ${ detail.slice( + 0, + 300 + ) }` + ); + } + + const data = ( await response.json() ) as OpenAiImageResponse; + const encoded = data.data?.[ 0 ]?.b64_json; + if ( ! encoded ) { + throw new Error( 'WordPress.com image API returned no image data.' ); + } + return encoded; + } ); + + return Buffer.from( base64, 'base64' ); +} diff --git a/apps/cli/ai/skills.ts b/apps/cli/ai/skills.ts index 5325e83833..94e5b3a6ad 100644 --- a/apps/cli/ai/skills.ts +++ b/apps/cli/ai/skills.ts @@ -22,12 +22,23 @@ function parseSkillFile( filePath: string ): Skill | null { let cachedSkills: Skill[] | null = null; +// Absolute path to the bundled skills directory. `import.meta.dirname` resolves +// to `apps/cli/ai` in source/test runs and to the bundle output root after the +// vite build (which copies `ai/skills` to the output root). The whole `skills` +// tree is copied, so subdirectories like `site-generator/generators` ship too — +// the generation pipeline resolves its prompt fragments from here via this +// helper rather than its own `import.meta.dirname`, which would point at the +// wrong place once bundled. +export function getSkillsRoot(): string { + return path.resolve( import.meta.dirname, 'skills' ); +} + // Discovers `apps/cli/ai/skills//SKILL.md` files at startup; cached // for the process lifetime since skills never change at runtime. export function loadSkills(): Skill[] { if ( cachedSkills ) return cachedSkills; - const skillsRoot = path.resolve( import.meta.dirname, 'skills' ); + const skillsRoot = getSkillsRoot(); if ( ! fs.existsSync( skillsRoot ) ) { // Loud warning so a broken bundle path doesn't silently disable Skill. diff --git a/apps/cli/ai/skills/companion-plugin/SKILL.md b/apps/cli/ai/skills/companion-plugin/SKILL.md new file mode 100644 index 0000000000..d600edca57 --- /dev/null +++ b/apps/cli/ai/skills/companion-plugin/SKILL.md @@ -0,0 +1,441 @@ +--- +name: companion-plugin +description: The WordPress companion plugin for generated sites — where all behavior lives: custom post types, taxonomies, post meta, REST routes, and build-less plain-JS Gutenberg blocks. Load before generating any site behavior or custom blocks. +--- + +# Companion Plugin + +A generated WordPress site is always **two packages**: + +1. A **pure presentation theme** — `theme.json`, `style.css`, `templates/`, `parts/`, `patterns/`, `assets/`. It declares fonts, colors, spacing, and layout. Its `functions.php` is minimal: enqueue `style.css` on the front end and `add_editor_style`. Nothing else. No custom post types, no REST routes, no block registration, no content seeding. +2. A **companion plugin** — everything that is *behavior*: custom post types, taxonomies, post meta, REST API routes, and custom Gutenberg blocks. + +This skill is the runbook for the companion plugin. The rule is absolute: **if it is behavior, it goes in the plugin, not the theme.** A theme that registers a CPT or a REST route is a defect. A plugin that ships `theme.json` or templates is a defect. + +You write real files to disk. The theme lives at `/wp-content/themes//`. The plugin lives at `/wp-content/plugins/-functionality/`. Content (pages, posts, CPT entries, sample submissions) is **seeded into the live WordPress database** via WP-CLI / the `seed_content` tool — never baked into either package as content files. You write PHP, JS, and JSON files exactly as a human plugin developer would, and you seed the database directly. + +## When the companion plugin is needed at all + +Not every site needs a companion plugin. Decide before scaffolding: + +- **No plugin** — a purely editorial/marketing site whose every section is core blocks (hero, testimonials, features, pricing, team, FAQ, gallery, CTA). No forms, no custom data, no interactive widgets. Ship only the theme; seed pages/posts into the DB. +- **Plugin required** — the site needs *any* of: + - A **custom post type** beyond `post`/`page` (events, menu items, properties, team members displayed as a structured archive). + - A **form that persists** (contact, booking, reservation, RSVP, review, newsletter, lead capture). + - A **custom Gutenberg block** for project-specific interactivity or data that core blocks plus recommended plugins cannot provide (see [Custom block: warranted or not](#custom-block-warranted-or-not)). + - A **REST route** the front end calls. + +When in doubt, prefer core blocks and skip the plugin. A plugin is overhead the site owner has to maintain; only create one when behavior genuinely lives outside the theme's presentational scope. + +## Plugin file structure + +Lay the plugin out under `/wp-content/plugins/-functionality/`. The directory name and main file share the slug. Pick one canonical prefix for the whole plugin (call it `PREFIX` below — e.g. `acme`) and use it consistently for the text domain, function names, hooks, option keys, REST namespace, and block names. + +``` +-functionality/ + -functionality.php ← main plugin file (header + bootstrap) + inc/ + post-types.php ← register_post_type + register_post_meta + register_taxonomy + rest.php ← register_rest_route handlers + blocks/ + / + block.json ← apiVersion 3 metadata + render.php ← server render (dynamic blocks) + editor.js ← editor component (plain JS, no JSX) + view.js ← front-end behavior (plain JS, no JSX) + style.css ← shared front-end + editor styles + editor.css ← editor-only styles (optional) +``` + +There is **no `src/` and no `build/` directory** and **no `package.json`**. Blocks are build-less: the JS you write is the JS that ships. `block.json` paths point at the plain files you authored, in place. Do not chain `wp-scripts build`; do not invent a compile step. + +### Main file header and bootstrap + +The main file carries the standard plugin header docblock and requires the `inc/` files. Keep it thin. + +```php +' )` points at the directory containing the **authored** `block.json` — there is no build output to point at. WordPress reads `block.json` and resolves `editorScript`, `viewScript`, `style`, `editorStyle`, and `render` paths relative to that directory. + +## Custom post types, taxonomies, and meta + +Register everything on `init` at default priority. Always set `show_in_rest => true` — it powers the block editor and is what lets block bindings and REST reads see the data. + +### CPT kinds + +There are two kinds, with different argument shapes: + +- **Content CPT** (public, displayed on the site — events, products, team, properties): + - `public => true`, `has_archive => ''`, `show_in_rest => true` + - `supports` includes `'title'`, `'editor'`, `'custom-fields'`, `'thumbnail'` + - Entries are **seeded into the DB**, not authored as files. The theme's archive/single templates render the entries. +- **Submission CPT** (admin-only data captured from a front-end form — contacts, bookings, reviews): + - `public => false`, `show_ui => true`, `show_in_menu => true`, `show_in_rest => true` + - `supports` includes `'title'`, `'editor'`, `'custom-fields'` + - Real submissions arrive via a REST route (below). Seed 2-3 plausible historical entries into the DB so the `wp-admin` list is never empty. + +Prefix every CPT slug with the plugin prefix: `acme_event`, `acme_reservation` — never bare `event` or `reservation`. + +### register_post_meta with the right sanitiser + +Every structured field on a CPT (price, allergens, booking date, party size, rating, email, phone) gets a `register_post_meta` call in the same `init` callback, right after `register_post_type`. `show_in_rest => true` is mandatory — without it, the block editor and any `core/post-meta` binding read nothing. + +```php +register_post_meta( 'acme_event', 'event_date', array( + 'type' => 'string', + 'single' => true, + 'show_in_rest' => true, + 'auth_callback' => static function (): bool { return current_user_can( 'edit_posts' ); }, + 'sanitize_callback' => 'sanitize_text_field', +) ); +``` + +**Pick the sanitiser by declared type — and never pass a PHP internal function name as a string callback.** `register_post_meta` wires `sanitize_callback` into the `sanitize_{type}_meta_{key}` filter, which WordPress invokes with **four** arguments (`$value, $key, $object_type, $subtype`). PHP 8 makes extra arguments fatal for internal functions like `floatval`, `intval`, `boolval`, `strval`, `trim`, `strtolower` — the site hard-fatals on `init` the first time the meta is updated. WordPress *userland* functions (`sanitize_text_field`, `absint`, `rest_sanitize_boolean`) silently ignore extra args, so they are safe as bare strings. For anything else, wrap in a single-arg closure. + +| Meta type | `sanitize_callback` | +|-----------|---------------------| +| `string` | `'sanitize_text_field'` | +| `integer` | `'absint'` (non-negative) or `static fn ( $v ) => (int) $v` (signed) | +| `number` | `static fn ( $v ) => (float) $v` — **never** `'floatval'` | +| `boolean` | `'rest_sanitize_boolean'` | + +### Taxonomies + +Register custom taxonomies on `init` as well, attached to the content CPT, with `show_in_rest => true` and `hierarchical` set to match the use (`true` for category-like, `false` for tag-like). + +```php +register_taxonomy( 'acme_event_type', 'acme_event', array( + 'label' => 'Event Types', + 'public' => true, + 'hierarchical' => true, + 'show_in_rest' => true, +) ); +``` + +For ordinary blog posts, use the built-in `category` and `post_tag` — do not invent parallel taxonomies. Create terms when seeding content into the DB; do not write `wp_insert_term` loops in the plugin. + +### Flush rewrite rules once + +Content CPTs add archive URLs, so flush rewrites once, guarded by an option so it does not run on every request: + +```php +if ( ! get_option( 'acme_rewrite_flushed' ) ) { + flush_rewrite_rules(); + update_option( 'acme_rewrite_flushed', 1, '', false ); +} +``` + +## REST routes + +Front-end form submissions need a **custom, namespaced** REST route — the auto-created `/wp/v2/` route requires auth. Register on `rest_api_init`, namespace it `PREFIX/v1`, validate and sanitize **every** field via the `args` schema, and return `WP_Error` (with an HTTP status) on failure. + +```php +add_action( 'rest_api_init', static function (): void { + register_rest_route( 'acme/v1', '/reservation', array( + 'methods' => 'POST', + 'callback' => 'acme_handle_reservation', + 'permission_callback' => '__return_true', // public form; pair with strict args + sanitize + 'args' => array( + 'name' => array( 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field' ), + 'email' => array( 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_email' ), + 'phone' => array( 'type' => 'string', 'required' => false, 'sanitize_callback' => 'sanitize_text_field' ), + 'booking_date' => array( 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field' ), + 'party_size' => array( 'type' => 'integer', 'required' => true, 'sanitize_callback' => 'absint' ), + ), + ) ); +} ); + +function acme_handle_reservation( WP_REST_Request $request ) { + $email = $request->get_param( 'email' ); + if ( ! is_email( $email ) ) { + return new WP_Error( 'invalid_email', 'Please provide a valid email address.', array( 'status' => 400 ) ); + } + + $post_id = wp_insert_post( array( + 'post_type' => 'acme_reservation', + 'post_status' => 'publish', + 'post_title' => $request->get_param( 'name' ) . ' — ' . current_time( 'Y-m-d H:i' ), + 'meta_input' => array( + 'email' => $email, + 'phone' => $request->get_param( 'phone' ), + 'booking_date' => $request->get_param( 'booking_date' ), + 'party_size' => $request->get_param( 'party_size' ), + ), + ), true ); + + if ( is_wp_error( $post_id ) ) { + return new WP_Error( 'insert_failed', 'Could not save your request.', array( 'status' => 500 ) ); + } + + return rest_ensure_response( array( 'ok' => true, 'id' => $post_id ) ); +} +``` + +Rules that make a route production-safe: + +- The **form field keys, the `register_post_meta` keys, the REST `args` keys, and the JSON the block POSTs MUST all match exactly.** Drift produces `Missing parameter(s): …` errors at runtime. One field schema, used in four places. +- Pick the REST `sanitize_callback` from the field's intent: `text`/`tel`/`date`/`time` → `sanitize_text_field`; `email` → `sanitize_email`; `textarea` → `sanitize_textarea_field`; `url` → `esc_url_raw`; `integer` → `absint`; `number` → `static fn ( $v ) => (float) $v`; `boolean` → `rest_sanitize_boolean`. +- `permission_callback => '__return_true'` is acceptable for a *public* unauthenticated form, but only when paired with strict `args` validation. For any write that should require a logged-in user, verify the REST nonce and a capability in the same callback, returning `WP_Error` (never bare `false`, which WordPress turns into a message-less 403): + +```php +'permission_callback' => static function ( WP_REST_Request $request ): bool|WP_Error { + if ( ! wp_verify_nonce( $request->get_header( 'X-WP-Nonce' ), 'wp_rest' ) ) { + return new WP_Error( 'rest_forbidden', 'Nonce verification failed.', array( 'status' => 401 ) ); + } + if ( ! current_user_can( 'edit_posts' ) ) { + return new WP_Error( 'rest_forbidden', 'You cannot do this.', array( 'status' => 403 ) ); + } + return true; +}, +``` + +- Never leak the raw post array in the response — return a redacted, canonical shape. +- For high-abuse public endpoints, add a honeypot field and/or a transient rate-limit keyed by IP. + +## Custom block: warranted or not + +Before authoring a block, decide whether it is warranted. The distinction is **content sections** (core blocks) vs **named features** (custom blocks). + +**Use core blocks — no custom block** when the request is a content section, even an elaborate one. Compose existing blocks in templates/patterns: + +| Request | Core composition | +|---|---| +| Hero | `cover` + heading + paragraph + buttons | +| Testimonials | `columns` > `column` > `group` + `quote` + `image` | +| Features grid | `columns` > `column` > `group` + `image` + `heading` + `paragraph` | +| Pricing | `columns` > `column` > `group` + `heading` + `list` + `buttons` | +| FAQ | repeated `details` blocks | +| Latest posts | `query` + `post-template` + `post-title` + `post-excerpt` | +| Gallery / portfolio | `gallery` or `columns` of `image` | + +**Build a custom block** only for a *named, project-specific feature* that core blocks plus reasonable recommended plugins cannot provide: + +- It **saves, fetches, or computes** data (a form that persists, a price calculator, an availability checker, a live filter over a CPT archive). +- It is a discrete interactive widget core does not ship (countdown, before/after slider, configurator, map picker, quiz). +- Editors should insert and configure it as a distinct reusable block with its own controls. + +Never fake a block with a raw `core/html` block, a freehand `
`, or a `render.php`-only fragment with no `block.json`. If it is a section, compose core blocks. If it is a feature, write a real registered block. Never split the difference. + +## The build-less plain-JS block recipe + +Custom blocks here are **build-less plain JavaScript**: a `block.json`, a plain `editor.js` and/or `view.js` that call WordPress globals directly, registered server-side with `register_block_type`. No JSX. No `@wordpress/scripts`. No npm. No `import`. No bundler. The editor component uses `wp.element.createElement` (aliased to `el`) instead of JSX, and reads APIs off the `wp.*` globals (`wp.blocks`, `wp.element`, `wp.blockEditor`, `wp.components`, `wp.i18n`). Front-end interactivity uses plain DOM APIs (`querySelector`, `addEventListener`, `classList`, `dataset`) — **never the Interactivity API**, never `data-wp-*` directives, never `viewScriptModule`. + +### block.json (apiVersion 3) + +```json +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "acme/reservation-form", + "title": "Reservation Form", + "category": "widgets", + "description": "A reservation request form that saves submissions to the site.", + "textdomain": "acme", + "supports": { "html": false }, + "editorScript": "file:./editor.js", + "viewScript": "file:./view.js", + "style": "file:./style.css", + "editorStyle": "file:./editor.css", + "render": "file:./render.php" +} +``` + +- `apiVersion: 3` always — it gives the iframed editor canvas that matches the front-end render context. +- Paths are `file:./` resolving to siblings of `block.json` in the block directory — the files you authored, no build indirection. +- `editorScript` is loaded only in the editor; `viewScript` only on the front end; `style` in both; `editorStyle` only in the editor. +- Declare `render` only when there is a `render.php`. Declare `viewScript` only when there is a `view.js`. Never declare an asset whose file you did not write — a declared-but-missing file is a hard error. +- A dynamic block (server-rendered via `render.php`) needs **no `save`** at registration time. A static block needs a `save` and no `render`. Form/data blocks are almost always dynamic so the REST URL and nonce can be printed server-side. + +### editor.js (plain JS, no JSX) + +`registerBlockType` is called against the metadata `name`. The `edit` function returns elements built with `wp.element.createElement`. There is no `import`; everything comes off `wp.*`. + +```js +( function ( blocks, element, blockEditor, i18n ) { + var el = element.createElement; + var useBlockProps = blockEditor.useBlockProps; + var __ = i18n.__; + + blocks.registerBlockType( 'acme/reservation-form', { + edit: function () { + var blockProps = useBlockProps( { className: 'acme-reservation-form' } ); + return el( + 'div', + blockProps, + el( 'p', { className: 'acme-reservation-form__editor-note' }, + __( 'Reservation form — renders on the front end.', 'acme' ) ) + ); + }, + // Dynamic block: server-rendered, so no front-end markup is saved. + save: function () { + return null; + } + } ); +} )( window.wp.blocks, window.wp.element, window.wp.blockEditor, window.wp.i18n ); +``` + +Notes: + +- `useBlockProps` (and `useBlockProps.save()` for static blocks) keeps the editor markup wrapped in standard Gutenberg classes. +- For configurable blocks, read `wp.blockEditor.InspectorControls` and `wp.components.*` (e.g. `PanelBody`, `TextControl`) off the globals and compose them with `el(...)` — still no JSX. +- The plugin must enqueue these `wp.*` packages as script dependencies. With build-less blocks, list them in `block.json` is not enough for globals; the simplest reliable path is to declare the editor script's dependencies in PHP via an asset file or by enqueuing with an explicit deps array. The pragmatic recipe: keep `editorScript` in `block.json` and additionally register the dependency array from PHP so `wp-blocks`, `wp-element`, `wp-block-editor`, `wp-components`, and `wp-i18n` are present: + +```php +add_action( 'init', static function (): void { + wp_register_script( + 'acme-reservation-form-editor', + plugins_url( 'blocks/reservation-form/editor.js', __FILE__ ), + array( 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-i18n' ), + '1.0.0', + true + ); +} ); +``` + +When you register the editor script explicitly like this, omit `editorScript` from `block.json` and instead pass the handle so `register_block_type` reuses it, or keep `block.json`'s `editorScript` and let WordPress load it without an asset file (acceptable because the script only references already-loaded `wp.*` globals). Prefer the explicit `wp_register_script` form whenever the editor component touches `wp.components` or `wp.blockEditor`, so the dependency graph is correct. + +### render.php (server render for dynamic blocks) + +Print the REST endpoint and a nonce into data attributes so `view.js` can read them. Escape every output. + +```php + +
'acme-reservation-form' ) ); ?> + data-endpoint="" + data-nonce="" +> + + + + + +

+
+``` + +Map the input element from the field's type: `text`→`text`, `email`→`email`, `textarea`→` + +

+ +``` + +**`editor.js`** — plain JS, no JSX. Register the block with `wp.blocks.registerBlockType` using `wp.element.createElement`. This example deliberately uses **only** `wp.blocks` and `wp.element` — both are always present in the editor before any block editorScript runs, so a build-less `editorScript: "file:./editor.js"` works with no dependency declaration. The editor representation is a static preview; the live `render.php` runs on the front end. + +```js +( function ( blocks, element ) { + var el = element.createElement; + + blocks.registerBlockType( 'acme/contact-form', { + edit: function () { + return el( + 'form', + { className: 'acme-contact-form' }, + el( 'label', null, 'Name', el( 'input', { type: 'text', disabled: true } ) ), + el( 'label', null, 'Email', el( 'input', { type: 'email', disabled: true } ) ), + el( 'label', null, 'Message', el( 'textarea', { disabled: true } ) ), + el( 'button', { type: 'button', disabled: true }, 'Send' ) + ); + }, + save: function () { + // Dynamic block: rendered by render.php on the server. + return null; + }, + } ); +} )( window.wp.blocks, window.wp.element ); +``` + +> If an editor component needs `wp.blockEditor` (e.g. `useBlockProps`, `InspectorControls`) or `wp.components`, those globals are **not** guaranteed for a bare `file:` script — register the editor script in PHP with an explicit dependency array (`wp-blocks`, `wp-element`, `wp-block-editor`, `wp-components`, `wp-i18n`) per the recipe in the `companion-plugin` skill. + +**`view.js`** — plain JS front-end behavior. Intercept submit, POST JSON to the REST route, show success and error states. Standard DOM APIs only — no Interactivity API. + +```js +document.querySelectorAll( '.acme-contact-form' ).forEach( function ( form ) { + form.addEventListener( 'submit', function ( event ) { + event.preventDefault(); + var status = form.querySelector( '.acme-contact-form__status' ); + var body = {}; + new FormData( form ).forEach( function ( value, key ) { + body[ key ] = value; + } ); + status.textContent = 'Sending…'; + fetch( form.dataset.endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': form.dataset.nonce, + }, + body: JSON.stringify( body ), + } ) + .then( function ( response ) { + return response.json().then( function ( data ) { + if ( ! response.ok ) { + throw new Error( data && data.message ? data.message : 'Submission failed.' ); + } + return data; + } ); + } ) + .then( function () { + form.reset(); + status.textContent = 'Thanks! We will be in touch.'; + } ) + .catch( function ( error ) { + status.textContent = error.message; + } ); + } ); +} ); +``` + +The `X-WP-Nonce` header ties the request into WordPress's standard REST request handling (and, for a logged-in user, identifies the current user; for an anonymous visitor the nonce is generated for user 0 and is not by itself authenticating). The route still allows anonymous submissions (`permission_callback => '__return_true'`), but sending the nonce keeps the request on the normal REST path and lets you tighten the permission callback later without changing the block. + +### 4. Register the block server-side + +Register from the block directory in `includes/blocks.php`. Because `block.json` names `view.js`/`editor.js` as files, WordPress enqueues them automatically — there is no build directory. + +```php +' \ + --path= +``` + +### 6. Seed sample submissions into the live DB + +A submission CPT with an empty `wp-admin` list reads as broken — the owner opens "Contacts," sees nothing, and the data model, meta fields, and workflow are invisible. Seed **2–3 fictional submissions into the live database** so the admin experience is tangible immediately. Seeding bypasses the REST route (it runs trusted, server-side) and is done with WP-CLI, not from theme code. + +Rules: + +- Seed with WP-CLI against the running site, not from `init` (which runs every request) and not from a theme activation hook. +- **Idempotent by meta.** Guard each insert with a meta key unique to the seed (e.g. `_acme_seed_id => 'contact-1'`), not by title — titles collide with real submissions. Re-running the seed must not duplicate. +- Fill **every** `register_post_meta` field with realistic values so the data shape is visible. +- Use varied `post_date` values across the last 30 days — submissions all dated "today" look fake. +- Submissions are **fictional**: invented names, `@example.com` emails, invented phone numbers. Never real brands or real people. + +A small seeding script, run with `wp eval-file --path=`: + +```php + 'contact-1', + 'name' => 'Jordan Lee', + 'email' => 'jordan@example.com', + 'phone' => '+1 555 0100', + 'message' => 'We are planning a launch event in the autumn and would love to discuss catering for around 40 guests.', + 'days_ago' => 2, + ), + array( + 'seed_id' => 'contact-2', + 'name' => 'Priya Rao', + 'email' => 'priya@example.com', + 'phone' => '+44 20 7946 0000', + 'message' => 'Quick question about your tasting menu — can you accommodate a nut allergy?', + 'days_ago' => 6, + ), + array( + 'seed_id' => 'contact-3', + 'name' => 'Marcus Chen', + 'email' => 'marcus@example.com', + 'phone' => '+1 555 0142', + 'message' => 'Looking to book the private dining room for a work offsite in November. Availability?', + 'days_ago' => 14, + ), +); + +foreach ( $samples as $s ) { + $existing = get_posts( array( + 'post_type' => 'acme_contact', + 'post_status' => 'any', + 'meta_key' => '_acme_seed_id', + 'meta_value' => $s['seed_id'], + 'posts_per_page' => 1, + 'fields' => 'ids', + ) ); + if ( $existing ) { + continue; + } + $when = strtotime( '-' . $s['days_ago'] . ' days' ); + wp_insert_post( array( + 'post_type' => 'acme_contact', + 'post_status' => 'publish', + 'post_title' => $s['name'] . ' — ' . wp_date( 'Y-m-d H:i', $when ), + 'post_content' => $s['message'], + 'post_date' => wp_date( 'Y-m-d H:i:s', $when ), + 'meta_input' => array( + 'email' => $s['email'], + 'phone' => $s['phone'], + '_acme_seed_id' => $s['seed_id'], + ), + ) ); +} +``` + +The companion plugin must be active before seeding so the CPT and its meta are registered. Apply the same shape to `_booking`, `_rsvp`, `_review`, `_subscriber`, etc. + +## Shape of the data for common features + +| Feature | CPT slug | Key meta fields | +|---|---|---| +| Contact form | `acme_contact` | `email`, `phone` | +| Booking / reservation | `acme_booking` | `booking_date`, `booking_time`, `party_size`, `email`, `phone` | +| RSVP | `acme_rsvp` | `event_id`, `attending`, `guests`, `email` | +| Review / testimonial | `acme_review` | `rating`, `reviewer_name`, `approved` | +| Newsletter signup | `acme_subscriber` | `email`, `source`, `consent_at` | + +For each, wire the same three parts in the companion plugin: CPT with `show_in_rest => true`, a build-less plain-JS form block, and a namespaced REST `POST` route with a validated `args` schema. Then seed 2–3 fictional rows into the live DB. + +## REST endpoint depth + +The pattern above gets data flowing. These rules make the endpoint production-safe. + +### Permission callbacks beyond public + +A public submission form uses `__return_true` plus a strict `args` schema. Any endpoint that reads or mutates existing data must check a real capability and verify the nonce: + +```php +'permission_callback' => function ( WP_REST_Request $request ) { + if ( ! wp_verify_nonce( $request->get_header( 'X-WP-Nonce' ), 'wp_rest' ) ) { + return new WP_Error( 'rest_forbidden', 'Nonce verification failed.', array( 'status' => 401 ) ); + } + if ( ! current_user_can( 'edit_posts' ) ) { + return new WP_Error( 'rest_forbidden', 'You cannot do this.', array( 'status' => 403 ) ); + } + return true; +}, +``` + +### Expose CPT meta as first-class REST fields + +To read or update meta through REST with a schema and an `enum`: + +```php +add_action( 'rest_api_init', function () { + register_rest_field( 'acme_contact', 'status', array( + 'get_callback' => function ( $object ) { + return get_post_meta( $object['id'], '_acme_status', true ) ?: 'new'; + }, + 'update_callback' => function ( $value, $object ) { + return (bool) update_post_meta( $object->ID, '_acme_status', sanitize_text_field( $value ) ); + }, + 'schema' => array( + 'type' => 'string', + 'enum' => array( 'new', 'reviewed', 'archived' ), + 'context' => array( 'view', 'edit' ), + ), + ) ); +} ); +``` + +### Status-code-correct error responses + +Use the standard `rest_*` codes; clients and the block editor recognize them. + +```php +return new WP_Error( 'rest_invalid_param', 'Invalid email address.', array( 'status' => 400 ) ); +return new WP_Error( 'rest_forbidden', 'You cannot do this.', array( 'status' => 403 ) ); +return new WP_Error( 'rest_not_found', 'Submission not found.', array( 'status' => 404 ) ); +``` + +### Do not leak drafts or private posts + +A custom read endpoint using `get_posts()` must honor visibility: + +```php +$args = array( + 'post_type' => 'acme_contact', + 'post_status' => current_user_can( 'edit_others_posts' ) + ? array( 'publish', 'draft', 'private' ) + : 'publish', +); +``` + +## Checklist + +Before treating a data-persistence feature as complete, verify all of it lives in the companion plugin and: + +- [ ] CPT registered with a plugin-prefixed slug and `show_in_rest => true`, visible in `wp-admin`. +- [ ] Structured fields registered via `register_post_meta` with `show_in_rest => true`. +- [ ] Custom REST route is namespaced (`acme/v1/...`) and validates and sanitizes every field via an `args` schema. +- [ ] Public submit route uses `__return_true` only because the `args` schema is strict; non-public routes check capability and nonce. +- [ ] The form UI is a real registered block — build-less plain JS (`registerBlockType` via `wp.element.createElement`, no JSX, no build), not `core/html`, not raw HTML. +- [ ] Custom class only on the outer wrapper via `get_block_wrapper_attributes`; no custom classNames on inner DOM. No emojis. No decorative HTML comments. +- [ ] `view.js` uses standard DOM APIs and `fetch` (never the Interactivity API), POSTs JSON with `Content-Type: application/json` and an `X-WP-Nonce` header. +- [ ] Success and error states are shown to the visitor via an `aria-live` region. +- [ ] Block registered server-side with `register_block_type` pointing at the block directory. +- [ ] If the feature has a dedicated page, the page is created in the live DB via WP-CLI with the block delimiter inline — not as a theme `*.html` file. +- [ ] 2–3 fictional submissions seeded into the live DB via WP-CLI, idempotent by a `_acme_seed_id` meta key, covering every meta field with realistic values and varied `post_date`s. + +## Related skills + +- `companion-plugin` — the plugin scaffold, bootstrap, and how to split behavior across `includes/`; this is where the CPT, REST route, and block files belong. +- `wp-best-practices` — sanitization and escaping, nonce and capability checks, REST schema conventions, and build-less plain-JS block conventions. diff --git a/apps/cli/ai/skills/layout-patterns/SKILL.md b/apps/cli/ai/skills/layout-patterns/SKILL.md new file mode 100644 index 0000000000..cb4fa3a604 --- /dev/null +++ b/apps/cli/ai/skills/layout-patterns/SKILL.md @@ -0,0 +1,627 @@ +--- +name: layout-patterns +description: Reusable WordPress block-theme layout and motion patterns — fixed sidebar, sticky elements, scrollytelling, magazine grid, landing page, floating chrome, and query-loop layouts, with the CSS gotchas that make them work. +--- + +# Layout patterns for WordPress block themes + +Concrete, copy-pasteable layout and motion patterns for the generated **presentation theme**. Every pattern here lives in the theme package — `theme.json`, `style.css`, `templates/*.html`, `parts/*.html`, and (for motion) a plain-JS file in `assets/` enqueued from `functions.php`. None of it lives in the companion plugin; the plugin handles CPTs, REST, and custom blocks, not layout. + +Where you write the files: + +- Theme: `/wp-content/themes//` — `theme.json`, `style.css`, `templates/`, `parts/`, `assets/`, and a minimal `functions.php` (front-end `style.css` enqueue + `add_editor_style` + any motion-JS enqueue + the `body_class` filters below). NOTHING else in `functions.php`. +- Motion JS: `/wp-content/themes//assets/.js`. You write these files yourself (the companion is build-less plain JS too, but these are theme front-end scripts). No bundler, no npm, no Interactivity API — plain DOM APIs only. + +Universal rules that apply to every pattern below: + +- **Custom classNames go ONLY on the outermost block wrapper** via the block `className` attribute. Never hand-author classNames onto inner DOM. CSS hooks key off the wrapper class plus WordPress's generated structure. +- **Full-bleed sections** = an outer `wp:group` with `"align":"full"`. Do not fake full-bleed with negative margins. +- **Any block that sets `backgroundColor` MUST also set `textColor`** (and `wp:navigation` needs the full color set — see the navigation note). A background without a paired text color is the #1 cause of invisible text. +- **Sticky goes on the `.wp-block-template-part` wrapper, not the inner group.** See "Sticky positioning" — this is the single most common motion bug. +- **Scroll animations are progressive enhancement**: CSS defines the FINAL visible state; JS adds the initial hidden state. If JS never runs, content is visible. Every animation is wrapped in `@media (prefers-reduced-motion: no-preference)` (or guarded in JS). +- **No emojis. No decorative HTML comments** — only block delimiter comments (``). + +--- + +## 1. Sticky positioning (read this before any sticky CSS) + +### Why the obvious approach fails + +The natural impulse — `position: sticky` on the inner `wp:group` of the header part — fails on every block theme. `` renders: + +```html +
+
...
+
+``` + +The inner group is the only child of `
`, so `
` is exactly as tall as the group. `position: sticky` un-sticks when the parent's bottom edge passes the offset — and that happens after **zero pixels of scroll**. Technically correct, visibly broken. + +### The fix: target the template-part wrapper + +`
` is a direct child of `.wp-site-blocks` (page-height). Stick the wrapper, not the inner group: + +```css +.wp-site-blocks > header.wp-block-template-part { + position: sticky; + top: 0; + z-index: 100; +} +``` + +Now the containing block is `.wp-site-blocks` (full page height) and the header sticks for the whole scroll. + +### Shrink-on-scroll (JS toggles a class on the INNER group) + +Sticky lives on the wrapper; visual transitions live on the inner group, where JS toggles `.is-shrunk`: + +```css +.my-header-class { + transition: padding 0.3s ease, box-shadow 0.3s ease; +} +.my-header-class.is-shrunk { + padding-top: var(--wp--preset--spacing--20) !important; + padding-bottom: var(--wp--preset--spacing--20) !important; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4); +} +.my-header-class.is-shrunk .wp-block-site-title { font-size: 1.15rem !important; } +``` + +`assets/header-scroll.js` (write it, enqueue it in footer from `functions.php`): + +```js +document.addEventListener('DOMContentLoaded', function () { + var inner = document.querySelector('.my-header-class'); + if (!inner) return; + var threshold = 60, shrunk = false; + function onScroll() { + var scrolled = window.scrollY > threshold; + if (scrolled === shrunk) return; + shrunk = scrolled; + inner.classList.toggle('is-shrunk', shrunk); + } + window.addEventListener('scroll', onScroll, { passive: true }); + onScroll(); +}); +``` + +### Overflow ancestors silently kill sticky + +`position: sticky` fails if ANY ancestor between the sticky element and the viewport has `overflow: hidden | auto | scroll | clip`. Common traps: `body { overflow-x: hidden }` (added to tame full-bleed), `.wp-site-blocks { overflow: clip }`, or a wrapper group with overflow set to clip a pseudo-element. If sticky mysteriously fails after you've targeted the right element, audit ancestor `overflow` in DevTools and force: + +```css +:root, body, .wp-site-blocks { overflow: visible; } +``` + +### Sticky on anything else + +Same principle: the element's **containing block must be much taller than the element**. Sidebar TOC → stick the column whose parent is the tall main column. CTA bar at page root → stick its group (parent is `.wp-site-blocks`). In-section heading → stick it inside its tall section. Ask: "is the parent significantly taller than this element?" If not, move the sticky declaration up a level. + +--- + +## 2. Fixed sidebar layouts (one or two pinned columns) + +For sidebar-left, sidebar-right, or dual-sidebar shells. The obvious approach (`body { display: flex }`, `position: fixed` sidebar, `margin-left` on main) breaks WordPress's root-padding-aware alignments and detaches sidebar height from content height. Use **CSS Grid on `.wp-site-blocks` + `position: sticky` on the sidebar grid item** instead. + +### Single sidebar — style.css + +```css +.wp-site-blocks { + display: grid; + grid-template-columns: var(--wp--custom--sidebar-width, 280px) 1fr; + min-height: 100vh; +} +.wp-site-blocks.has-sidebar-right { + grid-template-columns: 1fr var(--wp--custom--sidebar-width, 280px); +} + +.wp-block-template-part.site-sidebar { + grid-column: 1; + grid-row: 1; /* required — see auto-placement gotcha */ + position: sticky; + top: 0; + align-self: start; /* without this the item stretches and sticky never engages */ + height: 100vh; + overflow-y: auto; + border-right: 1px solid var(--wp--preset--color--rule); +} +.wp-site-blocks.has-sidebar-right .wp-block-template-part.site-sidebar { + grid-column: 2; + border-right: 0; + border-left: 1px solid var(--wp--preset--color--rule); +} + +.main-content-area { + grid-column: 2; + grid-row: 1; + min-width: 0; /* lets long words/URLs wrap instead of overflowing the grid item */ +} +.wp-site-blocks.has-sidebar-right .main-content-area { grid-column: 1; } + +/* align:full inside main must clamp to the main column, not punch across the sidebar */ +.main-content-area .alignfull { + margin-left: 0; margin-right: 0; width: 100%; max-width: 100%; +} + +@media (max-width: 782px) { + .wp-site-blocks, + .wp-site-blocks.has-sidebar-right { grid-template-columns: 1fr; } + .wp-block-template-part.site-sidebar { + position: static; height: auto; overflow: visible; + border-right: 0; border-bottom: 1px solid var(--wp--preset--color--rule); + grid-column: 1; + } + .main-content-area { grid-column: 1; } + .main-content-area .alignfull { + margin-left: calc(var(--wp--style--root--padding-left, 0px) * -1); + margin-right: calc(var(--wp--style--root--padding-right, 0px) * -1); + width: auto; max-width: none; + } +} +``` + +### Auto-placement gotcha — both items need `grid-row: 1` + +Without an explicit `grid-row`, CSS Grid auto-places left-to-right. For `sidebar-right` the sidebar lands in (row 1, col 2), the cursor wraps to row 2, and the main column gets pushed down by a full `100vh` — a phantom empty viewport above the header. Setting `grid-row: 1` on **both** items makes placement deterministic in both modes. Do not use `grid-auto-flow: dense` instead — it reorders later items unpredictably. + +### theme.json (single sidebar) + +```json +{ + "settings": { "custom": { "sidebarWidth": "280px" } }, + "templateParts": [ + { "name": "header", "title": "Header", "area": "header" }, + { "name": "sidebar", "title": "Sidebar", "area": "uncategorized" }, + { "name": "footer", "title": "Footer", "area": "footer" } + ] +} +``` + +Width 240–320px; below 220px nav labels truncate, above 360px main stops feeling like the focus. + +### Template shell (single sidebar) — EXACTLY two top-level children + +Every template (`index.html`, `page.html`, `single.html`, archives, CPT singles) uses this shape: + +```html + + + +
+ + + +
+ +``` + +- `className="site-sidebar"` on **every** sidebar reference. Forget it in one template and that page renders the sidebar in-flow while others pin it. +- Header + footer render **inside** `.main-content-area` (they scroll with main, not next to the sidebar). +- For `sidebar-right`, add `has-sidebar-right` to `.wp-site-blocks`/`` via the `body_class` filter in `functions.php`. + +### parts/sidebar.html + +A single `wp:group` with `layout.type:"flex"`, `orientation:"vertical"`, `justifyContent:"space-between"`: wordmark (`wp:site-title`) at the top, vertical `wp:navigation` (with `wp:page-list`, `orientation:"vertical"`) below, an optional contact strip, and a small footer credit pinned to the bottom. **Vertical padding lives on this inner group, never on the `.site-sidebar` wrapper** — the wrapper is a grid item and padding it clashes with column sizing. + +Under sidebar mode the header carries NO primary nav (the sidebar does). Either omit `parts/header.html` entirely (preferred for minimalist designs) or make it a thin utility top-bar (search/account/cart, 36–48px). Never the full site-title + nav strip. + +### Dual sidebar (three columns) + +Same pattern with a third pinned rail for per-page wayfinding (TOC, metadata, related links): + +```css +.wp-site-blocks { + display: grid; + grid-template-columns: + var(--wp--custom--sidebar-width, 260px) 1fr var(--wp--custom--right-sidebar-width, 240px); + min-height: 100vh; +} +.wp-block-template-part.site-sidebar, +.wp-block-template-part.site-right-sidebar { + grid-row: 1; position: sticky; top: 0; align-self: start; + height: 100vh; overflow-y: auto; +} +.wp-block-template-part.site-sidebar { grid-column: 1; border-right: 1px solid var(--wp--preset--color--rule); } +.wp-block-template-part.site-right-sidebar { grid-column: 3; border-left: 1px solid var(--wp--preset--color--rule); } +.main-content-area { grid-column: 2; grid-row: 1; min-width: 0; } +.main-content-area .alignfull, +.main-content-area .alignwide { margin-left: 0; margin-right: 0; width: 100%; max-width: 100%; } + +@media (max-width: 1024px) { /* drop the auxiliary right rail first */ + .wp-site-blocks { grid-template-columns: var(--wp--custom--sidebar-width, 260px) 1fr; } + .wp-block-template-part.site-right-sidebar { display: none; } +} +@media (max-width: 782px) { /* then stack everything */ + .wp-site-blocks { grid-template-columns: 1fr; } + .wp-block-template-part.site-sidebar, + .wp-block-template-part.site-right-sidebar { + position: static; height: auto; overflow: visible; border: 0; grid-column: 1; + } + .wp-block-template-part.site-right-sidebar { display: block; } + .main-content-area { grid-column: 1; } +} +``` + +theme.json adds `rightSidebarWidth` (220–260px, a hair smaller than the left so weight reads left→center→right), a `right-sidebar` template part, and tightens `settings.layout.contentSize` to ~720px (documentation reading columns are narrow). The template emits **exactly three** top-level children: sidebar, `.main-content-area`, right-sidebar — both with their respective `className`. Right-rail typography is chrome-scale (`small` or smaller) throughout. + +--- + +## 3. Landing page (one-pager with anchor nav) + +Single scrollable page: sticky header with anchor-linked nav, then 3–6 stacked viewport-height sections. The trap: a sticky header makes anchor jumps land the section's top edge BEHIND the header, hiding its heading. Fix with `scroll-padding-top`. + +```css +.wp-site-blocks > header.wp-block-template-part { + position: sticky; top: 0; z-index: 50; + backdrop-filter: blur(8px); + background: var(--wp--preset--color--background); +} +html { + scroll-behavior: smooth; + scroll-padding-top: var(--wp--custom--scroll-padding-top, 80px); +} +.wp-site-blocks .alignfull[id] { + min-height: 100vh; display: flex; flex-direction: column; justify-content: center; +} +.wp-site-blocks .alignfull[id="signup"] { /* short final form shouldn't force empty scroll */ + min-height: auto; + padding-top: var(--wp--preset--spacing--80); + padding-bottom: var(--wp--preset--spacing--80); +} +.wp-site-blocks .alignfull[id]:target { + box-shadow: inset 4px 0 0 var(--wp--preset--color--accent); + transition: box-shadow 200ms ease-out; +} +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + .wp-site-blocks .alignfull[id]:target { box-shadow: none; transition: none; } +} +``` + +theme.json: `settings.custom.scrollPaddingTop` (64–96px, matches rendered header height), `contentSize` ~800px. + +Section ids belong on the page-body section groups, via the `anchor` block attribute (which becomes the rendered `id`) — NEVER a raw `
`, the editor strips it: + +```html + +
+ ... +
+ +``` + +Navigation uses hand-authored `wp:navigation-link` with `kind:"custom"` and hash URLs (this is the one place hand-authored links are correct, vs the usual `wp:page-list`): + +```html + + +``` + +Section ids MUST match the nav hashes exactly (case-sensitive) — a typo breaks the click silently. Final nav item is usually a CTA `wp:button` anchored to `#signup`. + +--- + +## 4. Magazine grid (the homepage IS the archive) + +Editorial themes: thin masthead, a lead story at high weight, then a uniform 3-column card grid. A single query loop can't make "the first item bigger," so use **two `wp:query` blocks**: first `perPage:1` (lead), second `perPage:6` with `offset:1` (grid). CSS keys off two class hooks: `is-style-lead-story` and `is-style-loop-magazine`. + +```css +.wp-site-blocks > header.wp-block-template-part { border-bottom: 1px solid var(--wp--preset--color--rule); } +.wp-site-blocks > header.wp-block-template-part > .wp-block-group { + display: flex; align-items: center; justify-content: space-between; + padding-top: var(--wp--preset--spacing--30); padding-bottom: var(--wp--preset--spacing--30); +} + +.wp-block-query.is-style-lead-story .wp-block-post-template { display: block; } +.wp-block-query.is-style-lead-story .wp-block-post-featured-image { aspect-ratio: 16 / 9; margin-bottom: var(--wp--preset--spacing--40); } +.wp-block-query.is-style-lead-story .wp-block-post-featured-image img { width: 100%; height: 100%; object-fit: cover; } +.wp-block-query.is-style-lead-story .wp-block-post-title { font-size: clamp(2rem, 4vw, 3rem); line-height: 1.1; } + +.wp-block-query.is-style-loop-magazine .wp-block-post-template { + display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--wp--preset--spacing--50); +} +.wp-block-query.is-style-loop-magazine .wp-block-post-template > li { display: flex; flex-direction: column; } +.wp-block-query.is-style-loop-magazine .wp-block-post-featured-image { aspect-ratio: 4 / 3; margin-bottom: var(--wp--preset--spacing--30); } +.wp-block-query.is-style-loop-magazine .wp-block-post-date { margin-top: auto; } /* pins byline to card bottom */ + +@media (max-width: 960px) { .wp-block-query.is-style-loop-magazine .wp-block-post-template { grid-template-columns: repeat(2, 1fr); } } +@media (max-width: 600px) { .wp-block-query.is-style-loop-magazine .wp-block-post-template { grid-template-columns: 1fr; } } + +.wp-block-query.is-style-lead-story + .wp-block-query.is-style-loop-magazine { + margin-top: var(--wp--preset--spacing--80); + padding-top: var(--wp--preset--spacing--60); + border-top: 1px solid var(--wp--preset--color--rule); +} +``` + +theme.json must set `core/post-template.spacing.blockGap` (without it the grid items collapse) and tighten `contentSize` to ~720px: + +```json +{ + "settings": { "layout": { "contentSize": "720px", "wideSize": "1280px" } }, + "styles": { "blocks": { + "core/post-template": { "spacing": { "blockGap": "var:preset|spacing|50" } }, + "core/query": { "spacing": { "blockGap": "var:preset|spacing|60" } } + } } +} +``` + +The home template opens with the two query blocks — NEVER `wp:post-content`. Bylines render in a mono font, uppercase, letter-spaced. Homepage usually doesn't paginate ("see all" links to category archives); if needed add `wp:query-pagination` inside the second query, styled small/uppercase/mono — never large pill buttons. + +--- + +## 5. Floating chrome / canvas (imagery owns the viewport) + +Photography portfolios, galleries, lookbooks: every image reaches all four viewport edges; chrome is just a floating wordmark + menu via `position: fixed`. Wrong shape for text-heavy sites. + +Root padding must be zero here (and only here) so full-bleed means literally the viewport edge, not edge-minus-gutter: + +```json +{ + "styles": { "spacing": { "padding": { "top": "0", "bottom": "0", "left": "0", "right": "0" } } }, + "settings": { "useRootPaddingAwareAlignments": true, "layout": { "contentSize": "760px", "wideSize": "1280px" } } +} +``` + +Keep `useRootPaddingAwareAlignments: true` so text pages can still constrain to `contentSize`. + +```css +.wp-site-blocks > header.wp-block-template-part { + position: fixed; top: 0; left: 0; right: 0; z-index: 100; + pointer-events: none; /* let clicks pass through the band */ + padding: var(--wp--preset--spacing--40) var(--wp--preset--spacing--50); +} +.wp-site-blocks > header.wp-block-template-part > .wp-block-group { + pointer-events: auto; /* restore on actual chrome */ + display: flex; justify-content: space-between; align-items: flex-start; + mix-blend-mode: difference; /* legible against any image */ + color: white; /* difference flips this per backdrop — do NOT use literal #000/#fff */ +} +.canvas-hero .wp-block-image, .canvas-hero .wp-block-cover { width: 100vw; height: 100vh; margin: 0; } +.canvas-hero .wp-block-image img, .canvas-hero .wp-block-cover img { width: 100%; height: 100%; object-fit: cover; } +.canvas-caption { /* captions BETWEEN images, never overlaid */ + padding: var(--wp--preset--spacing--40) var(--wp--preset--spacing--50); + max-width: var(--wp--style--global--content-size); + font-family: var(--wp--preset--font-family--mono); + font-size: var(--wp--preset--font-size--x-small); + text-transform: uppercase; letter-spacing: 0.1em; + color: var(--wp--preset--color--muted); +} +.wp-block-navigation__responsive-container { /* hamburger overlay reads cleanly */ + background: var(--wp--preset--color--background); + color: var(--wp--preset--color--foreground); + mix-blend-mode: normal; +} +``` + +The `mix-blend-mode: difference` + `color: white` combo keeps the wordmark/menu legible over any photo (white over black = white, white over white = black). The `pointer-events` flip is mandatory — without `auto` on children the fixed band swallows every click. Homepage `
` uses `layout.type:"default"` with zero padding so the hero reaches the edges; text pages (about/contact) restore `constrained` layout with comfortable padding. The nav uses `overlayMenu:"always"` (the hamburger IS the menu on every viewport). Register a `has-floating-chrome` body class via `body_class`. + +--- + +## 6. Scroll motion catalog (progressive enhancement only) + +Use one or two effects, tastefully. NO libraries (GSAP, Lenis, ScrollMagic, AOS), NO scroll-jacking (`wheel` + `preventDefault`, scroll-snap on the body), NO `position: fixed` headers (use sticky), NO `background-attachment: fixed` parallax. Animate only `opacity`, `transform`, `filter`, `background-color` (never `width`/`height`/`top`/`padding` — they trigger layout). + +Every effect respects reduced motion. CSS-only effects wrap in `@media (prefers-reduced-motion: no-preference)`; JS effects early-return on `matchMedia('(prefers-reduced-motion: reduce)').matches`. **CSS defines the final visible state; JS adds the initial hidden state** so a JS failure leaves content visible. + +Motion CSS that sets `opacity: 0` must NOT load in the editor iframe (`add_editor_style` would blank the canvas). Keep reveal/hidden-state CSS in a separate `assets/motion.css` enqueued front-end-only via `wp_enqueue_scripts`, NOT in `style.css`. Frontend-only body-class effects (e.g. `body.is-scrolled`) are safe in `style.css` since the class only toggles on the front end. + +### A. Section reveal on enter (IntersectionObserver) — the always-on default + +```css +@media (prefers-reduced-motion: no-preference) { + .reveal-on-scroll { opacity: 0; transform: translateY(24px); transition: opacity 0.7s ease, transform 0.7s ease; } + .reveal-on-scroll.is-visible { opacity: 1; transform: translateY(0); } +} +``` + +Add `className:"reveal-on-scroll"` to each top-level section group (NOT the hero — it's visible on load). `assets/reveal-on-scroll.js` (write + enqueue): + +```js +(function () { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + var els = document.querySelectorAll('.reveal-on-scroll'); + if (!('IntersectionObserver' in window) || !els.length) return; + var io = new IntersectionObserver(function (entries) { + entries.forEach(function (e) { + if (e.isIntersecting) { e.target.classList.add('is-visible'); io.unobserve(e.target); } + }); + }, { rootMargin: '0px 0px -10% 0px' }); + els.forEach(function (el) { io.observe(el); }); +})(); +``` + +### B. Hero scroll-fade (CSS scroll-driven, no JS) + +```css +@media (prefers-reduced-motion: no-preference) { + @supports (animation-timeline: scroll()) { + .hero-content { + opacity: 1; transform: translateY(0); + animation: hero-fade linear both; + animation-timeline: scroll(root); + animation-range: 0px 60vh; + } + @keyframes hero-fade { from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(-40px); } } + } +} +``` + +Apply `hero-content` to the inner content group of the hero `wp:cover` (not the cover itself). First hero only. Browsers without the API get a static hero (acceptable). + +### C. Scroll progress bar (CSS scroll-driven) — longform/editorial only + +```css +@media (prefers-reduced-motion: no-preference) { + @supports (animation-timeline: scroll()) { + .scroll-progress { + position: fixed; top: 0; left: 0; right: 0; height: 3px; + background: var(--wp--preset--color--accent, currentColor); + transform-origin: left center; transform: scaleX(0); z-index: 1000; + animation: scroll-progress-grow linear; animation-timeline: scroll(root); + } + @keyframes scroll-progress-grow { from { transform: scaleX(0); } to { transform: scaleX(1); } } + } +} +``` + +Emit `` at the top of the page body (it's decorative — screen readers must ignore it). + +### D. Sticky narrative + scrolling visuals (NYT-style) — case studies only + +```css +@media (prefers-reduced-motion: no-preference) { + .narrative-pin > .wp-block-column:first-child { + position: sticky; top: var(--wp--custom--scroll-padding-top, 96px); align-self: flex-start; + } +} +``` + +`wp:columns {"className":"narrative-pin"}`: first column = short pinned text (must fit one viewport, else nothing to pin), second column = the long scrolling stack. + +### E. Counter on enter (IntersectionObserver) — only with 2–4 real stat numbers + +Markup: `

0

`. Write `assets/counter.js`: early-return on reduced motion (render final value immediately), IntersectionObserver to a cubic ease-out count-up, `toLocaleString()` for separators. + +### Header on-scroll variants (pick AT MOST one) + +`assets/header-scroll.js` toggles `body.is-scrolled` past a 60px threshold and (under no-preference) `body.header-hidden` while scrolling down. Variants: + +1. **Shrink** — section 1's `.is-shrunk` pattern. +2. **Invert** — transparent over hero, solid past fold (needs the picked palette tokens, so this CSS goes in `style.css` keyed off `body.is-scrolled`): + ```css + @media (prefers-reduced-motion: no-preference) { + .wp-site-blocks > header.wp-block-template-part > .wp-block-group { transition: background-color 0.3s ease, color 0.3s ease, backdrop-filter 0.3s ease; } + body.is-scrolled .wp-site-blocks > header.wp-block-template-part > .wp-block-group { + background-color: var(--wp--preset--color--background); color: var(--wp--preset--color--primary); backdrop-filter: blur(8px); + } + } + ``` +3. **Hide on scroll-down, show on scroll-up** (longform): + ```css + @media (prefers-reduced-motion: no-preference) { + .wp-site-blocks > header.wp-block-template-part { transition: transform 0.3s ease; } + body.header-hidden .wp-site-blocks > header.wp-block-template-part { transform: translateY(-100%); } + } + ``` +4. **Active-anchor underline** (landing-page only) — `assets/active-anchor.js` watches each `
` and toggles `.is-active` on the matching `` (use `rootMargin: -40% 0px -40% 0px` to track the viewport's middle band): + ```css + @media (prefers-reduced-motion: no-preference) { + .wp-block-navigation a { position: relative; } + .wp-block-navigation a::after { content: ''; position: absolute; left: 0; right: 0; bottom: -4px; height: 2px; background: currentColor; transform: scaleX(0); transform-origin: left center; transition: transform 0.3s ease; } + .wp-block-navigation a.is-active::after { transform: scaleX(1); } + } + ``` + +### Per-page budget + +- **Homepage**: section reveal (A) is mandatory and free; pick 1–2 of B/C/D/E and at most ONE header variant. Zero rich effects is also valid for minimal themes. +- **Other pages**: section reveal (A) only. +- **CPT single entries**: none. + +Enqueue each motion JS in the footer from `functions.php`: + +```php +add_action( 'wp_enqueue_scripts', function () { + wp_enqueue_script( + 'myprefix-reveal-on-scroll', + get_theme_file_uri( 'assets/reveal-on-scroll.js' ), + array(), wp_get_theme()->get( 'Version' ), true + ); +} ); +``` + +--- + +## 7. Query-loop layouts (front-end archives and listings) + +Content is seeded into the live DB (via WP-CLI / the seed_content tool), and CPTs/meta come from the companion plugin. The theme's job is the **loop layout** in templates. Every `wp:query` loop is three decisions: (1) per-item data composition, (2) loop shape, (3) page composition around it. Don't stamp one shape on every CPT. + +### The single hard rule + +**Never put `wp:post-content` inside an archive's `wp:post-template`.** It renders the full single-page body for every entry — a wall of full posts. Compose from post-* primitives: `wp:post-featured-image`, `wp:post-title`, `wp:post-excerpt`, `wp:post-date`, `wp:post-author-name`. `wp:post-content` is correct only in `single-.html` (one detail page, not a loop). + +### Rendering structured meta (price, role, year, date) + +There is NO `wp:post-meta` block — emitting it renders nothing. Use **block bindings** on a paragraph or heading (the companion plugin registers each meta key with `show_in_rest => true`, which bindings require): + +```html + +

+ +``` + +For a labelled value ("Maker: …"), pair a static paragraph and a bound paragraph in a flex `wp:group`. The empty `

` is a placeholder replaced at render time. + +### Picking a shape (decide in this order) + +1. **Page role** is decisive. + - Homepage preview (archive lives elsewhere) → horizontal rail or compact 3-col grid, capped 4–6. NOT editorial list / zigzag / cover-hero — giant single-column rows look broken on a homepage. + - Hero archive (the loop IS the page) → richest shapes: editorial list, zigzag, magazine, featured+rest. + - Secondary listing → grid or list. Mid-page band → rail, compact list, simple grid. +2. **Entry count** (dedicated archives): 3–6 → editorial list / zigzag / cover-hero; 6–12 → grid (2–3 col) / featured+rest; 12–30 → dense grid / compact list; 30+ → compact list / pagination / sibling loops. +3. **Visual weight per entry**: portraits → grid; long detail → editorial list / zigzag; single value (price) → compact list; photographic → cover-hero; prose → magazine / featured+rest. +4. **Brand voice**: editorial → list/magazine; minimal → consistent-chrome grid; lookbook → cover-hero/magazine; brutalist → hairline-border grid/compact list. +5. **Natural data axis**: chronological → timeline / date-ordered; tiered → featured+rest; two slices → sibling loops; geographic → compact list with location meta. + +Pick by **domain purpose, not slug** — a CPT listing dentists is People (portrait + name + role), not "Default." + +### Shape: Card grid (equal-weight uniform grid) + +```html + +
+ +
+ + + +

+ + +
+ +
+ +``` + +### Shape: Editorial list / vertical stack (image-side rows, detail-heavy) + +Two-column rows (image 40% / text 60%), `align:"wide"`, `className:"is-style-loop-list"` (CSS adds row dividers). Use `wp:columns` with `verticalAlignment:"center"`; surface an overline meta paragraph, a large `wp:post-title`, and a longer `wp:post-excerpt`. + +### Shape: Horizontal rail (scrollable strip, "more elsewhere") + +`className:"is-style-loop-rail"`, `align:"full"`, `post-template` layout `flex`/`flexWrap:"nowrap"`. CSS adds `overflow-x: auto` + scroll-snap; cards get a fixed width. Cap 4–8 entries. + +### Shape: Cover-hero (featured image as background, title overlaid) + +`wp:cover {"useFeaturedImage":true,"dimRatio":40,"minHeight":420,"isLink":true}` inside a grid `post-template`. Title + meta go in `wp-block-cover__inner-container` with `textColor:"background"` (text over the dimmed image). Image-dominant — galleries, photo menus. + +### Shape: Featured + rest (one promoted, the rest in a grid) + +Needs **two queries**: first `perPage:1` rendered as a large cover-hero; second `perPage:6,"offset":1` as a 3-col grid (offset skips the featured entry). No labelling comment between them. + +### Shape: Compact list, zigzag, timeline, magazine + +- **Compact list** (`is-style-loop-list`): one flex row per entry, title + meta, no images. Long indexes (press, episodes, jobs). +- **Zigzag** (`is-style-loop-zigzag`): full-width image-text rows; CSS flips column order on `:nth-child(even)`. Portfolio walks, recipes. +- **Timeline** (`is-style-loop-timeline`): `orderBy:"meta_value"` on a date key; CSS adds a vertical line + node dots via `::before`. Events, milestones. +- **Magazine** (`is-style-loop-magazine`): grid where CSS makes the first child span 2 columns. Editorial homepages. + +When a shape needs a hook, add `className:"is-style-loop-"` to the `wp:query` and ship the matching rule in `style.css`. The shapes are starting points — invent new ones with the post-* primitives + standard layout blocks when the data calls for it (e.g. a status board, a comparison rail). + +### Page composition around the loop + +Surround the loop with the page's voice: intro + loop + CTA (most pages); manifesto + loop (about/team); hero feature + archive (editorial homepages); loop as one band among full-bleed bands; or loop + sibling loop (two queries slicing the same CPT — "upcoming / past" via `metaQuery` on a date key, "featured / recent" via `offset`). Each sibling loop can pick its own shape. + +--- + +## Cross-cutting checklist + +1. Custom classNames only on outer block wrappers; full-bleed via `align:full` outer group. +2. `backgroundColor` always paired with `textColor`; nav gets the full color set. +3. Sticky on the `.wp-block-template-part` wrapper; audit ancestor `overflow` if it fails. +4. Grid layouts: `min-width: 0` on text grid items; explicit `grid-row: 1` on sidebar shells. +5. Motion: final state in CSS, initial hidden state added by JS; every effect respects reduced motion; reveal CSS stays out of `style.css` (out of the editor iframe). +6. Never `wp:post-content` in a loop's `post-template`; render meta via block bindings (no `wp:post-meta`). +7. No emojis, no decorative HTML comments. Plain-JS motion files in `assets/`, enqueued front-end-only — no bundler, no Interactivity API. diff --git a/apps/cli/ai/skills/site-generator/SKILL.md b/apps/cli/ai/skills/site-generator/SKILL.md new file mode 100644 index 0000000000..55043db53b --- /dev/null +++ b/apps/cli/ai/skills/site-generator/SKILL.md @@ -0,0 +1,110 @@ +--- +name: site-generator +description: Generate a complete WordPress site — a pure-presentation theme plus a companion plugin — from a description. Load FIRST when the user wants to build a whole new site or theme. Orchestrates spec, design selection, parallel theme generation, companion plugin, content seeding, AI imagery, and validation. +user-invokable: true +--- + +# Site Generator + +This is the orchestrator for building a complete WordPress site end to end. It +uses dedicated generation tools that run many model calls in parallel and write +whole packages to disk in one call — far faster and more complete than writing +files one per turn. Your job is to drive the pipeline in order and verify the +result, not to hand-author theme files. + +## Output model (read this first) + +Every generated site is TWO packages: + +- **Theme** — pure presentation: `theme.json`, `style.css`, `templates/`, + `parts/`, `patterns/`, `assets/`. Minimal `functions.php`. No behaviour. +- **Companion plugin** — all behaviour: custom post types, taxonomies, post + meta, REST routes, and build-less plain-JS blocks. Survives a theme switch. + +Page content is seeded into the **live database**, never baked into the theme. +For background, the `theme-architecture`, `companion-plugin`, `layout-patterns`, +`data-persistence`, and `wp-best-practices` skills hold the doctrine the +generators apply; load them if you need to reason about a result or fix it. + +## Pipeline + +### 1. Resolve the site + +- If the user is building a brand-new site, run the `site-spec` skill to gather + the site name and any layout preference, then call **`site_create`**. +- If they want to use an existing/active site, use that one (`site_info`). + +### 2. Build the site spec (JSON) + +Synthesize a JSON spec string you will pass to every generation tool. Use the +`theme-architecture` skill's layout-mode and content-mode taxonomy to choose +sensible values, and the `visual-design` skill for aesthetic direction. Shape: + +```json +{ + "name": "Ember & Oak", + "type": "restaurant", + "audience": "local diners looking for a special evening", + "tone": "warm, refined, unfussy", + "topic": "a wood-fired neighbourhood restaurant in Lisbon", + "layoutPreference": "landing-page or vertical-stack", + "pages": ["Home", "Menu", "About", "Reservations", "Contact"], + "features": ["reservation form"] +} +``` + +Keep it concise but specific; the topic and tone drive design quality. + +### 3. Generate and choose a design direction + +Call **`generate_design_previews`** with `nameOrPath` and `spec`. It writes +several first-fold HTML previews to `/design/` and opens them. Show the +user the directions and use **AskUserQuestion** to let them pick one (or ask for +a regenerate). Keep the chosen preview's HTML — you pass it next. + +### 4. Generate the theme + +Call **`generate_theme`** with `nameOrPath`, `spec`, and `design` (the chosen +preview's HTML or its brief). It generates the whole theme in parallel, writes +it, activates it, and returns a **MANIFEST** JSON block at the end of its output. +**Copy that manifest verbatim** — the next tools need it. + +### 5. Generate the companion plugin (only if needed) + +If the manifest's `companionPlugin.needed` is `true`, call +**`generate_companion_plugin`** with `nameOrPath`, `spec`, and the `manifest`. +It generates CPTs, REST routes, and build-less plain-JS blocks, then activates +the plugin. A brochure site with no forms/CPTs/interactive blocks skips this. + +### 6. Seed content + +Call **`seed_content`** with `nameOrPath`, `spec`, and the `manifest`. It +generates each page's block markup, fills AI_IMAGE placeholders with generated +imagery, publishes the pages into the database, and sets the home page as the +static front page. + +### 7. Fill theme imagery + +Call **`generate_image`** with `nameOrPath` and `themeSlug` (the manifest's +`themeSlug`) to fill any `AI_IMAGE:` placeholders left in theme templates/parts. + +### 8. Verify and fix + +- Run **`validate_and_fix_blocks`** (with `nameOrPath` and the relevant + content/filePath) on generated block content; rewrite anything it flags. +- Run **`take_screenshot`** with `viewport: "all"` on the site URL. Check the + navigation, hero, full-width sections (they must span the viewport — fix in + markup with `align: "full"`, not CSS), color contrast, and spacing. Fix issues + with `Edit`/`wp_cli` and re-verify. + +## Rules + +- The generation tools each run for a while (many parallel model calls). That is + expected — let them complete; do not try to hand-write the files yourself. +- Always pass the SAME `spec` string and the SAME `manifest` through steps 4–7. +- Never put behaviour in the theme or content in theme files — the tools already + enforce the split; don't undo it with manual `wp_cli`/`Write` edits. +- WordPress.com login is required (AI generation + imagery route through the + WordPress.com AI proxy). If a tool reports a login error, tell the user to run + `/login`. +- No emojis in any generated content. diff --git a/apps/cli/ai/skills/site-generator/generators/_shared.md b/apps/cli/ai/skills/site-generator/generators/_shared.md new file mode 100644 index 0000000000..3d1a152aa4 --- /dev/null +++ b/apps/cli/ai/skills/site-generator/generators/_shared.md @@ -0,0 +1,64 @@ +# Shared generation rules + +These rules govern EVERY file you generate for this site, regardless of which specific generator invoked you. They are absolute unless the site spec or the user's original request explicitly overrides one of them. Read them before writing a single line, and re-read the relevant section before you declare colors, choose a layout, or write copy. + +## Two-package architecture (never blur the line) + +A generated site is always TWO packages with a hard separation of concerns: + +- **The theme** (`/wp-content/themes//`) is PURE PRESENTATION: `theme.json`, `style.css`, `templates/`, `parts/`, `patterns/`, `assets/`, and a minimal `functions.php` that does nothing but enqueue `style.css` on the front end and call `add_editor_style`. The theme NEVER registers custom post types, taxonomies, post meta, REST routes, or blocks, and NEVER seeds content. +- **The companion plugin** (`/wp-content/plugins/-functionality/`) owns ALL behavior: custom post types, taxonomies, post meta, REST API routes, and any custom Gutenberg blocks. +- **Content** is never baked into files. Pages, posts, and CPT entries are seeded into the LIVE WordPress database (via WP-CLI / the seed-content tool), not written as `*.html` content files in the theme. + +When a generator asks for theme markup, do not reach for behavior. When it asks for the plugin, do not reach for presentation. Keep the two clean. + +## Composition and block markup + +- **No decorative HTML comments.** Only WordPress block delimiter comments (`` ... ``) are allowed. Never insert labelling comments like ``, ``, or any `` that is not a block delimiter. Section identity lives in `className` and block attributes, not in comments. +- **Output fully expanded block markup.** Never emit `` placeholders or any shorthand — write the complete nested markup inside every block. +- **Prefer core blocks for content.** Compose with `wp:group`, `wp:columns`, `wp:cover`, `wp:media-text`, `wp:heading`, `wp:paragraph`, `wp:buttons`, `wp:image`, `wp:quote`, `wp:details`, `wp:gallery`, `wp:list`, `wp:navigation`, `wp:site-title`, etc. Reach for a custom block (in the companion plugin) only when a feature is genuinely interactive or data-backed and no core block expresses it. `wp:html` is reserved for tiny exotic embeds only — never for heroes, navs, card grids, forms, testimonials, CTAs, sidebars, or any section a core block can express. +- **One dominant semantic wrapper per section.** Treat every section as self-contained: a single outer `wp:group` (or `wp:cover`) that owns the section, with content nested inside it. +- **Full-bleed sections use an outer group with `align:full`.** A section that bleeds edge-to-edge (hero, photographic band, footer band, full-bleed CTA strip) is an outer `wp:group`/`wp:cover` with `"align":"full"`. Sections at the theme's wide width use `"align":"wide"` (feature grids, query loops, most sections). Default content alignment is reserved for INNER content holders only — a constrained group nested inside a section that holds readable copy. A top-level section with no `align` will inherit body root padding and render as a narrow column; that is the visual symptom of forgetting this. +- **Section container vs inner holder.** The outer section container (which owns the background, padding, and `align`) uses `"layout":{"type":"default"}` or omits `layout` — never `constrained` (constrained clamps width and breaks edge-to-edge backgrounds). The inner content holder nested inside it MAY be `constrained` so readable copy sits at content width. The constrained group inside a full-bleed container is the canonical pattern. +- **Horizontal page gutter lives in `theme.json` root padding only.** Do not add left/right padding to `
` wrappers, top-level page groups, template shells, or the page root — that double-pads. Sections break out of root padding via `align`, never via wrapper padding. +- **Custom classNames go ONLY on the outermost block wrapper**, via the block's `className` attribute (e.g. `{"className":"site-hero"}`). Never put custom classes on inner DOM elements or on nested blocks. Section identity and any custom CSS hooks attach to the outer wrapper; everything inside is styled through that hook or through block attributes. +- **Sticky positioning goes on the `.wp-block-template-part` wrapper, not the inner group.** When a header (or any part) is sticky, the sticky rule targets the template-part wrapper element, never the inner `wp:group` inside the part. + +## Color pairing discipline (read before declaring any block colors) + +Inheritance in WordPress block themes is unreliable. A child block whose text color falls through to the body default renders invisible against any parent surface that is not the body background. Defend against it at the block level: + +- **Whenever a block declares `backgroundColor` (or `style.color.background`), it MUST also declare `textColor` (or `style.color.text`).** No exceptions, at every level. A tinted `wp:group` that omits its own text color passes the inheritance burden to its children and the chain breaks the moment one child also omits it. +- **`wp:button` MUST declare BOTH text and background colors** at the block level. `is-style-outline` buttons (transparent background) MUST declare `textColor` AND `borderColor` together. +- **When a block declares `style.border.width`, it MUST also declare `borderColor`** as a palette slug, so borders never inherit `currentColor`. +- **`wp:navigation` MUST declare all of: `textColor`, `style.elements.link.color.text`, `overlayBackgroundColor`, `overlayTextColor`, and `style.spacing.blockGap`.** Missing any one breaks at least one of the desktop / hover / mobile-overlay / item-spacing render modes. Mobile overlay defaulting to invisible is the canonical nav bug. +- **Any block with `layout.type:"grid"` or `layout.type:"flex"` MUST declare `style.spacing.blockGap`** (post-template grids, columns, custom flex groups, `wp:buttons`, `wp:navigation`). WordPress flex/grid layouts have no gap by default. The structural test: right after you write `layout.type:"grid"` or `layout.type:"flex"`, the next attribute should be `style.spacing.blockGap`. + +## Design token discipline + +- **Reference `theme.json` tokens by slug in block markup.** Use the declared palette, font-size, font-family, and spacing presets: `{"textColor":"primary"}`, `{"fontSize":"large"}`, `{"style":{"spacing":{"padding":{"top":"var:preset|spacing|40"}}}}`. Never introduce hardcoded hex colors, raw px values, or font names in block attributes. +- **CSS in `style.css` references tokens via CSS variables** — `var(--wp--preset--color--primary)`, `var(--wp--preset--font-family--body)`, `var(--wp--preset--spacing--40)` — rather than hardcoding values. Custom CSS is reserved for polish (typographic detail, link treatments, button variants, image effects, animation states), not for re-implementing layout that block attributes already express. +- **Fonts are declared in `theme.json`** via `settings.typography.fontFamilies` with `fontFace` entries pointing at the bundled font files. Do NOT enqueue fonts from PHP and do not create a `fonts.php`. +- **Typography restraint.** Body text around 1rem with line-height 1.5–1.65. Headings scale modestly; cap display text near 3.5rem (e.g. `clamp(2.5rem, 4vw, 3.5rem)`). Never go below line-height 1.0 on any text; heading line-heights stay between 1.1 and 1.3. + +## Scroll animation and motion + +- **Progressive enhancement only.** CSS defines the FINAL visible state (the element is fully visible and correctly positioned with CSS alone, no JS). JavaScript ADDS the initial hidden state and removes it on scroll/intersection, so that with JS disabled the content is still fully visible. Never let the visible state depend on JS running. +- **Every animation respects reduced motion.** Wrap motion in `@media (prefers-reduced-motion: reduce)` so that users who prefer reduced motion see the final state with no transition. +- Keep motion subtle and purposeful; it supports the content, it does not perform. + +## Plain JavaScript only (when behavior is involved) + +- Any interactive or stateful behavior (countdowns, filters, sliders, calculators, form submission, scroll reveals) is implemented in **plain JavaScript** using standard DOM APIs (`querySelector`, `addEventListener`, `classList`, `dataset`, `fetch`). +- **Never use the WordPress Interactivity API** (`@wordpress/interactivity`, `data-wp-*` directives, its store/state system). +- **Custom blocks are build-less plain JS**: a `block.json` plus a plain `view.js`/`editor.js` that calls `wp.blocks.registerBlockType` using `wp.element.createElement` — NO JSX, NO `@wordpress/scripts`, NO npm build step. Blocks are registered server-side with `register_block_type` from the companion plugin. + +## Content quality + +- **NO EMOJIS anywhere** — not in headings, paragraphs, button labels, navigation, footer text, code comments, or any visible string. Avoid glyphs WordPress auto-converts to emoji (`:)`, `<3`, etc.). If you need iconography, use inline custom SVG. +- **Realistic, domain-specific copy.** When the spec or request names a business or domain, all body text must reflect it: a bakery's team page is about bakers, a dental clinic's services page lists dental services. Never fall back to generic SaaS/agency/consulting filler. +- **Cohesive imagery.** Within a logical group (all team photos, all product shots, all entries of one CPT) keep aspect ratio and photographic style consistent, following the image conventions the per-file generator specifies. + +--- + +The specific generator instructions for the file you are about to produce, followed by the site spec JSON and the chosen design direction, follow below — apply every rule above to that work. diff --git a/apps/cli/ai/skills/site-generator/generators/block.md b/apps/cli/ai/skills/site-generator/generators/block.md new file mode 100644 index 0000000000..78c269f8ae --- /dev/null +++ b/apps/cli/ai/skills/site-generator/generators/block.md @@ -0,0 +1,223 @@ +# Generator: Custom Gutenberg Block (build-less, plain JS) + +You generate ONE custom Gutenberg block for a generated WordPress site. The block lives in the +site's **companion plugin** — never in the theme. The theme is pure presentation (theme.json, +templates, parts, patterns, style.css); ALL behavior (custom post types, taxonomies, post meta, +REST routes, and every custom block) lives in the companion plugin at +`/wp-content/plugins/-functionality/`. This generator produces the source for a single +block under that plugin's `blocks//` directory. + +The block MUST require **no build step**. There is no wp-scripts, no npm, no webpack, no JSX, no +`src/` → `build/` compilation. You write plain JavaScript files that the browser loads directly. The +plugin registers the block server-side with `register_block_type()` pointing at the directory that +holds your `block.json`. + +## Input + +The task line gives you the block spec: + +- **slug** — the block slug, e.g. `availability-checker`. This is the exact slug the directory is + named after and the suffix of the block name. Do not rename it or reformat it. +- **title** — the human-readable title shown in the inserter, e.g. "Availability Checker". +- **purpose** — what the block does: the interactive behavior, the data it persists or fetches, the + default content, the copy and labels. + +You will also be given the theme's design tokens (color slugs, font-size slugs, spacing slugs from +theme.json) and, for data-backed blocks, the companion plugin's registered post type slug, its meta +keys, and the REST route the plugin exposes. Use those verbatim — do not invent post type slugs, +meta keys, or routes. The plugin's CPT/REST registration and your block's `fetch()` target must +match exactly, or submissions fail at runtime with "missing parameter" errors. + +## When this generator runs + +This generator runs only for **named, interactive or data-backed features** — a discrete noun that +implies state, computation, persistence, or custom editor controls: booking form, contact form, +reservation widget, countdown timer, calculator, quote builder, pricing configurator, before/after +slider, RSVP form, newsletter signup, availability checker, review submission. + +It does NOT run for content sections. Heroes, testimonials, feature grids, pricing displays, team +sections, FAQs, galleries, and CTAs are composed from CORE blocks in templates/patterns, not built +as custom blocks. If the spec describes layout or content arrangement rather than a stateful +feature, no custom block is needed. + +## The block name + +The block name in `block.json` MUST be `/`, where `` is the +companion plugin's prefix (the same prefix used for its REST namespace and its block registrations). +Use the prefix you are given verbatim. Do not derive a different prefix from the theme name or the +site title. Every block in the plugin shares the one prefix. + +## Build-less file shapes + +You emit a small, flat set of files for the block directory (NO `src/`, NO `build/`): + +| File | Required when | +|------|---------------| +| `block.json` | always | +| `editor.js` | always (editor registration) | +| `view.js` | the block has front-end interactivity (interactive or form-backed blocks) | +| `render.php` | the block is dynamic (server-rendered output) | + +There is no `index.js`, no `edit.js` + `save.js` split, no `style.scss`/`editor.scss`. Styling comes +from the theme's design tokens referenced inline in markup (block attributes) and, where needed, plain +CSS shipped by the plugin or inline `