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
62 changes: 56 additions & 6 deletions src/author/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ export async function author(input: string, options: AuthorOptions = {}): Promis
command: 'author',
source,
summary: { blocks: 0, valid: 0, invalid: 0, warnings: graphItems.length + fontItems.length },
items: [...fontItems, ...graphItems],
items: [...stageSourceAnalysisItems(fontItems), ...stageSourceAnalysisItems(graphItems)],
styleLedger: safetyLedger,
assets: assets.length > 0 ? assets : undefined,
evidence: { ...evidence, dependencies: [...evidence.dependencies, { kind: 'tailwind-build', reference: 'pinned compiler/build graph' }], coverage: sourceCoverage(safetyLedger, assets, [...preparedAssets.values()], styleInput, editorStyleInput, definition, options, fontWarnings) },
Expand All @@ -375,7 +375,7 @@ export async function author(input: string, options: AuthorOptions = {}): Promis
command: 'author',
source,
summary: { blocks: 0, valid: 0, invalid: 0, warnings: graphItems.length + fontItems.length },
items: [...fontItems, ...graphItems],
items: [...stageSourceAnalysisItems(fontItems), ...stageSourceAnalysisItems(graphItems)],
styleLedger: safetyLedger,
assets: assets.length > 0 ? assets : undefined,
evidence: { ...evidence, coverage: sourceCoverage(safetyLedger, assets, [...preparedAssets.values()], styleInput, editorStyleInput, definition, options, fontWarnings) },
Expand All @@ -402,7 +402,11 @@ export async function author(input: string, options: AuthorOptions = {}): Promis
...nativeSource.conversion.summary,
warnings: nativeSource.conversion.summary.warnings + graphItems.length + fontItems.length,
},
items: [...nativeSource.conversion.items, ...fontItems, ...graphItems],
items: [
...stageSourceAnalysisItems(nativeSource.conversion.items),
...stageSourceAnalysisItems(fontItems),
...stageSourceAnalysisItems(graphItems),
],
styleLedger: preflightStyleLedger,
assets: assets.length > 0 ? assets : undefined,
evidence: { ...evidence, coverage: sourceCoverage(preflightStyleLedger, assets, [...preparedAssets.values()], styleInput, editorStyleInput, definition, options, fontWarnings) },
Expand Down Expand Up @@ -484,7 +488,27 @@ export async function author(input: string, options: AuthorOptions = {}): Promis
selectorDependencies: selectorTransport.dependencies,
});
} catch (error) {
return authorFailure(error, source, evidence);
const failureItems = stageFinalProposalItems(authoringFailureItems('input', error));
return {
...conversion,
ok: false,
command: 'author',
source,
summary: {
...conversion.summary,
warnings: conversion.summary.warnings + fontItems.length + graphItems.length + assetItems.length + failureItems.length,
},
items: [
...stageSourceAnalysisItems(conversion.items),
...stageSourceAnalysisItems(fontItems),
...stageSourceAnalysisItems(graphItems),
...stageSourceAnalysisItems(assetItems),
...failureItems,
],
assets: assets.length > 0 ? assets : undefined,
styleLedger,
evidence,
};
}
}
let compiled: ReturnType<typeof compileAnalyzedDesign> | undefined;
Expand Down Expand Up @@ -602,7 +626,13 @@ export async function author(input: string, options: AuthorOptions = {}): Promis
...conversion.summary,
warnings: conversion.summary.warnings + fontItems.length + graphItems.length + assetItems.length + generationItems.length,
},
items: [...conversion.items, ...fontItems, ...graphItems, ...assetItems, ...generationItems],
items: [
...stageSourceAnalysisItems(conversion.items),
...stageSourceAnalysisItems(fontItems),
...stageSourceAnalysisItems(graphItems),
...stageSourceAnalysisItems(assetItems),
...stageFinalProposalItems(generationItems),
],
assets: assets.length > 0 ? assets : undefined,
styleLedger: compiled ? [...styleLedger, ...compiled.editorStyleLedger] : styleLedger,
package: packageSource,
Expand Down Expand Up @@ -735,7 +765,7 @@ function authorFailure(
source?: BlockRunnerReport['source'],
evidence?: AuthorSourceEvidence,
): BlockRunnerReport {
const items = authoringFailureItems('input', failure);
const items = stageSourceAnalysisItems(authoringFailureItems('input', failure));
return {
ok: false,
command: 'author',
Expand All @@ -746,6 +776,26 @@ function authorFailure(
};
}

type AuthorItemStage = { stage: 'intermediate'; phase: 'source-analysis' } | { stage: 'final-proposal' };

function stageAuthorItems(items: readonly ReportItem[], stage: AuthorItemStage): ReportItem[] {
return items.map((item) => ({
...item,
details: {
...(item.details && typeof item.details === 'object' && !Array.isArray(item.details) ? item.details : {}),
...stage,
},
}));
}

function stageSourceAnalysisItems(items: readonly ReportItem[]): ReportItem[] {
return stageAuthorItems(items, { stage: 'intermediate', phase: 'source-analysis' });
}

function stageFinalProposalItems(items: readonly ReportItem[]): ReportItem[] {
return stageAuthorItems(items, { stage: 'final-proposal' });
}

/**
* Read source facts without applying the rules engine. Locations refer to the original HTML and
* unknown elements are intentionally recorded rather than classified as invalid markup.
Expand Down
7 changes: 6 additions & 1 deletion src/author/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,12 @@ function adaptNativeSourceStyles(
});
}
if (!targets?.length) return [rule];
const gridDeclarations = rule.declarations.filter((declaration) => gridProperties.has(declaration.property));
// `display:flex` is ordinary residual layout, not evidence that a Core container owns a
// grid. Only display:grid participates in the native-grid ownership/rejection path.
const hasNonGridDisplay = rule.declarations.some((declaration) => declaration.property === 'display' && declaration.value.trim() !== 'grid');
const gridDeclarations = hasNonGridDisplay ? [] : rule.declarations.filter((declaration) => declaration.property !== 'display'
? gridProperties.has(declaration.property)
: declaration.value.trim() === 'grid');
const unresolved = (reason: string, target = targets[0], declaration = rule.declarations[0]) => {
const sourceRef = target?.sourceRef;
const location = sourceRef ? locationFor(sourceRef) : undefined;
Expand Down
54 changes: 53 additions & 1 deletion src/author/proposal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export function bindAuthoringProposal(input: {
element = index.get(node.sourceRef);
if (!element) throw staleSourceRefDiagnostic('proposal sourceRef', node.sourceRef, node.id, input);
if (isSourceUnit(element) && !isCompatibleSourceBinding(element, node.block)) {
throw new Error(`${describeElement(element, dom, input.sourcePath)}: proposal sourceRef ${node.sourceRef} cannot bind ${element.tagName.toLowerCase()} content to ${node.block}`);
throw incompatibleSourceBindingDiagnostic(node, element, dom, input);
}
if (ownsContent(node.block)) {
const overlapping = [...usedElements.entries()].find(([, candidate]) => candidate === element || candidate.contains(element!) || element!.contains(candidate));
Expand All @@ -91,6 +91,7 @@ export function bindAuthoringProposal(input: {
return { id: node.id, block: node.block, ...(Object.keys(attributes).length ? { attributes } : {}), ...(node.lock ? { lock: node.lock } : {}), ...(node.children?.length ? { children: node.children.map(bind) } : {}) };
};
const structure = input.proposal.structure.map(bind);
validateNativeProposalRelationships(input.proposal.structure, index, dom, input);
const nodeIds = new Set(flatten(input.proposal.structure).map((node) => node.id));
validateDecisions(decisions, index, sourceRefToNode, nodeIds, input, dom);
return {
Expand All @@ -115,6 +116,57 @@ export function bindAuthoringProposal(input: {
} finally { dom.window.close(); }
}

/** Validate native parentage while source and proposal references are still directly available. */
function validateNativeProposalRelationships(
nodes: readonly AuthoringProposalNode[],
index: ReadonlyMap<string, Element>,
dom: JSDOM,
input: Parameters<typeof bindAuthoringProposal>[0],
parent?: AuthoringProposalNode,
): void {
for (const node of nodes) {
if (node.block === 'core/button' && parent?.block !== 'core/buttons') {
const element = node.sourceRef ? index.get(node.sourceRef) : undefined;
throw authorDiagnostic(
'invalid-proposal-relationship',
`proposal node ${node.id} cannot bind core/button outside a core/buttons parent`,
sourceLocation(element, dom, input.sourcePath),
{
sourceRef: node.sourceRef,
node: node.id,
selectedParent: parent ? { node: parent.id, block: parent.block } : null,
requiredRelationship: { parentBlock: 'core/buttons', relationship: 'direct-child' },
action: 'place-core-button-under-core-buttons',
},
);
}
validateNativeProposalRelationships(node.children ?? [], index, dom, input, node);
}
}

function incompatibleSourceBindingDiagnostic(
node: AuthoringProposalNode,
element: Element,
dom: JSDOM,
input: Parameters<typeof bindAuthoringProposal>[0],
): ReturnType<typeof authorDiagnostic> {
const sourceTag = element.tagName.toLowerCase();
const expectedBlock = sourceTag === 'figure' || sourceTag === 'img' ? 'core/image' : undefined;
return authorDiagnostic(
'incompatible-proposal-source-binding',
`${describeElement(element, dom, input.sourcePath)}: proposal sourceRef ${node.sourceRef} cannot bind ${sourceTag} content to ${node.block}`,
sourceLocation(element, dom, input.sourcePath),
{
sourceRef: node.sourceRef,
node: node.id,
block: node.block,
...(expectedBlock ? { requiredBlock: expectedBlock } : {}),
classification: 'incompatible-source-block-binding',
action: expectedBlock === 'core/image' ? 'replace-with-core-image' : 'select-a-compatible-native-block',
},
);
}

function bindContent(node: AuthoringProposalNode, element: Element, attributes: Record<string, JsonValue>, decisions: Map<string, AuthoringProposalDecision>, ref: string): void {
const decision = (attribute: string) => decisions.get(`${ref}:${node.id}:${attribute}`) ?? decisions.get(`${ref}::${attribute}`);
const apply = (attribute: string, value: JsonValue): void => {
Expand Down
45 changes: 44 additions & 1 deletion test/author.diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,50 @@ describe('author diagnostics', () => {
proposal: { structure: [{ id: 'group', block: 'core/group', sourceRef: sourceRef(html, 'div') }, { id: 'button', block: 'core/button', sourceRef: sourceRef(html, 'a') }] },
});
expect(report.items).toEqual(expect.arrayContaining([
expect.objectContaining({ code: 'unresolved-native-style-mapping', details: expect.objectContaining({ classification: 'unsupported-native-style-mapping', unsupportedReason: expect.any(String) }) }),
expect.objectContaining({
code: 'invalid-proposal-relationship',
details: expect.objectContaining({ requiredRelationship: { parentBlock: 'core/buttons', relationship: 'direct-child' }, action: 'place-core-button-under-core-buttons', stage: 'final-proposal' }),
}),
]));
});

it('keeps source analysis visible when a final proposal relationship is rejected', async () => {
const html = '<a href="/go">Go</a><figure><img src="https://example.test/photo.jpg" alt="Photo"><figcaption><svg viewBox="0 0 2 2"><rect/></svg></figcaption></figure>';
const proposal = { structure: [{ id: 'cta', block: 'core/button', sourceRef: sourceRef(html, 'a') }] };
const options = { author: { name: 'example/diagnostic-stages' }, proposal };
const [first, second] = await Promise.all([author(html, options), author(html, options)]);
expect(first.ok).toBe(false);
expect(first.package).toBeUndefined();
expect(first.items).toEqual(second.items);
expect(first.items.find((item) => /Custom HTML fallback/i.test(item.reason)))
.toMatchObject({ details: { stage: 'intermediate', phase: 'source-analysis' } });
expect(first.items.find((item) => item.code === 'invalid-proposal-relationship')).toMatchObject({
source: { offset: 0 },
details: {
sourceRef: sourceRef(html, 'a'),
node: 'cta',
selectedParent: null,
requiredRelationship: { parentBlock: 'core/buttons', relationship: 'direct-child' },
action: 'place-core-button-under-core-buttons',
stage: 'final-proposal',
},
});
});

it('names the core/image correction for a figure bound to an incompatible block', async () => {
const html = '<figure><img src="https://example.test/photo.jpg" alt="Photo"></figure>';
const report = await author(html, {
author: { name: 'example/diagnostic-figure' },
proposal: { structure: [{ id: 'layout', block: 'core/columns', sourceRef: sourceRef(html, 'figure') }] },
});
expect(report.ok).toBe(false);
expect(report.package).toBeUndefined();
expect(report.items.find((item) => item.code === 'incompatible-proposal-source-binding')).toMatchObject({
source: { offset: 0 },
details: {
sourceRef: sourceRef(html, 'figure'), node: 'layout', block: 'core/columns', requiredBlock: 'core/image',
action: 'replace-with-core-image', stage: 'final-proposal',
},
});
});
});
46 changes: 35 additions & 11 deletions test/author.native-style-adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,28 @@ describe('native source-style adapters', () => {
expect(report.items).toEqual(expect.arrayContaining([expect.objectContaining({ code: 'unresolved-native-style-mapping' })]));
});

