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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Warn when the native block constructor discards explicitly supplied intent attributes, including the attribute key and input path without raw values. Warnings remain informational in strict mode.
- Preserve nested lists inside intent list items.
- Reject malformed intent nodes, children, list items and table cells with input-located errors instead of returning partial content.
- Reject unregistered intent block names in normal and strict mode. This changes the previous warning-only behavior: `realize` returns a failed report with empty output, and low-level `assemble` rejects. Registering custom block types remains outside the public API.
Expand Down
52 changes: 52 additions & 0 deletions dev/test/intent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,58 @@ import type { IntentNode } from '../../src/types.js';
const FIXTURES = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixtures');

describe('intent assembly', () => {
it.each([false, true])('warns for discarded explicit attributes without failing (strict: %s)', async (strict) => {
const report = await realize(JSON.stringify({ blocks: [{
block: 'core/group', children: [{
block: 'core/paragraph', text: 'Example', attrs: { textAlign: 'center', madeUp: 'private-value' },
}],
}] }), { strict, sourcePath: 'intent.json' });
expect(report.ok).toBe(true);
expect(report.summary.warnings).toBe(2);
expect(report.output).toContain('<p>Example</p>');
for (const key of ['textAlign', 'madeUp']) {
expect(report.items).toContainEqual(expect.objectContaining({
code: 'intent-attribute-dropped', block: 'core/paragraph', status: 'warning',
source: { path: 'intent.json' },
details: { intentPath: `blocks[0].children[0].attrs[${JSON.stringify(key)}]`, attribute: key },
}));
}
expect(JSON.stringify(report.items)).not.toContain('private-value');
});

it('keeps discarded attributes informational even when keys contain strict-warning text', async () => {
const report = await realize(JSON.stringify({ blocks: [{
block: 'core/paragraph', text: 'Example', attrs: { 'no ID': true, 'Custom HTML fallback': true },
}] }), { strict: true });
expect(report.ok).toBe(true);
expect(report.summary.warnings).toBe(2);
expect(report.items.every((item) => item.code === 'intent-attribute-dropped')).toBe(true);
});

it('does not confuse accepted native attributes or normalization with discarded keys', async () => {
const report = await realize(JSON.stringify({ blocks: [{
block: 'core/group', attrs: { layout: { type: 'constrained' } }, children: [{
block: 'core/paragraph', text: 'Shorthand wins', attrs: {
content: '<strong>Attribute content</strong>', dropCap: false,
style: { typography: { textAlign: 'center' }, custom: { untouched: true } },
metadata: { name: 'Text', bindings: { content: { source: 'core/post-meta', args: { key: 'summary' } } } },
},
}, {
block: 'core/paragraph', attrs: { content: '<strong>Rich text</strong>' },
}],
}] }));
expect(report.ok).toBe(true);
expect(report.summary.warnings).toBe(0);
expect(report.output).toContain('Shorthand wins');
expect(report.output).not.toContain('Attribute content');
expect(report.output).toContain('<strong>Rich text</strong>');
const wp = await getWp();
const paragraph = wp.parse(report.output!)[0].innerBlocks[0];
expect(paragraph.attributes.style).toMatchObject({ typography: { textAlign: 'center' }, custom: { untouched: true } });
expect(paragraph.attributes.metadata).toMatchObject({ name: 'Text', bindings: { content: { source: 'core/post-meta' } } });
expect(await assemble([{ block: 'core/paragraph', text: 'Still an array', attrs: { madeUp: true } }])).toHaveLength(1);
});

it.each([false, true])('preserves nested lists and siblings (ordered: %s)', async (ordered) => {
const report = await realize(JSON.stringify({ blocks: [{
block: 'core/list', attrs: { ordered }, children: [
Expand Down
13 changes: 8 additions & 5 deletions skills/block-runner/references/ASSEMBLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,14 @@ printf '%s' "$INTENT_JSON" | npx -y block-runner@latest assemble - --json
Top level is `{ "blocks": [ ...nodes ] }`.

`attrs` is an open passthrough — put any extra block attribute there (`{"ordered": true}` for
a numbered list, `{"service": "github", "url": "..."}` for a social link). Unknown attributes
are harmless; a block ignores what it does not recognise.

That tolerance cuts both ways: a key WordPress does not declare is dropped without a word, so a
wrong shape reads as a success. Two cases where that matters:
a numbered list, `{"service": "github", "url": "..."}` for a social link). When Gutenberg discards
an explicitly supplied key, the assembly report includes an `intent-attribute-dropped` warning
with the block name, attribute key and exact input path, without its value. These warnings stay
informational in normal and strict mode. The low-level library `assemble()` still returns block
objects; use `realize()` for the report and shared finalization.

An accepted key does not prove its value is meaningful to a theme. Nested `style` objects remain
open, and normal defaults or rich-text normalization are not reported as loss. Two cases to check:

**A nested style attribute goes in at its full path.** A full-width button is
`{"style":{"dimensions":{"width":"100%"}}}`, not `{"width":100}`; the flat key is not a declared
Expand Down
1 change: 1 addition & 0 deletions src/convert/finalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export async function finalizeBlocks(
}

function isStrictFailureWarning(item: ReportItem): boolean {
if (item.code === 'intent-attribute-dropped') return false;
return /Custom HTML fallback|unresolved media|no ID|media map has no ID|sideload is disabled|file not found/i.test(
item.reason,
);
Expand Down
68 changes: 48 additions & 20 deletions src/intent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,16 @@ import {
HeadlessBootError,
IntentNode,
IntentTree,
ReportItem,
WpBlock,
WpModules,
} from '../types.js';

interface AssemblyDiagnostics {
warnings: ReportItem[];
sourcePath?: string;
}

interface ExtractResult {
parsed: boolean;
tree: IntentTree;
Expand Down Expand Up @@ -73,48 +79,70 @@ export async function assemble(nodes: IntentNode[]): Promise<WpBlock[]> {
return assembleWithWp(nodes, await getWp());
}

async function assembleWithWp(nodes: IntentNode[], wp: WpModules, intentPath = 'blocks'): Promise<WpBlock[]> {
async function assembleWithWp(
nodes: IntentNode[], wp: WpModules, intentPath = 'blocks', diagnostics?: AssemblyDiagnostics,
): Promise<WpBlock[]> {
if (!Array.isArray(nodes)) throw new Error(`${intentPath}: expected an array of block nodes`);
const out: WpBlock[] = [];
for (const [index, node] of nodes.entries()) {
out.push(await assembleNode(node, wp, `${intentPath}[${index}]`));
out.push(await assembleNode(node, wp, `${intentPath}[${index}]`, diagnostics));
}
return out;
}

async function assembleNode(node: IntentNode, wp: WpModules, intentPath: string): Promise<WpBlock> {
async function assembleNode(
node: IntentNode, wp: WpModules, intentPath: string, diagnostics?: AssemblyDiagnostics,
): Promise<WpBlock> {
if (!node || typeof node.block !== 'string' || !node.block.trim()) {
throw new Error(`${intentPath}: expected a node with a non-empty block name`);
}
const name = node.block;
if (!wp.getBlockType(name)) throw new Error(`${intentPath}: block type ${JSON.stringify(name)} is not registered`);
const text = node.text;
const attrs: Record<string, unknown> = { ...(node.attrs ?? {}) };
const children = await assembleWithWp(node.children === undefined ? [] : node.children, wp, `${intentPath}.children`);
const children = await assembleWithWp(node.children === undefined ? [] : node.children, wp, `${intentPath}.children`, diagnostics);

const createBlock = (innerBlocks: WpBlock[]): WpBlock => {
const block = wp.createBlock(name, attrs, innerBlocks);
if (diagnostics) {
for (const key of Object.keys(node.attrs ?? {})) {
if (Object.hasOwn(block.attributes, key)) continue;
diagnostics.warnings.push({
code: 'intent-attribute-dropped',
block: name,
status: 'warning',
reason: `attribute ${JSON.stringify(key)} was discarded by the registered block constructor`,
source: diagnostics.sourcePath ? { path: diagnostics.sourcePath } : undefined,
details: { intentPath: `${intentPath}.attrs[${JSON.stringify(key)}]`, attribute: key },
});
}
}
return block;
};

switch (name) {
case 'core/heading':
if (text != null) attrs.content = text;
attrs.level = node.level ?? attrs.level ?? 2;
return wp.createBlock(name, attrs, []);
return createBlock([]);

case 'core/paragraph':
if (text != null) attrs.content = text;
return wp.createBlock(name, attrs, []);
return createBlock([]);

case 'core/list-item':
if (text != null) attrs.content = text;
return wp.createBlock(name, attrs, children);
return createBlock(children);

case 'core/button':
if (text != null) attrs.text = text;
if (node.url != null) attrs.url = node.url;
return wp.createBlock(name, attrs, []);
return createBlock([]);

case 'core/image':
if (node.url != null) attrs.url = node.url;
if (node.alt != null) attrs.alt = node.alt;
return wp.createBlock(name, attrs, []);
return createBlock([]);

case 'core/list': {
let items = children;
Expand All @@ -125,7 +153,7 @@ async function assembleNode(node: IntentNode, wp: WpModules, intentPath: string)
return wp.createBlock('core/list-item', { content: item }, []);
});
}
return wp.createBlock(name, attrs, items);
return createBlock(items);
}

case 'core/quote': {
Expand All @@ -134,17 +162,17 @@ async function assembleNode(node: IntentNode, wp: WpModules, intentPath: string)
if (body.length === 0 && text != null) {
body.push(wp.createBlock('core/paragraph', { content: text }, []));
}
return wp.createBlock(name, attrs, body);
return createBlock(body);
}

case 'core/details':
if (text != null) attrs.summary = text;
return wp.createBlock(name, attrs, children);
return createBlock(children);

case 'core/pullquote':
if (text != null) attrs.value = text;
if (node.citation != null) attrs.citation = node.citation;
return wp.createBlock(name, attrs, []);
return createBlock([]);

case 'core/table': {
if (node.rows !== undefined && !Array.isArray(node.rows)) {
Expand All @@ -161,31 +189,31 @@ async function assembleNode(node: IntentNode, wp: WpModules, intentPath: string)
attrs.head = [toCells(node.rows[0], 0, 'th')];
attrs.body = node.rows.slice(1).map((row, index) => toCells(row, index + 1, 'td'));
}
return wp.createBlock(name, attrs, []);
return createBlock([]);
}

case 'core/cover':
if (node.url != null) attrs.url = node.url;
return wp.createBlock(name, attrs, children);
return createBlock(children);

case 'core/media-text':
if (node.url != null) {
attrs.mediaUrl = node.url;
attrs.mediaType = 'image';
}
if (node.alt != null) attrs.mediaAlt = node.alt;
return wp.createBlock(name, attrs, children);
return createBlock(children);

case 'core/columns':
case 'core/column':
case 'core/buttons':
case 'core/group':
case 'core/gallery':
return wp.createBlock(name, attrs, children);
return createBlock(children);

default:
if (text != null) attrs.content = text;
return wp.createBlock(name, attrs, children);
return createBlock(children);
}
}

Expand All @@ -204,8 +232,8 @@ export async function realize(rawIntent: string, options: AssembleOptions = {}):
try {
const config = await loadConfig(options);
const wp = await getWp();
const blocks = await assembleWithWp(extracted.tree.blocks, wp);
const warnings: BlockRunnerReport['items'] = [];
const warnings: ReportItem[] = [];
const blocks = await assembleWithWp(extracted.tree.blocks, wp, 'blocks', { warnings, sourcePath: options.sourcePath });
if (config.styling !== DEFAULT_CONFIG.styling) {
warnings.push({
block: 'input',
Expand Down
Loading