From e010743dc4aaffde03e2dff3c485167f9039403c Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Sat, 3 Oct 2026 08:09:06 +0400 Subject: [PATCH 1/2] fix: reject inline content in synced pattern references --- dev/test/cli.test.ts | 16 +++++++++++++++ dev/test/convert.test.ts | 21 +++++++++++++++++++ dev/test/gate.test.ts | 44 ++++++++++++++++++++++++++++++++++++++++ dev/test/intent.test.ts | 23 +++++++++++++++++++++ src/convert/finalize.ts | 13 +++++++++++- src/gate/canonicalize.ts | 6 ++++++ src/gate/patterns.ts | 34 +++++++++++++++++++++++++++++++ src/gate/validate.ts | 15 ++++++++------ 8 files changed, 165 insertions(+), 7 deletions(-) create mode 100644 src/gate/patterns.ts diff --git a/dev/test/cli.test.ts b/dev/test/cli.test.ts index a978967..ab29e88 100644 --- a/dev/test/cli.test.ts +++ b/dev/test/cli.test.ts @@ -522,6 +522,22 @@ describe('CLI', () => { await expect(stat(outPath)).rejects.toMatchObject({ code: 'ENOENT' }); }); + it('fails validation and refuses to write a lossy synced-pattern repair', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'block-runner-cli-')); + const inputPath = path.join(dir, 'pattern.html'); + const outPath = path.join(dir, 'fixed.html'); + const markup = '

Keep me

'; + await writeFile(inputPath, markup); + const validation = await runCli(['validate', inputPath, '--json']); + expect(validation.code).toBe(1); + expect(JSON.parse(validation.stdout).items[0].reason).toContain('serialization would discard'); + const repair = await runCli(['fix', inputPath, '--out', outPath]); + expect(repair.code).toBe(2); + expect(repair.stderr).toContain('--out is only written when the command succeeds'); + await expect(stat(outPath)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await readFile(inputPath, 'utf8')).toBe(markup); + }); + it('offers source retention when plugin inspect cannot integrate a host layout', async () => { const root = await mkdtemp(path.join(tmpdir(), 'block-runner-cli-')); await writeFile(path.join(root, 'package.json'), JSON.stringify({ scripts: { build: 'vite build' } })); diff --git a/dev/test/convert.test.ts b/dev/test/convert.test.ts index 9a4ad66..1699868 100644 --- a/dev/test/convert.test.ts +++ b/dev/test/convert.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { convert } from '../../src/index.js'; +import type { Rule } from '../../src/types.js'; const heroHtml = String.raw` @@ -25,6 +26,26 @@ const heroHtml = String.raw` `; describe('convert', () => { + it('rejects synced-pattern children emitted by a custom rule', async () => { + const rule: Rule = { + id: 'pattern-reference', + match: (node) => node.tagName === 'SECTION', + emit: async (_node, context) => context.wp.createBlock('core/block', { ref: 123 }, [ + context.wp.createBlock('core/paragraph', { content: 'Keep this content' }), + ]), + }; + const report = await convert('
Keep this content
', { + sourcePath: 'design.html', + config: { rules: { custom: [rule] } }, + }); + expect(report.ok).toBe(false); + expect(report.output).toBe(''); + expect(report.items).toContainEqual(expect.objectContaining({ + block: 'core/block', status: 'invalid', + source: expect.objectContaining({ path: 'design.html', htmlLine: 1 }), + })); + }); + it('converts a CSS-background hero to native cover content without Custom HTML', async () => { const report = await convert(heroHtml, { sourcePath: 'hero.html', diff --git a/dev/test/gate.test.ts b/dev/test/gate.test.ts index 296c945..031f53f 100644 --- a/dev/test/gate.test.ts +++ b/dev/test/gate.test.ts @@ -2,6 +2,50 @@ import { describe, expect, it } from 'vitest'; import { canonicalize, validate } from '../../src/index.js'; describe('gate', () => { + const inline = '

