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
11 changes: 11 additions & 0 deletions dev/test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,17 @@ describe('CLI', () => {
expect(report.items[0]?.reason).toBe('could not parse intent JSON');
});

it('preserves an output sentinel when intent assembly fails', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'block-runner-cli-'));
const outPath = path.join(dir, 'out.html');
await writeFile(outPath, 'Keep existing output');
const result = await runCli(['assemble', '-', '--out', outPath],
'{"blocks":[{"block":"core/paragraph","text":"First"},{"text":"Second"}]}');
expect(result.code).toBe(1);
expect(result.stdout).toContain('blocks[1]: expected a node with a non-empty block name');
expect(await readFile(outPath, 'utf8')).toBe('Keep existing output');
});

it('rejects styling flags on assemble and points callers to convert', async () => {
for (const args of [
['assemble', '-', '--styling', 'strict'],
Expand Down
63 changes: 60 additions & 3 deletions dev/test/intent.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { realize, validate } from '../../src/index.js';
import { describe, expect, it, vi } from 'vitest';
import { assemble, realize, validate } from '../../src/index.js';
import { getWp } from '../../src/headless/wp.js';
import * as media from '../../src/media/resolver.js';
import type { IntentNode } from '../../src/types.js';

const FIXTURES = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixtures');

Expand Down Expand Up @@ -142,7 +144,62 @@ describe('intent assembly', () => {
const report = await realize('{"blocks":[{}]}');

expect(report.ok).toBe(false);
expect(report.items[0]?.reason).toBe('intent parsed but contained no blocks');
expect(report.items[0]?.reason).toContain('blocks[0]: expected a node with a non-empty block name');
});

it.each([
[[{ block: 'core/paragraph', text: 'First' }, { text: 'Second' }], 'blocks[1]'],
[[{ block: 'core/group', children: [null] }], 'blocks[0].children[0]'],
[[{ block: 'core/group', children: {} }], 'blocks[0].children'],
[[{ block: 'core/group', children: null }], 'blocks[0].children'],
[[{ block: '' }], 'blocks[0]'],
[[{ block: 'core/list', items: ['First', { text: 'Second' }] }], 'blocks[0].items[1]'],
[[{ block: 'core/list', items: 'First' }], 'blocks[0].items'],
[[{ block: 'core/table', rows: [['First'], [{ text: 'Second' }]] }], 'blocks[0].rows[1][0]'],
[[{ block: 'core/table', rows: ['First'] }], 'blocks[0].rows[0]'],
[[{ block: 'core/table', rows: {} }], 'blocks[0].rows'],
])('rejects malformed consumed input %j at %s', async (nodes, location) => {
const report = await realize(JSON.stringify({ blocks: nodes }), { sourcePath: 'intent.json' });
expect(report.ok).toBe(false);
expect(report.output).toBe('');
expect(report.items[0]).toMatchObject({ status: 'invalid', source: { path: 'intent.json' } });
expect(report.items[0].reason).toContain(`${location}: expected`);
await expect(assemble(nodes as IntentNode[])).rejects.toThrow(`${location}: expected`);
});

it('rejects malformed input before constructing the media resolver', async () => {
const resolver = vi.spyOn(media, 'createMediaResolver');
try {
const report = await realize(JSON.stringify({ blocks: [
{ block: 'core/image', url: 'photo.jpg' }, { text: 'Missing name' },
] }));
expect(report.ok).toBe(false);
expect(resolver).not.toHaveBeenCalled();
} finally {
resolver.mockRestore();
}
});

it.each([
'{"block":"core/paragraph","text":"Text"}',
'[{"block":"core/paragraph","text":"Text"}]',
'{"blocks":[{"block":"core/paragraph","text":"Text"}]}',
'```json\n{"blocks":[{"block":"core/paragraph","text":"Text"}]}\n```',
])('keeps valid input wrappers', async (raw) => {
expect((await realize(raw)).ok).toBe(true);
});

it('keeps explicit tables and ignores unused list shorthand', async () => {
const report = await realize(JSON.stringify({ blocks: [
{ block: 'core/list', items: { unused: true }, children: [{ block: 'core/list-item', text: 'Kept' }] },
{ block: 'core/table', attrs: { body: [{ cells: [{ content: 'Body only', tag: 'td' }] }] } },
{ block: 'core/table', rows: [['Header'], ['Cell']] },
] }));
expect(report.ok).toBe(true);
expect(report.output).toContain('Kept');
expect(report.output).toContain('<td>Body only</td>');
expect(report.output).toContain('<th>Header</th>');
expect(report.output).toContain('<td>Cell</td>');
});

it('warns on an unregistered block name without failing the run', async () => {
Expand Down
45 changes: 29 additions & 16 deletions src/intent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,21 +74,23 @@ export async function assemble(nodes: IntentNode[]): Promise<WpBlock[]> {
return assembleWithWp(nodes, await getWp());
}

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

async function assembleNode(node: IntentNode, wp: WpModules): Promise<WpBlock | null> {
if (!node || typeof node.block !== 'string') return null;
async function assembleNode(node: IntentNode, wp: WpModules, intentPath: string): 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;
const text = node.text;
const attrs: Record<string, unknown> = { ...(node.attrs ?? {}) };
const children = await assembleWithWp(node.children ?? [], wp);
const children = await assembleWithWp(node.children === undefined ? [] : node.children, wp, `${intentPath}.children`);

switch (name) {
case 'core/heading':
Expand Down Expand Up @@ -116,8 +118,12 @@ async function assembleNode(node: IntentNode, wp: WpModules): Promise<WpBlock |

case 'core/list': {
let items = children;
if (items.length === 0 && Array.isArray(node.items)) {
items = node.items.map((item) => wp.createBlock('core/list-item', { content: item }, []));
if (items.length === 0 && node.items !== undefined) {
if (!Array.isArray(node.items)) throw new Error(`${intentPath}.items: expected an array of strings`);
items = node.items.map((item, index) => {
if (typeof item !== 'string') throw new Error(`${intentPath}.items[${index}]: expected a string`);
return wp.createBlock('core/list-item', { content: item }, []);
});
}
return wp.createBlock(name, attrs, items);
}
Expand All @@ -141,12 +147,19 @@ async function assembleNode(node: IntentNode, wp: WpModules): Promise<WpBlock |
return wp.createBlock(name, attrs, []);

case 'core/table': {
if (Array.isArray(node.rows) && node.rows.length > 0) {
const toCells = (row: string[], tag: 'th' | 'td'): { cells: { content: string; tag: string }[] } => ({
cells: row.map((content) => ({ content, tag })),
});
attrs.head = [toCells(node.rows[0], 'th')];
attrs.body = node.rows.slice(1).map((row) => toCells(row, 'td'));
if (node.rows !== undefined && !Array.isArray(node.rows)) {
throw new Error(`${intentPath}.rows: expected an array of string arrays`);
}
if (node.rows && node.rows.length > 0) {
const toCells = (row: string[], index: number, tag: 'th' | 'td'): { cells: { content: string; tag: string }[] } => {
if (!Array.isArray(row)) throw new Error(`${intentPath}.rows[${index}]: expected an array of strings`);
return { cells: row.map((content, cell) => {
if (typeof content !== 'string') throw new Error(`${intentPath}.rows[${index}][${cell}]: expected a string`);
return { content, tag };
}) };
};
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, []);
}
Expand Down Expand Up @@ -191,6 +204,7 @@ 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 = unknownBlockWarnings(extracted.tree.blocks, wp, options.sourcePath);
if (config.styling !== DEFAULT_CONFIG.styling) {
warnings.push({
Expand All @@ -200,7 +214,6 @@ export async function realize(rawIntent: string, options: AssembleOptions = {}):
source: options.sourcePath ? { path: options.sourcePath } : undefined,
});
}
const blocks = await assembleWithWp(extracted.tree.blocks, wp);
if (blocks.length === 0) {
return invalidInputReport('intent parsed but contained no blocks', options);
}
Expand Down
Loading