it('does not treat flex as authored-grid evidence while retaining the core/columns grid rejection', async () => {
const html = '<section class="layout"><p>One</p><p>Two</p></section>';
const proposal = { structure: [{ id: 'columns', block: 'core/columns', sourceRef: refs(html)('section'), children: [
{ id: 'first', block: 'core/column', children: [{ id: 'one', block: 'core/paragraph', sourceRef: refs(html)('p') }] },
{ id: 'second', block: 'core/column', children: [{ id: 'two', block: 'core/paragraph', sourceRef: refs(html)('p', 1) }] },
] }] };
const flex = await author(html, {
author: { name: 'example/columns-flex', styles: { mode: 'css', css: '.layout { display: flex; gap: 1rem; }' } }, proposal,
});
expect(flex.ok, JSON.stringify(flex.items)).toBe(true);
expect(flex.package!.canonicalPlan!.coverage!.styles.find((entry) => entry.property === 'display')?.nativeTargets).toBeUndefined();

const grid = await author(html, {
author: { name: 'example/columns-grid', styles: { mode: 'css', css: '.layout { display: grid; gap: 1rem; }' } }, proposal,
});
expect(grid.ok).toBe(false);
expect(grid.package).toBeUndefined();
expect(grid.items).toEqual(expect.arrayContaining([
expect.objectContaining({ code: 'unresolved-native-style-mapping', reason: expect.stringMatching(/core\/columns cannot own an authored grid/i) }),
]));
});