Keep this content

'; + const reference = `${inline}`; + + it.each([ + reference, + `
${reference}
`, + '

Keep this content

', + ])('rejects and preserves inline synced-pattern content during repair: %s', async (markup) => { + const report = await validate(markup, { sourcePath: 'pattern.html' }); + expect(report.ok).toBe(false); + expect(report.items).toContainEqual(expect.objectContaining({ + block: 'core/block', status: 'invalid', + reason: expect.stringContaining('serialization would discard'), + source: expect.objectContaining({ path: 'pattern.html' }), + })); + const fixed = await canonicalize(markup); + expect(fixed.ok).toBe(false); + expect(fixed.output).toBe(markup); + }); + + it('locates a malformed reference after an earlier valid reference', async () => { + const report = await validate(`\n${reference}`); + expect(report.items[0]?.source?.htmlLine).toBe(2); + }); + + it.each([ + '', + ' \n ', + '', + '', + '', + `
${inline}
`, + '

Default

', + ])('preserves valid references, legacy overrides and pattern definitions: %s', async (markup) => { + expect((await validate(markup)).ok).toBe(true); + const fixed = await canonicalize(markup); + expect(fixed.ok).toBe(true); + if (markup.includes('Custom text')) { + expect(fixed.output).toContain('"content":{"Text":{"content":"Custom text"}}'); + } + if (markup.includes('Keep this content')) expect(fixed.output).toContain('Keep this content'); + if (markup.includes('Default')) expect(fixed.output).toContain('Default'); + }); + it('validates valid block markup', async () => { const report = await validate('

Hello

'); diff --git a/dev/test/intent.test.ts b/dev/test/intent.test.ts index 22c346d..ad88f48 100644 --- a/dev/test/intent.test.ts +++ b/dev/test/intent.test.ts @@ -6,6 +6,29 @@ import { realize, validate } from '../../src/index.js'; const FIXTURES = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixtures'); describe('intent assembly', () => { + it('rejects nested synced-pattern children before serialization loses them', async () => { + const report = await realize(JSON.stringify({ blocks: [{ + block: 'core/group', children: [{ + block: 'core/block', attrs: { ref: 123 }, + children: [{ block: 'core/paragraph', text: 'Keep this content' }], + }], + }] }), { sourcePath: 'intent.json' }); + expect(report.ok).toBe(false); + expect(report.output).toBe(''); + expect(report.items).toContainEqual(expect.objectContaining({ + block: 'core/block', status: 'invalid', source: { path: 'intent.json' }, + details: { blockPath: 'blocks[0].innerBlocks[0]', locationKind: 'block-tree' }, + })); + }); + + it('preserves synced-pattern overrides supplied as attributes', async () => { + const report = await realize(JSON.stringify({ blocks: [{ + block: 'core/block', attrs: { ref: 123, content: { Text: { content: 'Custom text' } } }, + }] })); + expect(report.ok).toBe(true); + expect(report.output).toBe(''); + }); + it('assembles a nested cover, columns, and buttons tree into valid markup', async () => { const report = await realize( JSON.stringify({ diff --git a/src/convert/finalize.ts b/src/convert/finalize.ts index 20d0a15..68c5407 100644 --- a/src/convert/finalize.ts +++ b/src/convert/finalize.ts @@ -1,5 +1,6 @@ -import { validate } from '../gate/validate.js'; +import { flattenBlocks, validate } from '../gate/validate.js'; import { generatedMarkupItems } from '../gate/provenance.js'; +import { patternReferenceErrors } from '../gate/patterns.js'; import { applyMedia } from '../media/apply.js'; import { createMediaResolver } from '../media/resolver.js'; import { repairTokens } from '../tokens/apply.js'; @@ -32,6 +33,16 @@ export async function finalizeBlocks( context: FinalizeContext, ): Promise { const warnings = [...(context.warnings ?? [])]; + const invalidReferences = patternReferenceErrors(blocks, options.sourcePath); + if (invalidReferences.length > 0) { + return { + ok: false, + command: context.command, + summary: { blocks: flattenBlocks(blocks).length, valid: 0, invalid: invalidReferences.length, warnings: warnings.length }, + items: [...warnings, ...invalidReferences], + output: '', + }; + } warnings.push(...(await applyMedia(blocks, createMediaResolver(config, options), config))); const tokenRepair = await repairTokens(blocks, config, options, context.tokens); diff --git a/src/gate/canonicalize.ts b/src/gate/canonicalize.ts index e84ccc8..793bf75 100644 --- a/src/gate/canonicalize.ts +++ b/src/gate/canonicalize.ts @@ -7,6 +7,7 @@ import { buildTokenInverseMap } from '../tokens/repair.js'; import { BlockRunnerReport, CanonicalizeOptions, WpBlock, WpModules } from '../types.js'; import { validate } from './validate.js'; import { generatedMarkupItems } from './provenance.js'; +import { patternReferenceErrors } from './patterns.js'; /** * Rebuild genuinely-invalid registered blocks from their parsed attributes. @@ -65,6 +66,11 @@ export async function canonicalize(markup: string, options: CanonicalizeOptions const config = await loadConfig(options); const parsed = withMutedWordPressConsole(() => wp.parse(markup, { __unstableSkipMigrationLogs: true })); + // Gutenberg can call these valid, then drop all their children during serialization. + // Refuse the whole repair before token changes or rebuilding can lose the original input. + if (patternReferenceErrors(parsed).length > 0) { + return { ...await validate(markup, options), command: 'fix', output: markup }; + } let repairs: BlockRunnerReport['items'] = []; // createBlock revalidates as it rebuilds; Gutenberg dumps large validation diff --git a/src/gate/patterns.ts b/src/gate/patterns.ts new file mode 100644 index 0000000..5c0784f --- /dev/null +++ b/src/gate/patterns.ts @@ -0,0 +1,34 @@ +import type { ReportItem, WpBlock } from '../types.js'; + +/** Persisted synced-pattern instances reference a wp_block; they do not save children. */ +export function patternReferenceProblem(block: WpBlock): string | undefined { + if (block.name !== 'core/block' || (!block.innerBlocks.length && !block.originalContent?.trim())) { + return undefined; + } + return 'Synced pattern references cannot contain inline blocks or HTML: serialization would discard that content. ' + + 'Use ref with content overrides keyed by the referenced pattern\'s metadata.name values, ' + + 'or explicitly detach the pattern into ordinary blocks. No automatic repair is safe.'; +} + +/** Check producer trees before serialization can erase evidence of a malformed reference. */ +export function patternReferenceErrors(blocks: WpBlock[], sourcePath?: string): ReportItem[] { + const items: ReportItem[] = []; + const visit = (nodes: WpBlock[], parent: string): void => { + for (const [index, block] of nodes.entries()) { + const blockPath = `${parent}[${index}]`; + const reason = patternReferenceProblem(block); + if (reason) { + items.push({ + block: block.name, + status: 'invalid', + reason, + source: block.__blockRunnerSource ?? (sourcePath ? { path: sourcePath } : undefined), + details: { blockPath, locationKind: 'block-tree' }, + }); + } + visit(block.innerBlocks, `${blockPath}.innerBlocks`); + } + }; + visit(blocks, 'blocks'); + return items; +} diff --git a/src/gate/validate.ts b/src/gate/validate.ts index d9e4851..8bf2bd2 100644 --- a/src/gate/validate.ts +++ b/src/gate/validate.ts @@ -1,6 +1,7 @@ import { formatWithOptions } from 'node:util'; import { getWp } from '../headless/wp.js'; import { withMutedWordPressConsole } from '../headless/env.js'; +import { patternReferenceProblem } from './patterns.js'; import { BlockRunnerReport, ReportItem, @@ -27,6 +28,9 @@ export async function validate(markup: string, options: ValidateOptions = {}): P for (const block of flattenBlocks(blocks)) { summary.blocks += 1; + // Advance for valid blocks too, so a later reference points at its own occurrence. + const source = locateBlock(markup, block, searchOffset, options.sourcePath); + if (source.offset != null) searchOffset = source.offset + 1; // `core/html` is a raw passthrough: since @wordpress/block-library 10.5.0 (WordPress 7.1) // its `save()` returns null, so the generic validator expects EMPTY output and rejects any @@ -40,7 +44,10 @@ export async function validate(markup: string, options: ValidateOptions = {}): P continue; } - const [isValid, issues] = withMutedWordPressConsole(() => wp.validateBlock(block)); + const problem = patternReferenceProblem(block); + const [isValid, issues] = problem + ? [false, [] as unknown[]] as const + : withMutedWordPressConsole(() => wp.validateBlock(block)); if (isValid) { summary.valid += 1; @@ -48,14 +55,10 @@ export async function validate(markup: string, options: ValidateOptions = {}): P } summary.invalid += 1; - const source = locateBlock(markup, block, searchOffset, options.sourcePath); - if (source.offset != null) { - searchOffset = source.offset + 1; - } items.push({ block: block.name, status: 'invalid', - reason: formatValidationIssues(issues), + reason: problem ?? formatValidationIssues(issues), source, }); } From ead9d7cf738d35611a3bc843f6c10b7033da3e0c Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Sat, 3 Oct 2026 08:09:06 +0400 Subject: [PATCH 2/2] docs: explain synced pattern definitions and instance overrides --- skills/block-runner/references/GUIDE.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/skills/block-runner/references/GUIDE.md b/skills/block-runner/references/GUIDE.md index c3d7549..02c7fa5 100644 --- a/skills/block-runner/references/GUIDE.md +++ b/skills/block-runner/references/GUIDE.md @@ -106,7 +106,7 @@ to adding a plugin merely because it is easier to generate. The handoff is descr | A reusable named block that must live in plugin or theme source | **`author preview`** → confirmation → **`author write`**, then package and proof it | This produces registered-block source. It is not page `post_content`. | | New content for a page or post, with no authored HTML | **`assemble`** | An intent tree becomes native page blocks. | | Existing authored HTML that must become page or post `post_content` | **`convert`** | Rule-based translation of existing markup, and the only content path that carries CSS. Do not use frontend-scraped render output. | -| Block markup you already produced, before saving it to WordPress | **`validate`** → **`fix`** → **`validate`** | Proves the editor will accept it. | +| Block markup you already produced, before saving it to WordPress | **`validate`** → **`fix`** → **`validate`** | Checks block markup; site references and editor behavior still need target-site verification. | Choose by the requested artifact, not merely by the input format. A supplied HTML design still uses registered-block authoring when the user wants a reusable named block in code. Conversely, @@ -198,6 +198,24 @@ design yourself and describing it as an intent tree instead. ## 5. The pre-flight loop — before page markup is saved +### Synced patterns + +The shared pattern definition stores its blocks in a `wp_block` post. A page instance stores +only the reference and optional per-instance overrides, for example: + +```html + +``` + +Read the referenced pattern before choosing override keys: `Heading` must exactly match a +child's `metadata.name`, and that child's bindings must enable the requested attribute through +`core/pattern-overrides` (either an explicit attribute binding or `__default`). Never put inline +children inside `wp:block`; they are not saved as instance overrides. `validate`, `fix`, and +CLI `assemble` reject that shape, and `fix` cannot safely infer a replacement. For intent JSON, +put `ref` and `content` in `attrs` and omit `children`. If the user wants a detached copy, use +ordinary blocks explicitly. Validating markup offline does not verify that the referenced +pattern exists or that its override names match. Confirm those on the target site. + Run this on block markup before you write it to WordPress: ```bash