Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions dev/test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<!-- wp:block {"ref":123} --><!-- wp:paragraph --><p>Keep me</p><!-- /wp:paragraph --><!-- /wp:block -->';
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' } }));
Expand Down
21 changes: 21 additions & 0 deletions dev/test/convert.test.ts
Original file line number Diff line number Diff line change
@@ -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`
<!-- wp:html -->
Expand All @@ -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('<section>Keep this content</section>', {
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',
Expand Down
44 changes: 44 additions & 0 deletions dev/test/gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,50 @@ import { describe, expect, it } from 'vitest';
import { canonicalize, validate } from '../../src/index.js';

describe('gate', () => {
const inline = '<!-- wp:paragraph --><p>Keep this content</p><!-- /wp:paragraph -->';
const reference = `<!-- wp:block {"ref":123} -->${inline}<!-- /wp:block -->`;

it.each([
reference,
`<!-- wp:group --><div class="wp-block-group">${reference}</div><!-- /wp:group -->`,
'<!-- wp:block {"ref":123} --><p>Keep this content</p><!-- /wp:block -->',
])('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(`<!-- wp:block {"ref":123} /-->\n${reference}`);
expect(report.items[0]?.source?.htmlLine).toBe(2);
});

it.each([
'<!-- wp:block {"ref":123} /-->',
'<!-- wp:block {"ref":123} --> \n <!-- /wp:block -->',
'<!-- wp:block {"ref":123,"content":{"Text":{"content":"Custom text"}}} /-->',
'<!-- wp:block {"ref":123,"overrides":{"Text":{"content":"Custom text"}}} /-->',
'<!-- wp:block {"ref":123,"content":{"Text":{"values":{"content":"Custom text"}}}} /-->',
`<!-- wp:group --><div class="wp-block-group">${inline}</div><!-- /wp:group -->`,
'<!-- wp:paragraph {"metadata":{"name":"Text","bindings":{"__default":{"source":"core/pattern-overrides"}}}} --><p>Default</p><!-- /wp:paragraph -->',
])('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('<!-- wp:paragraph --><p>Hello</p><!-- /wp:paragraph -->');

Expand Down
23 changes: 23 additions & 0 deletions dev/test/intent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<!-- wp:block {"ref":123,"content":{"Text":{"content":"Custom text"}}} /-->');
});

it('assembles a nested cover, columns, and buttons tree into valid markup', async () => {
const report = await realize(
JSON.stringify({
Expand Down
20 changes: 19 additions & 1 deletion skills/block-runner/references/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
<!-- wp:block {"ref":123,"content":{"Heading":{"content":"Custom heading"}}} /-->
```

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
Expand Down
13 changes: 12 additions & 1 deletion src/convert/finalize.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -32,6 +33,16 @@ export async function finalizeBlocks(
context: FinalizeContext,
): Promise<BlockRunnerReport> {
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);
Expand Down
6 changes: 6 additions & 0 deletions src/gate/canonicalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions src/gate/patterns.ts
Original file line number Diff line number Diff line change
@@ -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;
}
15 changes: 9 additions & 6 deletions src/gate/validate.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand All @@ -40,22 +44,21 @@ 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;
continue;
}

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,
});
}
Expand Down
Loading