it('reads complete image class atoms and bounded figure child compounds', () => {
expect(nativeSelectorSubjects('.min-h-\\[680px\\]')).toMatchObject([{ classes: [{ decoded: 'min-h-[680px]' }] }]);
expect(nativeSelectorSubjects('.tracking-\\[0\\.18em\\]')).toMatchObject([{ classes: [{ decoded: 'tracking-[0.18em]' }] }]);
Expand Down Expand Up @@ -182,10 +204,14 @@ describe('native source-style adapters', () => {
author: { name: 'example/unwrapped-button', styles: { mode: 'css', css: 'div .move:hover { color: red; }' } },
proposal: { structure: [{ id: 'section', block: 'core/group', sourceRef: refs(html)('div') }, { id: 'button', block: 'core/button', sourceRef: refs(html)('a') }] },
});
const item = report.items.find((candidate) => candidate.code === 'unresolved-native-style-mapping')!;
const item = report.items.find((candidate) => candidate.code === 'invalid-proposal-relationship')!;
expect(report.package).toBeUndefined();
expect(item.source).toMatchObject({ path: '/Users/warden/Library/Application Support/Block Runner/previews/2026-09-05/export/long-project/preview.html', offset: 21, htmlLine: 1, htmlColumn: 22 });
expect(item.details).toMatchObject({ htmlSource: { path: '/Users/warden/Library/Application Support/Block Runner/previews/2026-09-05/export/long-project/preview.html', offset: 21 }, cssSource: { selector: 'div .move:hover' } });
expect(item.details).toMatchObject({
sourceRef: refs(html)('a'), node: 'button', selectedParent: null,
requiredRelationship: { parentBlock: 'core/buttons', relationship: 'direct-child' },
action: 'place-core-button-under-core-buttons', stage: 'final-proposal',
});
});

it('retains every matched unwrapped anchor for an unsupported relationship', async () => {
Expand All @@ -195,16 +221,14 @@ describe('native source-style adapters', () => {
author: { name: 'example/unwrapped-buttons', styles: { mode: 'css', css: 'div .move:hover { color: red; }' } },
proposal: { structure: [{ id: 'section', block: 'core/group', sourceRef: refs(html)('div') }, { id: 'button-a', block: 'core/button', sourceRef: refs(html)('a') }, { id: 'button-b', block: 'core/button', sourceRef: refs(html)('a', 1) }] },
});
const item = report.items.find((candidate) => candidate.code === 'unresolved-native-style-mapping')!;
const item = report.items.find((candidate) => candidate.code === 'invalid-proposal-relationship')!;
expect(report.package).toBeUndefined();
expect(item.source).toBeUndefined();
expect(item.details).toMatchObject({ cssSource: { selector: 'div .move:hover' } });
const details = item.details as { htmlSources?: unknown };
expect(details.htmlSources).toEqual([
{ sourceRef: refs(html)('a'), path: '/Users/warden/Library/Application Support/Block Runner/previews/2026-09-05/export/long-project/preview.html', offset: 21, line: 1, column: 22 },
{ sourceRef: refs(html)('a', 1), path: '/Users/warden/Library/Application Support/Block Runner/previews/2026-09-05/export/long-project/preview.html', offset: 56, line: 1, column: 57 },
]);
expect(item.details).not.toHaveProperty('htmlSource');
expect(item.source).toMatchObject({ path: '/Users/warden/Library/Application Support/Block Runner/previews/2026-09-05/export/long-project/preview.html', offset: 21, htmlLine: 1, htmlColumn: 22 });
expect(item.details).toMatchObject({
sourceRef: refs(html)('a'), node: 'button-a', selectedParent: null,
requiredRelationship: { parentBlock: 'core/buttons', relationship: 'direct-child' },
action: 'place-core-button-under-core-buttons', stage: 'final-proposal',
});
});

it('rejects adapter targets whose marker is absent from serialized native markup', async () => {
Expand Down
Loading