diff --git a/scripts/build-pattern-overrides-fixture.ts b/scripts/build-pattern-overrides-fixture.ts index abd82c0..e1d78f2 100644 --- a/scripts/build-pattern-overrides-fixture.ts +++ b/scripts/build-pattern-overrides-fixture.ts @@ -18,7 +18,7 @@ import { planStandalonePluginOutput, writePluginOutput, } from '../src/plugin/profile.js'; -import { author } from '../src/author/index.js'; +import { author, collectSourceEvidence } from '../src/author/index.js'; import type { AuthoringPlan } from '../src/authoring/schema.js'; import { validatePatternOverrideContract } from '../src/authoring/pattern-overrides.js'; import { getWp } from '../src/headless/wp.js'; @@ -33,6 +33,7 @@ const planPath = path.join(projectRoot, 'test', 'fixtures', 'authoring', 'patter const visualGoldenPath = fixtureVisualGoldenPath(); const pluginSlug = 'block-runner-pattern-overrides-fixture'; const responsivePluginSlug = 'block-runner-responsive-style-fixture'; +const nativeStyleAdapterPluginSlug = 'block-runner-native-style-adapter-proof'; const fixedTimestamp = new Date('2026-09-03T00:00:00.000Z'); const fixedZipMode = 0o644; @@ -99,6 +100,152 @@ export interface BuiltResponsiveStyleFixture { fixture: ProofFixture; } +/** The retained public-author() package for the utility-hero adapter proof. */ +export interface BuiltNativeStyleAdapterFixture { + /** Hash manifest pinning every retained source and generated proof input. */ + inputPath: string; + pluginDirectory: string; + pluginZip: string; + nativeContainerMarkup: string; + artifact: ProofArtifactContract; + fixture: ProofFixture; +} + +/** + * Build the exact checked-in utility hero through public author(), with the + * small supplied stylesheet that exercises the bounded Button/Image/Group + * mappings. The proposal intentionally contains no WordPress selector or + * wrapper-reset knowledge: those are compiler-owned adapter outputs. + */ +export async function buildNativeStyleAdapterProofFixture(outputDir: string): Promise { + const root = path.resolve(outputDir); + const sourcePath = path.join(projectRoot, 'benchmarks', 'authoring', 'sources', 'utility', 'hero.html'); + const source = await readFile(sourcePath, 'utf8'); + const inputPath = path.join(root, 'native-style-adapter.original.html'); + const cssPath = path.join(root, 'native-style-adapter.supplied.css'); + const proposalPath = path.join(root, 'native-style-adapter.proposal.json'); + const planPath = path.join(root, 'native-style-adapter.canonical-plan.json'); + const blocksPath = path.join(root, 'native-style-adapter.native.blocks.html'); + const identityPath = path.join(root, 'native-style-adapter.plugin-identity.json'); + const pluginDirectory = path.join(root, nativeStyleAdapterPluginSlug); + const pluginZip = path.join(pluginDirectory, `${nativeStyleAdapterPluginSlug}.zip`); + const css = [ + '.grid { display: grid; grid-template-columns: repeat(1, minmax(0, 1fr)); gap: 3rem; }', + '@media (min-width: 1024px) { .lg\\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } }', + '.px-5 { padding-left: 1.25rem; padding-right: 1.25rem; }', + '.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }', + '.transition { transition: transform 150ms, background-color 150ms; }', + '.hover\\:bg-cyan-200:hover { transform: translateY(-2px); background-color: rgb(165, 243, 252); }', + '.focus-visible\\:outline:focus-visible { outline: 2px solid rgb(103, 232, 249); }', + // Width is intentionally authored CSS. The compiler must preserve the + // source intrinsic ratio without allowing native image attributes to take + // ownership of this axis through WordPress inline sizing. + 'figure.relative > img.relative { width: 100%; border: 1px solid rgb(255, 255, 255); }', + 'figure.relative > figcaption.mt-3 { color: rgb(148, 163, 184); }', + ].join('\n'); + const entries = collectSourceEvidence(source, { entry: inputPath, sha256: createHash('sha256').update(source, 'utf8').digest('hex'), format: 'html' }).structure; + const ref = (tag: string, index = 0): string => entries.filter((entry) => entry.tag === tag)[index]?.sourceRef + ?? (() => { throw new Error(`Utility hero proof fixture could not bind ${tag}[${index}].`); })(); + const proposal = { + structure: [{ id: 'hero', block: 'core/group', sourceRef: ref('section'), children: [ + { id: 'hero-grid', block: 'core/group', sourceRef: ref('div', 0), children: [ + { id: 'copy', block: 'core/group', sourceRef: ref('div', 1), children: [ + { id: 'eyebrow', block: 'core/paragraph', sourceRef: ref('p', 0) }, + { id: 'title', block: 'core/heading', sourceRef: ref('h1') }, + { id: 'lede', block: 'core/paragraph', sourceRef: ref('p', 1) }, + { id: 'buttons', block: 'core/buttons', sourceRef: ref('div', 2), children: [ + { id: 'download', block: 'core/button', sourceRef: ref('a', 0) }, + { id: 'guide', block: 'core/button', sourceRef: ref('a', 1) }, + ] }, + ] }, + { id: 'image', block: 'core/image', sourceRef: ref('figure') }, + ] }, + ] }], + fields: [{ id: 'title-content', label: 'Title', mode: 'editable' as const, node: 'title', attribute: 'content' }], + locking: { mode: 'contentOnly' as const }, + }; + await Promise.all([writeFixed(inputPath, source), writeFixed(cssPath, `${css}\n`), writeFixed(proposalPath, `${JSON.stringify(proposal, null, 2)}\n`)]); + const result = await author(source, { + sourcePath: inputPath, + author: { name: 'block-runner/native-style-adapter-proof', styles: { mode: 'css', css } }, + proposal, + }); + if (!result.package?.canonicalPlan) { + throw new Error(`Public author() did not produce the native style adapter proof package: ${result.items.map((item) => item.reason).join('; ')}`); + } + const plan = result.package.canonicalPlan; + const generated = compileRegisteredBlock(plan); + const nativeContainerMarkup = await serializeNativeTemplate(generated.template); + await mkdir(pluginDirectory, { recursive: true }); + const packagePlan = await planStandalonePluginOutput(pluginDirectory, { name: plan.target.name, files: result.package.files }); + await writePluginOutput(packagePlan); + const npmEnvironment = await npmEnvironmentForGeneratedPlugin(pluginDirectory); + await execFileAsync('npm', ['ci', '--include=dev', '--ignore-scripts', '--no-audit', '--no-fund'], { + cwd: pluginDirectory, timeout: 180_000, env: npmEnvironment, + }); + await buildDeterministicPluginZip(pluginDirectory, npmEnvironment); + await execFileAsync('npm', ['run', 'test:zip', '--', pluginZip], { + cwd: pluginDirectory, timeout: 30_000, env: { ...npmEnvironment, TZ: 'UTC' }, + }); + const artifact: ProofArtifactContract = { + sha256: `sha256:${createHash('sha256').update(await readFile(pluginZip)).digest('hex')}`, + capabilities: { patternOverrides: false }, + }; + const fixture: ProofFixture = { + blockName: plan.target.name, + pluginSlug: nativeStyleAdapterPluginSlug, + blockTitle: plan.target.title, + // The utility hero has several native rich-text blocks. Target its source + // heading explicitly so the proof cannot edit a paragraph or button by + // accident, then verify that exact edited value after reopening. + editableFields: [{ path: 'title-content', surface: 'richText', selector: 'h1.wp-block-heading[contenteditable="true"]', value: 'Native style adapter proof saved title' }], + nativeStyleAdapterMatrix: { + button: { + wrapperSelector: '.block-runner-native-download', + linkSelector: '.block-runner-native-download > .wp-block-button__link', + linkPadding: { top: '12px', right: '20px', bottom: '12px', left: '20px' }, + hover: { transform: 'matrix(1, 0, 0, 1, 0, -2)', backgroundColor: 'rgb(165, 243, 252)' }, + focusOutline: { style: 'solid', width: '2px' }, + }, + image: { + selector: 'figure.wp-block-image.block-runner-native-image > img', + sourceDimensions: { width: '1280', height: '820', aspectRatio: '1280 / 820' }, + alt: 'A WordPress editor sidebar with editable block controls', + caption: 'Native controls stay with the block, not in a screenshot.', + }, + grid: { + selector: '.block-runner-native-hero-grid.wp-block-group', + samples: [ + { label: 'one-column', viewport: { width: 640, height: 844 }, surfaceViewport: { width: 640 }, columns: 1 }, + { label: 'two-column', viewport: { width: 1280, height: 900 }, surfaceViewport: { width: 1280 }, columns: 2 }, + ], + }, + }, + // The runner prepares the source URL as a real local upload and replaces + // this placeholder with WordPress's observed URL before browser proof. + frontend: { url: 'http://localhost:8888/', subtreeSelector: '.wp-block-post-content', expectedLinks: [], expectedMedia: [] }, + }; + const identity = { blockName: plan.target.name, pluginSlug: nativeStyleAdapterPluginSlug, pluginZip: path.basename(pluginZip), sha256: artifact.sha256 }; + const manifestPath = path.join(root, 'native-style-adapter.hashes.json'); + const retained = [inputPath, cssPath, proposalPath, planPath, blocksPath, identityPath]; + await Promise.all([ + writeFixed(planPath, `${JSON.stringify(plan, null, 2)}\n`), + writeFixed(blocksPath, `${nativeContainerMarkup}\n`), + writeFixed(identityPath, `${JSON.stringify(identity, null, 2)}\n`), + writeFixed(path.join(root, 'native-style-adapter.fixture.json'), `${JSON.stringify(fixture, null, 2)}\n`), + ]); + const hashes = Object.fromEntries(await Promise.all([...retained, pluginZip].map(async (file) => [ + path.basename(file), + `sha256:${createHash('sha256').update(await readFile(file)).digest('hex')}`, + ]))); + await writeFixed(manifestPath, `${JSON.stringify({ + schemaVersion: 1, + inputs: hashes, + pluginIdentity: path.basename(identityPath), + }, null, 2)}\n`); + return { inputPath: manifestPath, pluginDirectory, pluginZip, nativeContainerMarkup, artifact, fixture }; +} + /** * Build a deliberately small author()-produced block. The native @mobile * style must originate in the public authoring pass; this helper refuses to diff --git a/scripts/proof-playwright.mjs b/scripts/proof-playwright.mjs index 8208815..07010db 100644 --- a/scripts/proof-playwright.mjs +++ b/scripts/proof-playwright.mjs @@ -151,7 +151,8 @@ try { const saved = await phase('editor-save', () => savePost(page, editor)); const savedState = await editorState(page); - const editPersistence = editedValuesPersisted(preEdit, savedState, fixture.editableFields ?? [], fixture.blockName); + const savedSelectorTargets = await resolveScopedSelectorTargets(page, savedState, fixture.editableFields ?? [], fixture.blockName); + const editPersistence = editedValuesPersisted(preEdit, savedState, fixture.editableFields ?? [], fixture.blockName, savedSelectorTargets); const savePassed = fieldResult.status === 'pass' && saved && editPersistence.ok && savedState.invalidBlocks.length === 0; set('editor_save', savePassed ? 'pass' : 'fail', savePassed ? undefined : 'Editor save did not persist the edited block values.', { preEdit, @@ -159,14 +160,30 @@ try { editPersistence, }); - const reopened = await phase('editor-reopen', () => reopenPost(page)); + const reopened = await phase('editor-reopen', () => reopenPost(page, fixture)); const reopenedState = await editorState(page); - const reopenPersistence = editedValuesPersisted(preEdit, reopenedState, fixture.editableFields ?? [], fixture.blockName); - const persisted = savePassed && reopened && reopenedState.invalidBlocks.length === 0 && savedState.contentHash === reopenedState.contentHash && savedState.treeHash === reopenedState.treeHash && reopenPersistence.ok; - set('editor_reopen', persisted ? 'pass' : 'fail', persisted ? undefined : 'Saved editor tree/content changed after reopening.', { + const reopenedSelectorTargets = await resolveScopedSelectorTargets(page, reopenedState, fixture.editableFields ?? [], fixture.blockName); + const reopenPersistence = editedValuesPersisted(preEdit, reopenedState, fixture.editableFields ?? [], fixture.blockName, reopenedSelectorTargets); + const savedStateMatchesReopened = savedState.contentHash === reopenedState.contentHash && savedState.treeHash === reopenedState.treeHash; + const persisted = savePassed && reopened && reopenedState.invalidBlocks.length === 0 && savedStateMatchesReopened && reopenPersistence.ok; + const reopenReason = !savePassed + ? 'Editor save did not persist the edited block values.' + : !reopened + ? 'Could not reopen the saved post in the editor.' + : reopenedState.invalidBlocks.length > 0 + ? 'The reopened editor contains invalid blocks.' + : !savedStateMatchesReopened + ? 'Saved editor tree/content changed after reopening.' + : !reopenPersistence.ok + ? 'The declared edited field did not survive reopening.' + : undefined; + set('editor_reopen', persisted ? 'pass' : 'fail', reopenReason, { preEdit, saved: savedState, + savedSelectorTargets, reopened: reopenedState, + reopenedSelectorTargets, + savedStateMatchesReopened, reopenPersistence, }); @@ -200,6 +217,21 @@ try { }, prior?.artifacts); } + if (fixture.nativeStyleAdapterMatrix) { + const frame = page.frames().find((candidate) => candidate.name() === 'editor-canvas'); + const rootClientId = reopenedState.tree.filter((block) => block.name === fixture.blockName).map((block) => block.clientId); + const matrix = frame && rootClientId.length === 1 + ? await phase('editor-native-style-adapter-matrix', () => proveNativeStyleAdapterMatrix(page, frame, fixture, rootClientId[0], fixture.nativeStyleAdapterMatrix, 'editor-canvas', artifactDir)) + : { ok: false, details: { error: frame ? 'Expected exactly one reopened generated root for native style adapter proof.' : 'WordPress editor-canvas iframe was not present.' }, artifacts: [] }; + const prior = gates.editor_reopen; + const passed = prior?.status === 'pass' && matrix.ok; + set('editor_reopen', passed ? 'pass' : 'fail', passed ? undefined + : prior?.reason ?? 'The native style adapter matrix did not complete.', { + ...(prior?.details ?? {}), + nativeStyleAdapterMatrix: matrix.details, + }, [...(prior?.artifacts ?? []), ...matrix.artifacts]); + } + if (needsPattern) patternLifecycle = await phase('pattern-overrides', () => provePatternOverride(page, fixture)); if (required.has('accessibility_editor') && !needsFrontend) { await phase('accessibility-editor', () => proveAxeEditor(page, fixture, artifactDir)); @@ -438,6 +470,46 @@ function scopedLocator(page, scope, selector) { editorCanvas.locator(`[data-block=${JSON.stringify(clientId)}]`).locator(selector))); } +async function resolveScopedSelectorTargets(page, state, fields, blockName) { + const roots = state.tree.filter((block) => block.name === blockName); + return Promise.all(fields.map(async (field) => { + if (!field.selector) return undefined; + if (roots.length !== 1) { + return { path: field.path, selector: field.selector, rootMatches: roots.length, reason: 'Expected exactly one inserted root before resolving the selector.' }; + } + try { + const rootClientId = roots[0].clientId; + const control = scopedLocator(page, { rootClientIds: [rootClientId] }, field.selector); + const controlMatches = await control.count(); + if (controlMatches !== 1) { + return { path: field.path, selector: field.selector, rootClientId, controlMatches, reason: 'The scoped selector did not resolve to exactly one control inside the inserted root.' }; + } + const ownership = await control.evaluate((element, expectedRootClientId) => { + const owner = element.closest('[data-block]'); + const ancestors = []; + for (let current = owner; current; current = current.parentElement?.closest('[data-block]')) { + const clientId = current.getAttribute('data-block'); + if (clientId) ancestors.push(clientId); + if (clientId === expectedRootClientId) break; + } + return { clientId: owner?.getAttribute('data-block') ?? undefined, ancestors, belongsToRoot: ancestors.includes(expectedRootClientId) }; + }, rootClientId); + if (!ownership.clientId || !ownership.belongsToRoot) { + return { path: field.path, selector: field.selector, rootClientId, controlMatches, ownership, reason: 'The selected control has no native owning block inside the unique inserted root.' }; + } + const native = await page.evaluate((clientId) => { + const block = globalThis.wp?.data?.select('core/block-editor')?.getBlock?.(clientId); + return block ? { clientId: block.clientId, name: block.name, attributes: JSON.parse(JSON.stringify(block.attributes ?? {})) } : undefined; + }, ownership.clientId); + return native + ? { path: field.path, selector: field.selector, rootClientId, controlMatches, ownership, clientId: native.clientId, native } + : { path: field.path, selector: field.selector, rootClientId, controlMatches, ownership, clientId: ownership.clientId, reason: 'The selected control owner was not available from the native block store.' }; + } catch (error) { + return { path: field.path, selector: field.selector, rootClientId: roots[0].clientId, reason: `Could not re-resolve the scoped selector: ${error instanceof Error ? error.message : String(error)}` }; + } + })); +} + function combineLocators(locators) { const [first, ...rest] = locators; @@ -484,7 +556,7 @@ async function publishPost(page, editor) { } } -async function reopenPost(page) { +async function reopenPost(page, fixture) { try { // Editor heartbeat requests can keep networkidle open after the editor is // ready. The writing flow is the lifecycle condition that matters here. @@ -492,6 +564,18 @@ async function reopenPost(page) { if (!Number.isInteger(Number(id)) || Number(id) <= 0) return false; await page.goto(`${baseUrl}/wp-admin/post.php?post=${Number(id)}&action=edit`, { waitUntil: 'domcontentloaded' }); await waitForEditorReady(page, { requireCurrentBlocks: true }); + // The native-style fixture's source image becomes a real local upload + // immediately before insertion. On reopen Core can expose the block + // store before that image has mounted in the fresh canvas; capture the + // persistence state only after the supported native image is present. + if (fixture?.nativeStyleAdapterMatrix) { + await page.waitForFunction((selector) => { + const iframe = document.querySelector('iframe[name="editor-canvas"]'); + const root = iframe?.contentDocument ?? document; + const image = root.querySelector(selector); + return image?.tagName === 'IMG' && image.complete && image.naturalWidth > 0; + }, fixture.nativeStyleAdapterMatrix.image.selector, { timeout: 20_000 }); + } return true; } catch { return false; @@ -833,6 +917,137 @@ async function proveResponsiveStyles(page, surface, fixture, rootClientId, respo } } +/** + * Observe only the three supported source-to-Core relationships. Every value + * is taken from the installed plugin in the editor canvas or published page; + * this intentionally has no fixture-coordinate fallback. + */ +async function proveNativeStyleAdapterMatrix(page, surface, fixture, rootClientId, matrix, scope, artifactDir) { + const root = scope === 'editor-canvas' + ? surface.locator(`[data-block=${JSON.stringify(rootClientId)}]`) + : surface.locator(`.wp-block-${fixture.blockName.replace('/', '-')}`).first(); + const artifacts = []; + const originalViewport = page.viewportSize(); + try { + await root.waitFor({ state: 'visible' }); + const wrapper = root.locator(matrix.button.wrapperSelector); + const link = root.locator(matrix.button.linkSelector); + const image = root.locator(matrix.image.selector); + const caption = root.locator(`${matrix.image.selector.replace(/\s*>\s*img$/, '')} > figcaption.wp-element-caption`); + const grid = root.locator(matrix.grid.selector); + if (await wrapper.count() !== 1 || await link.count() !== 1 || await image.count() !== 1 || await caption.count() !== 1 || await grid.count() !== 1) { + throw new Error('Native style adapter matrix selectors must each match exactly one emitted Core element.'); + } + const before = await Promise.all([ + wrapper.evaluate((element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return { + rect: { x: rect.x, y: rect.y }, padding: { top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }, + transform: style.transform, transitionDuration: style.transitionDuration, + }; + }), + link.evaluate((element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return { rect: { x: rect.x, y: rect.y }, padding: { top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }, transform: style.transform, backgroundColor: style.backgroundColor }; + }), + ]); + await link.hover(); + await link.evaluate(async (element) => { + // Read after the authored transition completes, not its first frame. + const transitions = element.getAnimations().filter((animation) => + Number.isFinite(animation.effect?.getComputedTiming().endTime)); + await Promise.all(transitions.map((animation) => animation.finished.catch(() => undefined))); + }); + const hovered = await Promise.all([ + wrapper.evaluate((element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return { rect: { x: rect.x, y: rect.y }, transform: style.transform, transitionDuration: style.transitionDuration }; + }), + link.evaluate((element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return { rect: { x: rect.x, y: rect.y }, transform: style.transform, backgroundColor: style.backgroundColor }; + }), + ]); + // A real Tab interaction keeps this a keyboard-focus assertion rather than + // a selector-only simulation. Focusing first makes the target deterministic + // across editor and frontend tab order. + await link.focus(); + await page.keyboard.press('Shift+Tab'); + await page.keyboard.press('Tab'); + const focus = await link.evaluate((element) => { + const style = getComputedStyle(element); + return { active: document.activeElement === element, focusVisible: element.matches(':focus-visible'), outlineStyle: style.outlineStyle, outlineWidth: style.outlineWidth }; + }); + const media = await Promise.all([ + image.evaluate((element) => { + const style = getComputedStyle(element); + return { + width: element.getAttribute('width'), height: element.getAttribute('height'), alt: element.getAttribute('alt'), + inlineWidth: element.style.width, inlineHeight: element.style.height, + aspectRatio: style.aspectRatio, + renderedWidth: element.getBoundingClientRect().width, + renderedHeight: element.getBoundingClientRect().height, + containerWidth: element.parentElement.clientWidth, + loaded: element.complete && element.naturalWidth > 0, + }; + }), + caption.textContent(), + ]); + const grids = []; + for (const sample of matrix.grid.samples) { + const surfaceViewport = await setResponsiveSurfaceViewport(page, surface, sample); + const observed = await grid.evaluate((element) => ({ template: getComputedStyle(element).gridTemplateColumns, display: getComputedStyle(element).display })); + const columns = observed.template === 'none' ? 0 : observed.template.trim().split(/\s+/).filter(Boolean).length; + grids.push({ label: sample.label, viewport: { requested: sample.viewport, surface: surfaceViewport }, columns, expected: sample.columns, ...observed }); + } + const wrapperNeutral = Object.values(before[0].padding).every((value) => value === '0px') + && before[0].transform === 'none' && before[0].transitionDuration.split(',').every((value) => value.trim() === '0s'); + const paddingMatches = Object.entries(matrix.button.linkPadding).every(([side, value]) => before[1].padding[side] === value); + const aligned = Math.abs(before[1].rect.x - before[0].rect.x) <= 1; + const hoverMatches = hovered[1].transform === matrix.button.hover.transform + && hovered[1].backgroundColor === matrix.button.hover.backgroundColor + && Math.abs((hovered[1].rect.y - before[1].rect.y) + 2) <= 0.25 + && hovered[0].transform === 'none' + && hovered[0].transitionDuration.split(',').every((value) => value.trim() === '0s'); + const focusMatches = focus.active && focus.focusVisible && focus.outlineStyle === matrix.button.focusOutline.style && focus.outlineWidth === matrix.button.focusOutline.width; + const sourceRatio = Number(matrix.image.sourceDimensions.width) / Number(matrix.image.sourceDimensions.height); + const ratioParts = media[0].aspectRatio.match(/([0-9.]+)\s*\/\s*([0-9.]+)/g); + const renderedRatio = ratioParts?.length + ? ratioParts[ratioParts.length - 1].split('/').map((part) => Number(part.trim())) + : undefined; + const ratioMatches = renderedRatio !== undefined && Number.isFinite(sourceRatio) + && Math.abs((renderedRatio[0] / renderedRatio[1]) - sourceRatio) < 0.00001; + const renderedRatioMatches = media[0].renderedWidth > 0 + && Math.abs(media[0].renderedHeight - media[0].renderedWidth / sourceRatio) <= 1; + // Core adds natural attachment dimensions to the editor DOM. They must not + // override the authored CSS width or ratio, or leak into saved/frontend HTML. + const sizingMatches = media[0].inlineWidth === '' + && Math.abs(media[0].renderedWidth - media[0].containerWidth) <= 1 + && (scope === 'editor-canvas' + ? ['', 'auto'].includes(media[0].inlineHeight) + : media[0].width === null && media[0].height === null && media[0].inlineHeight === ''); + const mediaMatches = media[0].alt === matrix.image.alt && media[0].loaded + && ratioMatches && renderedRatioMatches && sizingMatches + && media[1]?.replace(/\s+/g, ' ').trim() === matrix.image.caption; + const gridsMatch = grids.every((sample) => sample.display === 'grid' && sample.columns === sample.expected); + const details = { scope, button: { wrapper: before[0], link: before[1], hovered: { wrapper: hovered[0], link: hovered[1] }, focus, wrapperNeutral, paddingMatches, aligned, hoverMatches, focusMatches }, image: { sourceDimensions: matrix.image.sourceDimensions, observed: media[0], caption: media[1], ratioMatches, renderedRatioMatches, sizingMatches, matches: mediaMatches }, grid: { samples: grids, matches: gridsMatch } }; + const imagePath = path.join(artifactDir, `native-style-adapter-${scope}.png`); + const jsonPath = path.join(artifactDir, `native-style-adapter-${scope}.json`); + await root.screenshot({ path: imagePath, animations: 'disabled' }); + await writeFile(jsonPath, JSON.stringify(details, null, 2), 'utf8'); + artifacts.push({ path: `artifacts/${path.basename(imagePath)}`, mediaType: 'image/png' }, { path: `artifacts/${path.basename(jsonPath)}`, mediaType: 'application/json' }); + return { ok: wrapperNeutral && paddingMatches && aligned && hoverMatches && focusMatches && mediaMatches && gridsMatch, details, artifacts }; + } catch (error) { + return { ok: false, details: { scope, error: error instanceof Error ? error.message : String(error) }, artifacts }; + } finally { + if (originalViewport) await page.setViewportSize(originalViewport).catch(() => undefined); + } +} + async function setResponsiveSurfaceViewport(page, surface, sample) { await page.setViewportSize(sample.viewport); let observed = await surface.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })); @@ -887,24 +1102,32 @@ async function readPublication(page, savedContent) { : undefined; } -function editedValuesPersisted(before, after, fields, blockName) { +function editedValuesPersisted(before, after, fields, blockName, selectorTargets = []) { const changed = before.contentHash !== after.contentHash || before.treeHash !== after.treeHash; const roots = after.tree.filter((block) => block.name === blockName); const visit = (nodes) => nodes.flatMap((node) => [node, ...visit(node.innerBlocks ?? [])]); const nodes = roots.length === 1 ? visit(roots) : []; - const checks = fields.map((field) => { + const checks = fields.map((field, index) => { const names = field.surface === 'richText' ? ['core/heading', 'core/paragraph', 'core/list-item', 'core/button'] : field.surface === 'link' ? ['core/button'] : ['core/image']; const candidates = nodes.filter((node) => names.includes(node.name) && (!field.metadataName || node.attributes?.metadata?.name === field.metadataName)); - const target = candidates.length === 1 ? candidates[0] : undefined; + const selectorTarget = field.selector ? selectorTargets[index] : undefined; + // Selector fields are bound to the native owner of the freshly resolved + // DOM control. Do not let an equivalent value in another compatible block + // stand in for that exact target. Non-selector and metadata fields retain + // their existing unique-compatible-block contract. + const target = field.selector + ? candidates.find((node) => node.clientId === selectorTarget?.clientId) + : candidates.length === 1 ? candidates[0] : undefined; + const value = field.value ?? `Proof edit ${field.path}`; const attribute = field.surface === 'richText' ? (target?.name === 'core/button' ? 'text' : 'content') : field.surface === 'altText' ? 'alt' : 'url'; - const value = field.value ?? `Proof edit ${field.path}`; const expected = field.surface === 'media' ? field.media : { [attribute]: value }; const actual = target?.attributes ?? {}; - const ok = Boolean(target && expected && Object.entries(expected).every(([key, expectedValue]) => actual[key] === expectedValue)); - return { path: field.path, metadataName: field.metadataName, matches: candidates.length, expected, actual, ok }; + const ok = Boolean(target && expected && (!field.selector || selectorTarget?.ownership?.belongsToRoot) + && Object.entries(expected).every(([key, expectedValue]) => actual[key] === expectedValue)); + return { path: field.path, metadataName: field.metadataName, selector: field.selector, matches: candidates.length, selectorTarget, expected, actual, ok }; }); // Values elsewhere in the post (including its title or another block) cannot // satisfy this claim. Reopen also compares the exact saved serialization. @@ -1863,13 +2086,18 @@ async function proveFrontend(page, fixture, baseUrl, activePublication, artifact const responsiveStyleMatrix = fixture.responsiveStyleMatrix ? await proveResponsiveStyles(page, page, fixture, undefined, fixture.responsiveStyleMatrix, 'frontend') : undefined; - const assetsPass = ownedStyles.length > 0 && healthyAssets && (sharedMatrix?.ok ?? true) && (responsiveStyleMatrix?.ok ?? true); + const nativeStyleAdapterMatrix = fixture.nativeStyleAdapterMatrix + ? await proveNativeStyleAdapterMatrix(page, page, fixture, undefined, fixture.nativeStyleAdapterMatrix, 'frontend', artifactDir) + : undefined; + const assetsPass = ownedStyles.length > 0 && healthyAssets && (sharedMatrix?.ok ?? true) && (responsiveStyleMatrix?.ok ?? true) && (nativeStyleAdapterMatrix?.ok ?? true); set('frontend_assets', assetsPass ? 'pass' : 'fail', assetsPass ? undefined : sharedMatrix && !sharedMatrix.ok ? 'The generated shared stylesheet/font did not load on the published frontend root.' : responsiveStyleMatrix && !responsiveStyleMatrix.ok ? 'The author-produced responsive style matrix did not match on the published frontend.' - : 'No successful plugin-owned stylesheet was observed on the published post, or a plugin asset failed.', { + : nativeStyleAdapterMatrix && !nativeStyleAdapterMatrix.ok + ? 'The native style adapter matrix did not match on the published frontend.' + : 'No successful plugin-owned stylesheet was observed on the published post, or a plugin asset failed.', { postId: activePublication.id, permalink: activePublication.permalink, assets, @@ -1877,7 +2105,8 @@ async function proveFrontend(page, fixture, baseUrl, activePublication, artifact ownedStyles, ...(sharedMatrix ? { browserMatrix: sharedMatrix.details } : {}), ...(responsiveStyleMatrix ? { responsiveStyleMatrix: responsiveStyleMatrix.details } : {}), - }, [...(sharedMatrix?.artifacts ?? [])]); + ...(nativeStyleAdapterMatrix ? { nativeStyleAdapterMatrix: nativeStyleAdapterMatrix.details } : {}), + }, [...(sharedMatrix?.artifacts ?? []), ...(nativeStyleAdapterMatrix?.artifacts ?? [])]); activePublication.frontendAssets = ownedAssets; const scopedErrors = { consoleErrors: consoleErrors.slice(consoleStart), pageErrors: pageErrors.slice(pageErrorStart) }; const runtimePass = scopedErrors.consoleErrors.length === 0 && scopedErrors.pageErrors.length === 0; diff --git a/src/author/index.ts b/src/author/index.ts index 0a3ec3c..0546dd2 100644 --- a/src/author/index.ts +++ b/src/author/index.ts @@ -33,6 +33,7 @@ import { createAnalyzedDesignCoverage, hasNativeCoverageTransport, namespaceAuthoringFontReferences, + UnresolvedNativeStyleMappingError, prepareAuthoringFonts, validateCoverageFulfillment, } from './plan.js'; @@ -500,6 +501,7 @@ export async function author(input: string, options: AuthorOptions = {}): Promis classifyConfirmedPresetCoverage(expectedCoverageInput, supplied, definition.styles?.context?.theme?.settings); classifyConfirmedResponsiveCoverage(expectedCoverageInput, supplied); classifyConfirmedStructuredCssCoverage(expectedCoverageInput, supplied); + reconcileVerifiedNativeTargets(expectedCoverageInput, supplied.coverage); const expectedCoverage = validateAuthoringPlan({ ...supplied, coverage: expectedCoverageInput }).coverage!; if (supplied.source?.entry !== source.entry || supplied.source.sha256 !== source.sha256 || supplied.source.format !== 'html') { throw new Error('Supplied authoring plan is not bound to this exact HTML source hash.'); @@ -517,7 +519,7 @@ export async function author(input: string, options: AuthorOptions = {}): Promis editorStyleLedger: [], } as ReturnType; } catch (error) { - generationItems.push({ block: name, status: 'warning', reason: error instanceof Error ? error.message : String(error) }); + generationItems.push(authoringFailureItem(name, error)); compiled = undefined; } } else if (!hardAssetFailure && !hardStyleFailure && !hardInlineStyleFailure && (conversion.ok || proposal)) { @@ -558,7 +560,7 @@ export async function author(input: string, options: AuthorOptions = {}): Promis compiled = { ...compiled, plan, generated: compileRegisteredBlock(plan) }; } } catch (error) { - generationItems.push({ block: name, status: 'warning', reason: error instanceof Error ? error.message : String(error) }); + generationItems.push(authoringFailureItem(name, error)); // Compilation may have produced a tentative package before canonical coverage/content // fulfillment runs. Never return that tentative package after a fail-closed validation. compiled = undefined; @@ -1442,6 +1444,7 @@ function toAuthoredStyleLedgerEntry( sourceSelectors: ReadonlyMap = new Map(), transportSelectors: ReadonlyMap = sourceSelectors, ): (entry: { + declarationId: string; ruleId: string; property: string; value: string; @@ -1454,6 +1457,8 @@ function toAuthoredStyleLedgerEntry( const selector = sourceSelectors.get(entry.ruleId); const transportSelector = transportSelectors.get(entry.ruleId); return { + declarationId: entry.declarationId, + ruleId: entry.ruleId, property: entry.property, value: entry.value, outcome: normalizeStyleOutcome(entry.outcome), @@ -1485,6 +1490,39 @@ function normalizeStyleOutcome(value: string): AuthoredStyleLedgerEntry['outcome : 'warned'; } +/** Preserve the native-mapping category for callers instead of flattening it into a warning. */ +function authoringFailureItem(block: string, error: unknown): ReportItem { + if (error instanceof UnresolvedNativeStyleMappingError) { + const mapping = error.mapping; + return { + block, status: 'warning', code: error.code, reason: error.message, + ...(mapping.htmlSource ? { source: { path: mapping.htmlSource.path, offset: mapping.htmlSource.offset, htmlLine: mapping.htmlSource.line, htmlColumn: mapping.htmlSource.column } } : {}), + details: mapping, + }; + } + const reason = error instanceof Error ? error.message : String(error); + const mapping = reason.match(/^unresolved-native-style-mapping:\s*(.*)$/); + return mapping + ? { block, status: 'warning', code: 'unresolved-native-style-mapping', reason, details: { mapping: mapping[1] } } + : { block, status: 'warning', reason }; +} + +/** Carry compiler-owned adapter destinations across a fresh source scan only by full identity. */ +function reconcileVerifiedNativeTargets( + scanned: import('../authoring/schema.js').AuthoringCoverage, + submitted: import('../authoring/schema.js').AuthoringCoverage | undefined, +): void { + if (!submitted) return; + for (const entry of scanned.styles) { + const matching = submitted.styles.filter((candidate) => candidate.declarationId === entry.declarationId + && candidate.ruleId === entry.ruleId && candidate.scope === entry.scope + && candidate.property === entry.property && candidate.value === entry.value + && candidate.atRules.join('\u0000') === entry.atRules.join('\u0000') + && candidate.source?.selector === entry.source?.selector && candidate.source?.offset === entry.source?.offset); + if (matching.length === 1 && matching[0]!.nativeTargets?.length) entry.nativeTargets = matching[0]!.nativeTargets!.map((target) => ({ ...target })); + } +} + function toInlineStyleLedgerEntry(entry: StyleLedgerEntry, source: AuthoredStyleLedgerEntry['source']): AuthoredStyleLedgerEntry { // The legacy styling mapper calls this `mapped`/`consumed`; package reports use the more useful // authoring vocabulary. A declaration preserved verbatim inside rich text or Custom HTML is a diff --git a/src/author/plan.ts b/src/author/plan.ts index d614fff..80f63c0 100644 --- a/src/author/plan.ts +++ b/src/author/plan.ts @@ -20,12 +20,27 @@ import { BACKGROUND_COLOR_TARGET, GRADIENT_TARGET, classifyBackground, lookupDec import { querySupports } from '../styles/capabilities.js'; import type { AssetLedgerEntry, AuthoredStyleLedgerEntry, AuthorConfig, WpBlock } from '../types.js'; import { scanCssUrlReferences, type FontAssetWarning, type FontLicenseDecision, type PreparedCssAsset } from './assets.js'; -import { decodeCssEscapes, fontFaceRules, forEachCssRule, scanStylesheet, scopeLocalSelectorList, scopeStylesheet, splitCssTopLevel, type CssDeclaration, type CssRule, type CssStylesheet } from './styles.js'; +import { decodeCssEscapes, fontFaceRules, forEachCssRule, nativeSelectorSubjects, scanStylesheet, scopeLocalSelectorList, scopeStylesheet, splitCssTopLevel, type CssDeclaration, type CssRule, type CssStylesheet } from './styles.js'; import { exactThemePresetTransport, styleContextFrom } from './style-context.js'; import { mapExactWordPressResponsiveMedia, resolveWordPressViewportRanges } from './responsive.js'; import { hasUnsafeResponsiveNativeCascade } from './cascade.js'; import { sourceDeclarationKey } from '../styles/apply.js'; +export class UnresolvedNativeStyleMappingError extends Error { + readonly code = 'unresolved-native-style-mapping' as const; + constructor(readonly mapping: { selector: string; property?: string; node?: string; role?: string; reason: string; cssSource?: { path: string; offset: number; line: number; column: number; selector: string }; htmlSource?: { sourceRef: string; path: string; offset: number; line: number; column: number }; htmlSources?: readonly { sourceRef: string; path: string; offset: number; line: number; column: number }[] }) { + super(`unresolved-native-style-mapping: ${mapping.reason}`); + this.name = 'UnresolvedNativeStyleMappingError'; + } +} + +/** Internal transport for a selector binding that fails before a native target can be emitted. */ +class NativeTargetBindingError extends Error { + constructor(readonly bindings: readonly { sourceRef: string; node: string; role: 'button-link' | 'image' | 'caption' | 'grid-container' }[], reason: string) { + super(reason); + } +} + export interface PreparedAuthoringFonts { /** CSS after removing global @font-face rules and namespacing their owned families. */ css: string; @@ -187,7 +202,7 @@ export function compileAnalyzedDesign(input: { const reconciled = input.structureOverride && input.sourceRefToNode ? reconcileProposalStyleOwnership( structure, input.rules, input.styleLedger, input.source, input.sourceRefToNode, - input.stylesheetFacts?.rules ?? input.rules, input.cascadeSensitiveDeclarations ?? new Set(), + input.stylesheetFacts?.rules ?? input.rules, input.cascadeSensitiveDeclarations ?? new Set(), input.sourcePath, ) : { rules: input.rules, styleLedger: input.styleLedger }; const responsive = liftExactResponsiveStyles({ @@ -241,6 +256,8 @@ export function compileAnalyzedDesign(input: { throw new Error('Editor-only CSS must use supported component-local rules; global or unsupported rules cannot be emitted.'); } const editorStyleLedger: AuthoredStyleLedgerEntry[] = editor.ledger.map((entry) => ({ + declarationId: entry.declarationId, + ruleId: entry.ruleId, property: entry.property, value: entry.value, outcome: entry.outcome === 'native' || entry.outcome === 'preset' || entry.outcome === 'literal' @@ -300,13 +317,14 @@ export function compileAnalyzedDesign(input: { * is already present; leave every other declaration in the scoped stylesheet. */ function reconcileProposalStyleOwnership( - structure: readonly AuthoringStructureNode[], + structure: AuthoringStructureNode[], rules: readonly CssRule[], ledger: readonly AuthoredStyleLedgerEntry[], source: string, bindings: ReadonlyMap, sourceRules: readonly CssRule[], cascadeSensitiveDeclarations: ReadonlySet, + sourcePath?: string, ): { rules: readonly CssRule[]; styleLedger: readonly AuthoredStyleLedgerEntry[] } { const dom = new JSDOM(source, { includeNodeLocations: true }); try { @@ -357,10 +375,262 @@ function reconcileProposalStyleOwnership( if (declarations.length) output.push({ ...rule, declarations }); return output; }, []); - return { rules: removePromoted(rules), styleLedger: nextLedger }; + const residual = removePromoted(rules); + return adaptNativeSourceStyles(structure, residual, nextLedger, sourceNode, bindings, sourcePath, (ref) => dom.nodeLocation(sourceNode.get(ref)!) ?? undefined); } finally { dom.window.close(); } } +const BUTTON_WRAPPER_RESET = new Map([ + ['margin', '0'], ['margin-top', '0'], ['margin-right', '0'], ['margin-bottom', '0'], ['margin-left', '0'], + ['padding', '0'], ['padding-top', '0'], ['padding-right', '0'], ['padding-bottom', '0'], ['padding-left', '0'], ['display', 'block'], ['background', 'transparent'], ['background-color', 'transparent'], + ['border', '0'], ['border-top', '0'], ['border-right', '0'], ['border-bottom', '0'], ['border-left', '0'], ['border-radius', '0'], ['border-top-left-radius', '0'], ['border-top-right-radius', '0'], ['border-bottom-left-radius', '0'], ['border-bottom-right-radius', '0'], ['box-shadow', 'none'], ['opacity', '1'], ['transform', 'none'], + ['translate', 'none'], ['rotate', 'none'], ['scale', 'none'], ['filter', 'none'], ['transition', 'none'], + ['transition-property', 'none'], ['transition-duration', '0s'], ['transition-delay', '0s'], +]); +const BUTTON_TARGET_PROPERTIES = new Set(`color background background-color background-image border border-color border-width border-style border-radius box-shadow + padding padding-top padding-right padding-bottom padding-left margin margin-top margin-right margin-bottom margin-left display width height min-width max-width min-height max-height + font font-family font-size font-weight font-style line-height letter-spacing text-align text-decoration text-transform white-space opacity transform translate rotate scale filter + transition transition-property transition-duration transition-delay transition-timing-function outline outline-color outline-width outline-style outline-offset cursor`.split(/\s+/)); + +/** Insert narrowly-qualified native rules beside their exact source rule, preserving nesting/order. */ +function adaptNativeSourceStyles( + structure: AuthoringStructureNode[], rules: readonly CssRule[], ledger: AuthoredStyleLedgerEntry[], + sourceNodes: ReadonlyMap, bindings: ReadonlyMap, sourcePath: string | undefined, + locationFor: (ref: string) => { startOffset: number; startLine: number; startCol: number } | undefined, +): { rules: CssRule[]; styleLedger: AuthoredStyleLedgerEntry[] } { + const nodes = flattenNodes(structure); + // Conversion retains source classes for residual CSS. For a directly-bound img, however, + // core/image serializes className on its figure; keeping the img utility there would apply it + // to the wrong native element. The adapter owns any supported img/caption transport instead. + for (const [ref, id] of bindings) { + const element = sourceNodes.get(ref); + const node = nodes.find((candidate) => candidate.id === id); + if (element?.matches('img') && node?.block === 'core/image' && typeof node.attributes?.className === 'string') { + const sourceClasses = new Set([...element.classList]); + const retained = node.attributes.className.split(/\s+/).filter((value) => value && !sourceClasses.has(value)).join(' '); + node.attributes = { ...node.attributes, ...(retained ? { className: retained } : {}) }; + if (!retained) delete node.attributes.className; + } + } + const gridProperties = new Set(['display', 'grid-template-columns', 'grid-template-rows', 'gap', 'row-gap', 'column-gap']); + const isGridSpecific = (property: string) => property === 'grid' || property === 'grid-template' || property.startsWith('grid-'); + const ownedGrids = new Set(); + const discoverUnconditionalGrids = (items: readonly CssRule[]): void => { + for (const rule of items) { + if (rule.kind === 'conditional') continue; + if (rule.kind !== 'style' || !rule.declarations.some((declaration) => declaration.property === 'display' && declaration.value.trim() === 'grid')) continue; + const subjects = nativeSelectorSubjects(rule.selector); + if (!subjects) continue; + for (const subject of subjects) for (const [ref, id] of bindings) { + const element = sourceNodes.get(ref); + const node = nodes.find((candidate) => candidate.id === id); + try { if (node?.block === 'core/group' && element?.matches(subject.staticSelector)) ownedGrids.add(id); } catch { /* unsupported selector is handled below */ } + } + } + }; + discoverUnconditionalGrids(rules); + type NativeTarget = { sourceRef: string; node: string; target: string; reset?: string; role: 'button-link' | 'image' | 'caption' | 'grid-container'; intrinsic?: { width: string; height: string; aspectRatio: string } }; + const intrinsicImages = new Map(nodes.flatMap((node) => { + const width = node.block === 'core/image' && typeof node.attributes?.width === 'string' ? node.attributes.width : undefined; + const height = node.block === 'core/image' && typeof node.attributes?.height === 'string' ? node.attributes.height : undefined; + return width && height && /^[1-9]\d*$/.test(width) && /^[1-9]\d*$/.test(height) + ? [[node.id!, { width, height, aspectRatio: `${width} / ${height}` }] as const] : []; + })); + const targetFor = (selector: string): NativeTarget[] | undefined => { + const subjects = nativeSelectorSubjects(selector); + if (!subjects) { + const bound = [...bindings.entries()].map(([sourceRef, id]) => ({ sourceRef, node: nodes.find((candidate) => candidate.id === id), element: sourceNodes.get(sourceRef) })) + .filter((candidate): candidate is { sourceRef: string; node: AuthoringStructureNode; element: Element } => !!candidate.node && !!candidate.element); + if (bound.some((candidate) => ['core/button', 'core/image', 'core/group', 'core/columns'].includes(candidate.node.block))) { + // `nativeSelectorSubjects` intentionally rejects relationships outside the bounded + // adapter. It is still possible to identify the exact source unit the supplied CSS + // selects without interpreting that relationship: use the existing token-aware dynamic + // pseudo transport, then let JSDOM match the original selector structure. + const matching = bound.filter((candidate) => { + if (!['core/button', 'core/image', 'core/group', 'core/columns'].includes(candidate.node.block)) return false; + try { return candidate.element.matches(replaceDynamicPseudos(selector, '')); } catch { return false; } + }).filter((candidate, index, candidates) => candidates.findIndex(({ sourceRef }) => sourceRef === candidate.sourceRef) === index) + .sort((left, right) => (locationFor(left.sourceRef)?.startOffset ?? Number.MAX_SAFE_INTEGER) - (locationFor(right.sourceRef)?.startOffset ?? Number.MAX_SAFE_INTEGER) + || left.sourceRef.localeCompare(right.sourceRef)); + if (matching.length) throw new NativeTargetBindingError(matching.map((candidate) => ({ sourceRef: candidate.sourceRef, node: candidate.node.id!, role: nativeAdapterRole(candidate.node) })), `${selector} is not a supported single-subject native selector`); + throw new Error(`unresolved-native-style-mapping: ${selector} is not a supported single-subject native selector`); + } + return undefined; + } + return subjects.flatMap((subject) => [...bindings.entries()].flatMap(([ref, id]) => { + const element = sourceNodes.get(ref); + if (!element) return []; + const node = nodes.find((candidate) => candidate.id === id); + if (!node) return []; + let inferredRelation: 'image' | 'caption' | undefined; + try { + if (subject.relation) { + if (node.block !== 'core/image' || !element.matches('figure') || !element.matches(subject.staticSelector)) return []; + const tag = subject.relation === 'image' ? 'img' : 'figcaption'; + if (![...element.querySelectorAll(tag)].some((child) => child.parentElement === element && child.matches(subject.terminalSelector ?? tag))) return []; + inferredRelation = subject.relation; + } + if (node.block === 'core/image' && element.matches('img')) inferredRelation = 'image'; + if (!subject.relation && !element.matches(subject.staticSelector)) { + // A figure is the source-bound unit for core/image, while authored utilities often + // live on its direct img/caption. This is the one bounded descendant bridge. + if (node.block !== 'core/image' || !element.matches('figure')) return []; + if ([...element.querySelectorAll('img')].some((child) => child.parentElement === element && child.matches(subject.terminalSelector ?? subject.staticSelector))) inferredRelation = 'image'; + else if ([...element.querySelectorAll('figcaption')].some((child) => child.parentElement === element && child.matches(subject.terminalSelector ?? subject.staticSelector))) inferredRelation = 'caption'; + else return []; + } + } catch { return []; } + const state = subject.state ? `:${subject.state}` : ''; + if (node.block === 'core/button') { + if (!findParent(structure, node.id!) || findParent(structure, node.id!)!.block !== 'core/buttons') throw new NativeTargetBindingError([{ sourceRef: ref, node: node.id!, role: 'button-link' }], `${selector} binds core/button without core/buttons wrapper`); + const marker = `block-runner-native-${node.id!.replace(/[^a-zA-Z0-9_-]/g, '-')}`; + // core/button serializes className on its own div.wp-block-button, never core/buttons. + node.attributes = { ...(node.attributes ?? {}), className: joinClass(node.attributes?.className, marker) }; + return [{ sourceRef: ref, node: node.id!, target: `.${marker} > .wp-block-button__link${state}`, reset: `.${marker}${state}`, role: 'button-link' as const }]; + } + if (node.block === 'core/image') { + if (!subject.relation && !inferredRelation) return []; + if (subject.relation && !element.matches('figure')) throw new NativeTargetBindingError([{ sourceRef: ref, node: node.id!, role: 'image' }], `${selector} requires a figure-bound core/image source`); + const marker = `block-runner-native-${node.id!.replace(/[^a-zA-Z0-9_-]/g, '-')}`; + node.attributes = { ...(node.attributes ?? {}), className: joinClass(node.attributes?.className, marker) }; + const imageRole = subject.relation ?? inferredRelation; + const relation = imageRole === 'caption' ? 'figcaption.wp-element-caption' : 'img'; + return [{ sourceRef: ref, node: node.id!, target: `figure.wp-block-image.${marker} > ${relation}${state}`, role: imageRole === 'caption' ? 'caption' as const : 'image' as const }]; + } + if (node.block === 'core/group' || node.block === 'core/columns') { + const marker = `block-runner-native-${node.id!.replace(/[^a-zA-Z0-9_-]/g, '-')}`; + node.attributes = { ...(node.attributes ?? {}), className: joinClass(node.attributes?.className, marker) }; + return [{ sourceRef: ref, node: node.id!, target: `.${marker}.${node.block === 'core/group' ? 'wp-block-group' : 'wp-block-columns'}${state}`, role: 'grid-container' as const }]; + } + return []; + })); + }; + const visit = (items: readonly CssRule[], conditional = false): CssRule[] => items.flatMap((rule): CssRule[] => { + if (rule.kind === 'conditional') return [{ ...rule, rules: visit(rule.rules, true) }]; + if (rule.kind !== 'style') return [rule]; + let targets: NativeTarget[] | undefined; + try { + targets = targetFor(rule.selector); + } catch (error) { + if (error instanceof UnresolvedNativeStyleMappingError) throw error; + const reason = error instanceof Error ? error.message.replace(/^unresolved-native-style-mapping:\s*/, '') : String(error); + const binding = error instanceof NativeTargetBindingError && error.bindings.length === 1 ? error.bindings[0] : undefined; + const pluralBindings = error instanceof NativeTargetBindingError && error.bindings.length > 1 ? error.bindings : undefined; + const sourceRef = binding?.sourceRef; + const location = sourceRef ? locationFor(sourceRef) : undefined; + const declaration = rule.declarations[0]; + throw new UnresolvedNativeStyleMappingError({ + selector: rule.selector, property: declaration?.property, + ...(binding ? { node: binding.node, role: binding.role } : {}), reason, + ...(declaration ? { cssSource: { path: sourcePath ?? '', selector: rule.selector, offset: declaration.source.start.offset, line: declaration.source.start.line, column: declaration.source.start.column } } : {}), + ...(sourceRef && location ? { htmlSource: { sourceRef, path: sourcePath ?? '', offset: location.startOffset, line: location.startLine, column: location.startCol } } : {}), + ...(pluralBindings ? { htmlSources: pluralBindings.flatMap(({ sourceRef }) => { + const source = locationFor(sourceRef); + return source ? [{ sourceRef, path: sourcePath ?? '', offset: source.startOffset, line: source.startLine, column: source.startCol }] : []; + }) } : {}), + }); + } + if (!targets?.length) return [rule]; + const gridDeclarations = rule.declarations.filter((declaration) => gridProperties.has(declaration.property)); + const unresolved = (reason: string, target = targets[0], declaration = rule.declarations[0]) => { + const sourceRef = target?.sourceRef; + const location = sourceRef ? locationFor(sourceRef) : undefined; + return new UnresolvedNativeStyleMappingError({ + selector: rule.selector, property: declaration?.property, node: target?.node, role: target?.role, reason, + ...(declaration ? { cssSource: { path: sourcePath ?? '', selector: rule.selector, offset: declaration.source.start.offset, line: declaration.source.start.line, column: declaration.source.start.column } } : {}), + ...(sourceRef && location ? { htmlSource: { sourceRef, path: sourcePath ?? '', offset: location.startOffset, line: location.startLine, column: location.startCol } } : {}), + }); + }; + const unsupportedGrid = rule.declarations.find((declaration) => isGridSpecific(declaration.property) && !gridProperties.has(declaration.property)) + ?? rule.declarations.find((declaration) => declaration.property === 'display' && declaration.value.trim() !== 'grid' && /grid/i.test(declaration.value)); + if (targets.some((target) => target.role === 'grid-container') && unsupportedGrid) { + throw unresolved(`unsupported authored grid declaration ${unsupportedGrid.property}`, targets[0], unsupportedGrid); + } + if (targets.every((target) => target.role === 'grid-container') && gridDeclarations.length === 0) return [rule]; + if (targets.some((target) => nodes.find((node) => node.id === target.node)?.block === 'core/columns') && gridDeclarations.length) { + throw unresolved('core/columns cannot own an authored grid'); + } + if (targets.some((target) => nodes.find((node) => node.id === target.node)?.block === 'core/button') + && rule.declarations.some((declaration) => !BUTTON_TARGET_PROPERTIES.has(declaration.property))) { + throw unresolved('property is outside the supported core/button adapter'); + } + for (const target of targets.filter((candidate) => candidate.role === 'image')) { + const controlledAxis = rule.declarations.find((declaration) => declaration.property === 'width' || declaration.property === 'height'); + if (!controlledAxis) continue; + const intrinsic = intrinsicImages.get(target.node); + if (!intrinsic) throw unresolved('authored image sizing requires complete valid intrinsic dimensions', target, controlledAxis); + const image = nodes.find((node) => node.id === target.node)!; + const existingRatio = image.attributes?.aspectRatio; + if (existingRatio !== undefined && !sameAspectRatio(existingRatio, intrinsic)) { + throw unresolved('source intrinsic dimensions conflict with an existing native image aspect ratio', target, controlledAxis); + } + image.attributes = { ...(image.attributes ?? {}), aspectRatio: intrinsic.aspectRatio }; + delete image.attributes.width; + delete image.attributes.height; + target.intrinsic = intrinsic; + } + if (targets.some((target) => target.role === 'grid-container') && gridDeclarations.length + && targets.some((target) => !ownedGrids.has(target.node))) { + // A caller predeclaring layout.type is not evidence of source ownership. + if (conditional || !rule.declarations.some((declaration) => declaration.property === 'display' && declaration.value.trim() === 'grid')) { + throw unresolved('grid declarations lack unconditional source display:grid ownership'); + } + } + if (conditional && targets.some((target) => nodes.find((node) => node.id === target.node)?.block === 'core/group') + && gridDeclarations.length && targets.some((target) => !ownedGrids.has(target.node))) { + throw unresolved('conditional grid declarations lack unconditional display:grid ownership'); + } + if (!conditional && targets.some((target) => nodes.find((node) => node.id === target.node)?.block === 'core/group') + && rule.declarations.some((declaration) => declaration.property === 'display' && declaration.value.trim() === 'grid')) { + const group = nodes.find((node) => node.id === targets[0]!.node)!; + const layout = group.attributes?.layout; + if (layout && (typeof layout !== 'object' || Array.isArray(layout) || (layout as Record).type !== 'grid')) { + throw unresolved('core/group requires a compatible native grid layout'); + } + group.attributes = { ...(group.attributes ?? {}), layout: { ...(layout as Record ?? {}), type: 'grid' } }; + } + const extras: CssRule[] = targets.flatMap((target, targetIndex) => { + const targetDeclarations = target.role === 'grid-container' ? rule.declarations.filter((declaration) => gridProperties.has(declaration.property)) : rule.declarations; + const targetRule: CssRule = { ...rule, id: `${rule.id}.native-target.${targetIndex}`, selector: target.target, declarations: targetDeclarations, generated: 'native-adapter-target' }; + const resets = target.reset ? rule.declarations.filter((declaration) => BUTTON_WRAPPER_RESET.has(declaration.property)).map((declaration) => ({ ...declaration, id: `${declaration.id}.native-reset.${targetIndex}`, value: BUTTON_WRAPPER_RESET.get(declaration.property)! })) : []; + return resets.length ? [targetRule, { ...rule, id: `${rule.id}.native-reset.${targetIndex}`, selector: target.reset!, declarations: resets, generated: 'native-adapter-wrapper-reset' } as CssRule] : [targetRule]; + }); + for (const declaration of rule.declarations) { + const entry = ledger.find((candidate) => candidate.declarationId === declaration.id); + if (entry) { + for (const target of targets) { + if (target.role === 'grid-container' && !gridProperties.has(declaration.property)) continue; + const role = target.role; + entry.nativeTargets = [...(entry.nativeTargets ?? []), { node: target.node, role, selector: target.target, ...(target.intrinsic ? { intrinsic: target.intrinsic } : {}), ...(declaration.important ? { important: true } : {}) }]; + if (target.reset && BUTTON_WRAPPER_RESET.has(declaration.property)) entry.nativeTargets.push({ node: target.node, role: 'button-wrapper-reset', selector: target.reset, ...(declaration.important ? { important: true } : {}) }); + } + if (targets.every((target) => target.role === 'image' || target.role === 'caption')) { + // Direct-image classes cannot remain on a native figure. Their exact source evidence + // is retained in coverage while the generated child selector becomes its transport. + entry.transportSelector = targets[0]!.target; + } + } + } + return targets.every((target) => target.role === 'image' || target.role === 'caption') ? extras : [rule, ...extras]; + }); + return { rules: visit(rules), styleLedger: ledger }; +} + +function findParent(nodes: AuthoringStructureNode[], id: string): AuthoringStructureNode | undefined { + for (const node of nodes) { if (node.children?.some((child) => child.id === id)) return node; const nested = findParent(node.children ?? [], id); if (nested) return nested; } return undefined; +} +function joinClass(value: JsonValue | undefined, marker: string): string { return [...new Set([...(typeof value === 'string' ? value.split(/\s+/) : []), marker])].filter(Boolean).join(' '); } +function nativeAdapterRole(node: AuthoringStructureNode): 'button-link' | 'image' | 'caption' | 'grid-container' { + if (node.block === 'core/button') return 'button-link'; + if (node.block === 'core/image') return 'image'; + return 'grid-container'; +} +function sameAspectRatio(value: JsonValue, intrinsic: { width: string; height: string }): boolean { + if (typeof value !== 'string') return false; + const match = /^\s*(\d+)\s*\/\s*(\d+)\s*$/.exec(value); + return !!match && BigInt(match[1]!) * BigInt(intrinsic.height) === BigInt(match[2]!) * BigInt(intrinsic.width); +} + function sourceDeclarations(rules: readonly CssRule[]): Array<{ selector: string; ruleId: string; declaration: import('./styles.js').CssDeclaration }> { return rules.flatMap((rule) => { if (rule.kind === 'conditional') return sourceDeclarations(rule.rules); @@ -638,6 +908,14 @@ export function validateCoverageFulfillment(plan: AuthoringPlan, sourceHtml?: st if (selectors.length === 0) { throw new Error(`${label} is marked scoped-css but has no matching ${entry.scope} structured CSS rule.`); } + for (const target of entry.nativeTargets ?? []) { + const expectedValue = target.role === 'button-wrapper-reset' ? BUTTON_WRAPPER_RESET.get(entry.property) : entry.value; + const emitted = expectedValue === undefined ? [] : coverageCssRuleSelectors(rules, entry.property, expectedValue, entry.atRules, target.selector); + if (!emitted.includes(target.selector)) throw new Error(`${label} is missing generated native target ${target.role} (${target.selector}).`); + } + // An img-bound source class is intentionally removed from core/image's figure wrapper. + // Its supplemental child target is therefore the complete, verified transport. + if (entry.nativeTargets?.length) continue; // This is deliberately the compiler's final template, rather than plan.structure: field // defaults and confirmed asset uses can replace attributes before WordPress receives it. serializedTemplate ??= serializeCompiledTemplate(compileRegisteredBlock(plan).template); @@ -1224,7 +1502,7 @@ function sha256(value: string): string { } function toCoverageStyle( - entry: Omit, 'outcome'> & { outcome: string }, + entry: Omit, 'outcome'> & { outcome: string }, scope: AuthoringCoverageStyle['scope'], ): AuthoringCoverageStyle { const outcome: AuthoringCoverageStyle['outcome'] = entry.outcome === 'native' || entry.outcome === 'preset' @@ -1232,6 +1510,8 @@ function toCoverageStyle( ? entry.outcome : 'warned'; return { + ...(entry.declarationId ? { declarationId: entry.declarationId } : {}), + ...(entry.ruleId ? { ruleId: entry.ruleId } : {}), property: entry.property, value: entry.value, outcome, @@ -1242,6 +1522,7 @@ function toCoverageStyle( ...(entry.transportSelector ? { transportSelector: entry.transportSelector } : {}), ...(entry.node ? { node: entry.node } : {}), ...(entry.responsive ? { responsive: entry.responsive } : {}), + ...(entry.nativeTargets?.length ? { nativeTargets: entry.nativeTargets } : {}), }; } diff --git a/src/author/proposal.ts b/src/author/proposal.ts index 739cff7..64600a0 100644 --- a/src/author/proposal.ts +++ b/src/author/proposal.ts @@ -142,6 +142,12 @@ function sourceAttributes(element: Element, block?: string): Map asset.reference === original && (asset.outcome === 'prepared' || asset.outcome === 'copied')) && plan.assets.some((asset) => asset.uses?.some((use) => use.node === nodeId && use.attribute === 'url')); - if (!retainedLocalImage && JSON.stringify(node.attributes?.[attribute]) !== JSON.stringify(expected)) { + const retainedIntrinsicRatio = (attribute === 'width' || attribute === 'height') && node.block === 'core/image' + && imageDimensionIsRecorded(plan, nodeId, attribute, expected); + if (!retainedLocalImage && !retainedIntrinsicRatio && JSON.stringify(node.attributes?.[attribute]) !== JSON.stringify(expected)) { throw new Error(`Source content fulfillment failed: ${ref} ${attribute} was not preserved by its exact bound node.`); } } @@ -246,6 +254,15 @@ export function validateProposalSourceContent(sourceHtml: string, bound: Authori } finally { dom.window.close(); } } +/** A CSS-owned image axis retains its source dimensions as adapter provenance, not WP inline sizing. */ +function imageDimensionIsRecorded(plan: AuthoringPlan, node: string, attribute: 'width' | 'height', expected: JsonValue | undefined): boolean { + return plan.coverage?.styles.some((entry) => entry.nativeTargets?.some((target) => { + const intrinsic = target.intrinsic; + return target.node === node && target.role === 'image' && intrinsic?.[attribute] === expected + && intrinsic?.aspectRatio === `${intrinsic?.width} / ${intrinsic?.height}`; + })) ?? false; +} + function flattenStructure(nodes: readonly AuthoringStructureNode[]): AuthoringStructureNode[] { return nodes.flatMap((node) => [node, ...flattenStructure(node.children ?? [])]); } diff --git a/src/author/styles.ts b/src/author/styles.ts index 724bb0a..7bb6ee4 100644 --- a/src/author/styles.ts +++ b/src/author/styles.ts @@ -509,6 +509,8 @@ export interface CssStyleRule { source: CssSourceRange; /** Present for CSS nesting. Nested selectors are parsed and ledgered, then conservatively blocked. */ nestedIn?: string; + /** Internal provenance for rules inserted by the native markup adapter. */ + generated?: 'native-adapter-target' | 'native-adapter-wrapper-reset'; } export interface CssConditionalRule { @@ -603,6 +605,117 @@ export function decodeCssEscapes(value: string): string { }); } +/** + * Read complete class atoms from the existing selector scanner. This is intentionally not a + * selector parser: native adaptation accepts only one compound subject and the four interaction + * states below. Keeping the raw spelling lets emitted rules retain escaped utility selectors + * while matching uses the decoded atom (so `focus-visible:outline` never equals its suffix). + */ +export interface NativeSelectorSubject { + raw: string; + /** The source compound which is bound to a native node. */ + staticSelector: string; + classes: Array<{ raw: string; decoded: string }>; + state?: 'hover' | 'focus' | 'focus-visible' | 'active'; + /** The only descendant relationship the native image block can faithfully retain. */ + relation?: 'image' | 'caption'; + /** Exact terminal compound for the bounded figure child bridge. */ + terminalSelector?: string; +} +export function nativeSelectorSubjects(selectorList: string): NativeSelectorSubject[] | undefined { + const subjects: NativeSelectorSubject[] = []; + for (const raw of splitTopLevel(selectorList, ',')) { + const selector = raw.trim(); + const relationship = splitNativeImageRelationship(selector); + const owner = relationship?.owner ?? selector; + const relation = relationship?.relation; + const terminal = relationship?.terminal; + if (!owner || /[\s>+~\[\]#*]/.test(owner) || /::|:(?:not|is|where|has)\s*\(/i.test(owner)) return undefined; + let index = 0; + const classes: Array<{ raw: string; decoded: string }> = []; + let state: NativeSelectorSubject['state']; + while (index < owner.length) { + if (owner[index] === '.') { + const atomStart = index; + const atom = readCssIdentifier(owner, index + 1); + if (!atom) return undefined; + classes.push({ raw: owner.slice(atomStart, atom.end), decoded: atom.value }); + index = atom.end; + } else if (owner[index] === ':') { + const match = /^:(focus-visible|hover|focus|active)/i.exec(owner.slice(index)); + if (!match || state) return undefined; + state = match[1]!.toLowerCase() as NativeSelectorSubject['state']; + index += match[0].length; + } else if (/^[a-z]/i.test(owner[index]!)) { + const match = /^[a-z][\w-]*/i.exec(owner.slice(index)); + if (!match || index !== 0) return undefined; + index += match[0].length; + } else return undefined; + } + // The sole supported relationship is a figure owner and one direct image/caption child. + // Parse the terminal compound with this same scanner rather than searching for "img": + // `.media-img` is one class atom, not a figure/image relationship. + if (terminal) { + if (!/^figure(?:\.|:|$)/i.test(owner) || state) return undefined; + const parsedTerminal = parseNativeTerminal(terminal, relation!); + if (!parsedTerminal) return undefined; + state = parsedTerminal.state; + subjects.push({ raw: selector, staticSelector: owner.replace(/:(?:focus-visible|hover|focus|active)\b/gi, ''), classes, ...(state ? { state } : {}), relation, terminalSelector: parsedTerminal.staticSelector }); + continue; + } + if (!classes.length) return undefined; + subjects.push({ raw: selector, staticSelector: owner.replace(/:(?:focus-visible|hover|focus|active)\b/gi, ''), classes, ...(state ? { state } : {}), ...(relation ? { relation } : {}) }); + } + return subjects; +} + +function parseNativeTerminal(value: string, relation: 'image' | 'caption'): { state?: NativeSelectorSubject['state']; staticSelector: string } | undefined { + const expected = relation === 'image' ? 'img' : 'figcaption'; + if (!value.toLowerCase().startsWith(expected)) return undefined; + let index = expected.length; + let state: NativeSelectorSubject['state']; + while (index < value.length) { + if (value[index] === '.') { + const atom = readCssIdentifier(value, index + 1); + if (!atom) return undefined; + index = atom.end; + } else if (value[index] === ':') { + const match = /^:(focus-visible|hover|focus|active)/i.exec(value.slice(index)); + if (!match || state) return undefined; + state = match[1]!.toLowerCase() as NativeSelectorSubject['state']; + index += match[0].length; + } else return undefined; + } + return { staticSelector: value.replace(/:(?:focus-visible|hover|focus|active)\b/gi, ''), ...(state ? { state } : {}) }; +} + +/** Split exactly one unescaped direct-child combinator, if this is the supported figure bridge. */ +function splitNativeImageRelationship(selector: string): { owner: string; terminal: string; relation: 'image' | 'caption' } | undefined { + let escaped = false; + let brackets = 0; + let parentheses = 0; + let divider = -1; + for (let index = 0; index < selector.length; index += 1) { + const char = selector[index]!; + if (escaped) { escaped = false; continue; } + if (char === '\\') { escaped = true; continue; } + if (char === '[') brackets += 1; + else if (char === ']') brackets -= 1; + else if (char === '(') parentheses += 1; + else if (char === ')') parentheses -= 1; + else if (char === '>' && brackets === 0 && parentheses === 0) { + if (divider >= 0) return undefined; + divider = index; + } + } + if (divider < 0) return undefined; + const owner = selector.slice(0, divider).trim(); + const terminal = selector.slice(divider + 1).trim(); + const relation = /^img(?:\.|:|$)/i.test(terminal) ? 'image' + : /^figcaption(?:\.|:|$)/i.test(terminal) ? 'caption' : undefined; + return owner && terminal && relation ? { owner, terminal, relation } : undefined; +} + export interface DeclarationDisposition { outcome: Exclude | 'scoped-css'; reason?: string; diff --git a/src/authoring/generate.ts b/src/authoring/generate.ts index 8295e33..8e04c69 100644 --- a/src/authoring/generate.ts +++ b/src/authoring/generate.ts @@ -4,6 +4,8 @@ import { parse as parseJavaScript } from '@babel/parser'; import Ajv from 'ajv'; import { JSDOM } from 'jsdom'; import postcss from 'postcss'; +import { bootHeadlessWordPressSync, withMutedWordPressConsole } from '../headless/env.js'; +import type { WpBlock } from '../types.js'; import { canonicalizeAuthoringPlan, hashAuthoringPlan, @@ -35,7 +37,7 @@ export const REGISTERED_BLOCK_TEMPLATE_VERSION = '0.9-static-v9' as const; * The declarative-style renderer is part of the owned template contract. It never accepts a * stylesheet fragment from the plan: its inputs are validated outcomes and structured rules. */ -export const REGISTERED_BLOCK_STYLE_EMITTER_VERSION = '3' as const; +export const REGISTERED_BLOCK_STYLE_EMITTER_VERSION = '4' as const; export const WORDPRESS_BLOCK_SCHEMA_VERSION = '7.1' as const; export const WORDPRESS_BLOCK_SCHEMA_URL = `https://schemas.wp.org/wp/${WORDPRESS_BLOCK_SCHEMA_VERSION}/block.json`; @@ -586,6 +588,7 @@ function prepareStaticPlan(input: AuthoringPlan): AuthoringPlan { } catch (error) { rethrowCapabilityError(error, 'structure'); } + validateNativeAdapterProvenance(plan); // Preview must refuse unsupported styles too, rather than promising an unwritable package. emitScss(plan.styles.outcomes, blockRootClass(plan.target.name)); // Preview and writing reject the same unresolved editor decisions. @@ -614,6 +617,113 @@ function prepareStaticPlan(input: AuthoringPlan): AuthoringPlan { return plan; } +/** Generated adapter records are compiler-owned claims, not arbitrary extra CSS annotations. */ +const NATIVE_BUTTON_RESET_VALUES = new Map([ + ['margin', '0'], ['margin-top', '0'], ['margin-right', '0'], ['margin-bottom', '0'], ['margin-left', '0'], + ['padding', '0'], ['padding-top', '0'], ['padding-right', '0'], ['padding-bottom', '0'], ['padding-left', '0'], ['display', 'block'], ['background', 'transparent'], ['background-color', 'transparent'], + ['border', '0'], ['border-top', '0'], ['border-right', '0'], ['border-bottom', '0'], ['border-left', '0'], ['border-radius', '0'], ['box-shadow', 'none'], ['opacity', '1'], ['transform', 'none'], ['translate', 'none'], ['rotate', 'none'], ['scale', 'none'], ['filter', 'none'], ['transition', 'none'], ['transition-property', 'none'], ['transition-duration', '0s'], ['transition-delay', '0s'], +]); +function validateNativeAdapterProvenance(plan: AuthoringPlan): void { + const nodes = new Map(); + const visit = (items: readonly AuthoringStructureNode[]) => items.forEach((node) => { if (node.id) nodes.set(node.id, node); visit(node.children ?? []); }); + visit(plan.structure); + const generated: Array<{ selector: string; kind: 'native-adapter-target' | 'native-adapter-wrapper-reset'; property: string; value: string; important?: boolean; atRules: string[] }> = []; + const collect = (rules: NonNullable, atRules: string[] = []) => rules.forEach((rule) => { + if (rule.kind === 'conditional') collect(rule.rules, [...atRules, `@${rule.name} ${rule.prelude}`]); + else if (rule.generated) for (const declaration of rule.declarations) generated.push({ selector: rule.selector, kind: rule.generated, property: declaration.property, value: declaration.value, ...(declaration.important ? { important: true } : {}), atRules }); + }); + collect(plan.styles.rules ?? []); + const claimed = new Set(); + for (const entry of plan.coverage?.styles ?? []) for (const target of entry.nativeTargets ?? []) { + const node = nodes.get(target.node); + const expected = target.role === 'button-wrapper-reset' ? 'native-adapter-wrapper-reset' : 'native-adapter-target'; + const expectedValue = target.role === 'button-wrapper-reset' ? NATIVE_BUTTON_RESET_VALUES.get(entry.property) : entry.value; + const matchIndex = generated.findIndex((item, index) => !claimed.has(index) && item.selector === target.selector && item.kind === expected + && item.property === entry.property && item.value === expectedValue && item.important === target.important + && item.atRules.join('\u0000') === entry.atRules.join('\u0000')); + if (!node || matchIndex < 0) { + throw new AuthoringGenerationError('forged-native-adapter: generated selector/provenance is not present in the canonical rules', 'coverage.styles.nativeTargets'); + } + claimed.add(matchIndex); + const marker = `block-runner-native-${target.node.replace(/[^a-zA-Z0-9_-]/g, '-')}`; + const state = '(?::(?:hover|focus|focus-visible|active))?'; + if ((target.role === 'button-link' || target.role === 'button-wrapper-reset') && (node.block !== 'core/button' + || (target.role === 'button-link' && !new RegExp(`^\\.${marker} > \\.wp-block-button__link${state}$`).test(target.selector)) + || (target.role === 'button-wrapper-reset' && !new RegExp(`^\\.${marker}${state}$`).test(target.selector)))) { + throw new AuthoringGenerationError('forged-native-adapter: button target does not match core/button markup', 'coverage.styles.nativeTargets'); + } + if ((target.role === 'image' || target.role === 'caption') && (node.block !== 'core/image' + || !new RegExp(`^figure\\.wp-block-image\\.${marker} > ${target.role === 'caption' ? 'figcaption\\.wp-element-caption' : 'img'}${state}$`).test(target.selector))) { + throw new AuthoringGenerationError('forged-native-adapter: image target does not match core/image markup', 'coverage.styles.nativeTargets'); + } + if (target.role === 'grid-container' && (node.block !== 'core/group' || !new RegExp(`^\\.${marker}\\.wp-block-group${state}$`).test(target.selector))) throw new AuthoringGenerationError('forged-native-adapter: grid target does not match core/group markup', 'coverage.styles.nativeTargets'); + } + // Provenance is bidirectional: generated adapter CSS is not allowed to outlive the source + // declaration that caused it, even if a caller removes every nativeTargets record. + for (const [index] of generated.entries()) { + if (!claimed.has(index)) { + throw new AuthoringGenerationError('forged-native-adapter: generated declaration has no matching source nativeTarget', 'styles.rules'); + } + } + // Selector spelling is not enough: core blocks are serialized by the pinned WordPress runtime, + // and that output is the markup the generated stylesheet will actually see. + const wp = bootHeadlessWordPressSync(); + const blocks = compileConfirmedTemplate(plan).map(function makeBlock([name, attributes, children]): WpBlock { + return wp.createBlock(name, attributes as Record, children?.map(makeBlock) ?? []); + }); + const serialized = withMutedWordPressConsole(() => wp.serialize(blocks)); + const dom = new JSDOM(serialized); + try { + // This boundary is driven by emitted CSS, not optional caller-retained metadata. A generated + // image width/height target always requires the native ratio representation and must never + // coexist with WordPress's width/height inline serialization. + for (const [nodeId, image] of nodes) { + if (image.block !== 'core/image') continue; + const marker = `block-runner-native-${nodeId.replace(/[^a-zA-Z0-9_-]/g, '-')}`; + const sizing = generated.some((item) => item.kind === 'native-adapter-target' + && (item.property === 'width' || item.property === 'height') + && new RegExp(`^figure\\.wp-block-image\\.${marker} > img(?::(?:hover|focus|focus-visible|active))?$`).test(item.selector)); + if (!sizing) continue; + const element = dom.window.document.querySelector(`figure.wp-block-image.${marker} > img`); + const ratio = image.attributes?.aspectRatio; + if (!element || typeof ratio !== 'string' || !/^\d+ \/ \d+$/.test(ratio) + || image.attributes?.width !== undefined || image.attributes?.height !== undefined + || element.getAttribute('width') !== null || element.getAttribute('height') !== null + || (element as HTMLElement).style.width !== '' || (element as HTMLElement).style.height !== '' + || (element as HTMLElement).style.aspectRatio !== ratio) { + throw new AuthoringGenerationError('unresolved-native-style-mapping: serialized core/image sizing does not retain the native ratio without overriding generated image CSS', 'styles.rules'); + } + } + for (const entry of plan.coverage?.styles ?? []) for (const target of entry.nativeTargets ?? []) { + const selector = target.selector.replace(/:(?:focus-visible|hover|focus|active)\b/g, ''); + try { + const element = dom.window.document.querySelector(selector); + if (!element) { + throw new AuthoringGenerationError(`forged-native-adapter: native target ${target.selector} does not apply to pinned WordPress serialized markup`, 'coverage.styles.nativeTargets'); + } + // Core Image serializes width/height as inline styles. A source CSS-owned axis instead + // keeps both source dimensions in provenance and emits the same intrinsic ratio through + // the supported core/image aspectRatio attribute. + if (target.role === 'image' && target.intrinsic) { + const image = nodes.get(target.node); + if (image?.attributes?.aspectRatio !== target.intrinsic.aspectRatio + || image.attributes?.width !== undefined || image.attributes?.height !== undefined + || element.getAttribute('width') !== null || element.getAttribute('height') !== null + || (element as HTMLElement).style.width !== '' || (element as HTMLElement).style.height !== '' + || (element as HTMLElement).style.aspectRatio !== target.intrinsic.aspectRatio) { + throw new AuthoringGenerationError('unresolved-native-style-mapping: serialized core/image sizing does not retain the recorded intrinsic ratio without overriding authored CSS', 'coverage.styles.nativeTargets'); + } + } + } catch (error) { + if (error instanceof AuthoringGenerationError) throw error; + throw new AuthoringGenerationError('forged-native-adapter: native target selector cannot be applied to pinned WordPress serialized markup', 'coverage.styles.nativeTargets'); + } + } + } finally { + dom.window.close(); + } +} + /** * Metadata remains a permissive JSON transport, but no metadata value may turn a confirmed * static package into code loading. WordPress interprets string `variations` as a PHP file path, diff --git a/src/authoring/schema.ts b/src/authoring/schema.ts index 94c75b9..2c86509 100644 --- a/src/authoring/schema.ts +++ b/src/authoring/schema.ts @@ -42,6 +42,8 @@ export type AuthoringCoverageStyleOutcome = 'native' | 'preset' | 'literal' | 's /** One source declaration and its final destination disposition. */ export interface AuthoringCoverageStyle { + declarationId?: string; + ruleId?: string; property: string; value: string; outcome: AuthoringCoverageStyleOutcome; @@ -62,6 +64,8 @@ export interface AuthoringCoverageStyle { responsive?: 'mobile' | 'tablet'; /** Exact target theme preset provenance for a preset outcome. */ preset?: { category: 'color' | 'spacing' | 'font-size' | 'font-family'; slug: string }; + /** Native destinations emitted in addition to, never instead of, the source declaration. */ + nativeTargets?: Array<{ node: string; role: 'button-link' | 'button-wrapper-reset' | 'image' | 'caption' | 'grid-container'; selector: string; important?: boolean; intrinsic?: { width: string; height: string; aspectRatio: string } }>; } export type AuthoringCoverageAssetOutcome = 'prepared' | 'copied' | 'uploaded' | 'reused' | 'external' | 'unresolved' | 'blocked'; @@ -230,6 +234,8 @@ export type AuthoringCssRule = { kind: 'style'; selector: string; declarations: AuthoringCssDeclaration[]; + /** Compiler-owned supplemental transport; source rules deliberately omit this field. */ + generated?: 'native-adapter-target' | 'native-adapter-wrapper-reset'; } | { kind: 'conditional'; name: 'media' | 'supports' | 'container'; @@ -581,8 +587,10 @@ function normalizeCoverageLocation(input: unknown, location: string): AuthoringC function normalizeCoverageStyle(input: unknown, location: string): AuthoringCoverageStyle { const value = objectAt(input, location); - knownKeys(value, location, ['property', 'value', 'outcome', 'scope', 'reason', 'atRules', 'source', 'transportSelector', 'node', 'responsive', 'preset']); + knownKeys(value, location, ['declarationId', 'ruleId', 'property', 'value', 'outcome', 'scope', 'reason', 'atRules', 'source', 'transportSelector', 'node', 'responsive', 'preset', 'nativeTargets']); return withOptional({ + declarationId: optionalString(value.declarationId, `${location}.declarationId`), + ruleId: optionalString(value.ruleId, `${location}.ruleId`), property: nonEmptyString(value.property, `${location}.property`), value: stringAt(value.value, `${location}.value`), outcome: enumAt(value.outcome, `${location}.outcome`, ['native', 'preset', 'literal', 'scoped-css', 'warned', 'blocked'] as const), @@ -598,6 +606,20 @@ function normalizeCoverageStyle(input: unknown, location: string): AuthoringCove knownKeys(preset, `${location}.preset`, ['category', 'slug']); return { category: enumAt(preset.category, `${location}.preset.category`, ['color', 'spacing', 'font-size', 'font-family'] as const), slug: nonEmptyString(preset.slug, `${location}.preset.slug`) }; })(), + nativeTargets: value.nativeTargets === undefined ? undefined : arrayAt(value.nativeTargets, `${location}.nativeTargets`).map((target, index) => { + const at = `${location}.nativeTargets[${index}]`; + const item = objectAt(target, at); + knownKeys(item, at, ['node', 'role', 'selector', 'important', 'intrinsic']); + const intrinsic = item.intrinsic === undefined ? undefined : objectAt(item.intrinsic, `${at}.intrinsic`); + if (intrinsic) knownKeys(intrinsic, `${at}.intrinsic`, ['width', 'height', 'aspectRatio']); + const role = enumAt(item.role, `${at}.role`, ['button-link', 'button-wrapper-reset', 'image', 'caption', 'grid-container'] as const); + if (intrinsic && role !== 'image') throw invalid(`${at}.intrinsic`, 'is only valid for an image native target'); + const dimensions = intrinsic ? { width: nonEmptyString(intrinsic.width, `${at}.intrinsic.width`), height: nonEmptyString(intrinsic.height, `${at}.intrinsic.height`), aspectRatio: nonEmptyString(intrinsic.aspectRatio, `${at}.intrinsic.aspectRatio`) } : undefined; + if (dimensions && (!/^[1-9]\d*$/.test(dimensions.width) || !/^[1-9]\d*$/.test(dimensions.height) || dimensions.aspectRatio !== `${dimensions.width} / ${dimensions.height}`)) { + throw invalid(`${at}.intrinsic`, 'must retain positive source dimensions and their exact aspect ratio'); + } + return { node: nonEmptyString(item.node, `${at}.node`), role, selector: nonEmptyString(item.selector, `${at}.selector`), ...(item.important === undefined ? {} : { important: booleanAt(item.important, `${at}.important`) }), ...(dimensions ? { intrinsic: dimensions } : {}) }; + }), }); } @@ -775,8 +797,9 @@ function normalizeCssRules(input: unknown, location: string, depth = 0): Authori return { kind, name: enumAt(rule.name, `${at}.name`, ['media', 'supports', 'container'] as const), prelude: nonEmptyString(rule.prelude, `${at}.prelude`), rules: normalizeCssRules(rule.rules, `${at}.rules`, depth + 1) }; } - knownKeys(rule, at, ['kind', 'selector', 'declarations']); - return { kind, selector: nonEmptyString(rule.selector, `${at}.selector`), + knownKeys(rule, at, ['kind', 'selector', 'declarations', 'generated']); + return withOptional({ kind, selector: nonEmptyString(rule.selector, `${at}.selector`), + generated: rule.generated === undefined ? undefined : enumAt(rule.generated, `${at}.generated`, ['native-adapter-target', 'native-adapter-wrapper-reset'] as const), declarations: arrayAt(rule.declarations, `${at}.declarations`).map((inputDeclaration, declarationIndex) => { const declarationAt = `${at}.declarations[${declarationIndex}]`; const declaration = objectAt(inputDeclaration, declarationAt); @@ -789,7 +812,7 @@ function normalizeCssRules(input: unknown, location: string, depth = 0): Authori return withOptional({ property, value, important: optionalBoolean(declaration.important, `${declarationAt}.important`) }); }), - }; + }); }); } diff --git a/src/authoring/styles.ts b/src/authoring/styles.ts index b090b95..fea17a3 100644 --- a/src/authoring/styles.ts +++ b/src/authoring/styles.ts @@ -215,7 +215,7 @@ export function authoringRulesFromStylesheet(rules: readonly CssRule[]): Authori if (rule.kind === 'conditional') return { kind: 'conditional', name: rule.name, prelude: rule.prelude, rules: authoringRulesFromStylesheet(rule.rules) }; return { kind: 'style', selector: rule.selector, declarations: rule.declarations.map(({ property, value, important }) => - ({ property, value, ...(important ? { important } : {}) })) }; + ({ property, value, ...(important ? { important } : {}) })), ...(rule.generated ? { generated: rule.generated } : {}) }; }); } @@ -273,10 +273,11 @@ export function renderConfirmedStyleRules( } return `${declaration.property}: ${declaration.value}${declaration.important ? ' !important' : ''};`; }); - const css = `${indent}${scoped.selector} { ${declarations.join(' ')} }`; + const provenance = rule.generated ? `${indent}/* generated ${rule.generated} */\n` : ''; + const css = `${provenance}${indent}${scoped.selector} { ${declarations.join(' ')} }`; const parsed = postcss.parse(css).nodes; - const parsedRule = parsed[0]; - if (parsed.length !== 1 || parsedRule?.type !== 'rule' || parsedRule.selector !== scoped.selector + const parsedRule = parsed.find((node) => node.type === 'rule'); + if (parsed.length !== (rule.generated ? 2 : 1) || parsedRule?.type !== 'rule' || parsedRule.selector !== scoped.selector || parsedRule.nodes.length !== rule.declarations.length || parsedRule.nodes.some((node, i) => node.type !== 'decl' || node.prop !== rule.declarations[i]!.property diff --git a/src/proof/runner.ts b/src/proof/runner.ts index 9b56344..ca33b09 100644 --- a/src/proof/runner.ts +++ b/src/proof/runner.ts @@ -156,6 +156,37 @@ export interface ProofResponsiveStyleMatrix { }[]; } +/** + * Focused evidence for source styles adapted to Core Button, Image, and Group + * markup. It deliberately names only the browser-observable relationships + * owned by the native adapter; it is not a general layout assertion surface. + */ +export interface NativeStyleAdapterMatrix { + button: { + /** Selector relative to the generated root for the Core Button wrapper. */ + wrapperSelector: string; + /** Selector relative to the generated root for its interactive anchor. */ + linkSelector: string; + /** Computed source padding which must belong to the anchor, never its wrapper. */ + linkPadding: { top: string; right: string; bottom: string; left: string }; + /** The authored hover transform and colour, each observed exactly on the anchor. */ + hover: { transform: string; backgroundColor: string }; + /** The authored keyboard focus outline on the anchor. */ + focusOutline: { style: string; width: string }; + }; + image: { + selector: string; + /** Preserved intrinsic evidence represented by the native image target. */ + sourceDimensions: { width: string; height: string; aspectRatio: string }; + alt: string; + caption: string; + }; + grid: { + selector: string; + samples: readonly { label: string; viewport: { width: number; height: number }; surfaceViewport?: { width: number; height?: number }; columns: number }[]; + }; +} + /** The exact per-instance map WordPress stores at core/block.attributes.content. */ export type PatternOverrideContent = Record>; @@ -201,6 +232,8 @@ export interface ProofFixture { * it does not require the historical heading/image layout reproduction. */ responsiveStyleMatrix?: ProofResponsiveStyleMatrix; + /** Public author()-to-WordPress proof for the bounded native style adapters. */ + nativeStyleAdapterMatrix?: NativeStyleAdapterMatrix; patternOverrides?: { /** Exact pattern title inserted through the visible inserter. */ title: string; @@ -837,6 +870,7 @@ function createRuntime( let environmentStarted = false; let staticPluginDeactivated = false; let deactivationEvidence: EvidenceReference | undefined; + let preparedNativeStyleMedia: { url: string; evidence: EvidenceReference } | undefined; let browserResults: Partial> | undefined; let deactivatedBrowserResults: Partial> | undefined; let stagedPluginZip: { host: string; container: string } | undefined; @@ -951,6 +985,16 @@ function createRuntime( return browserResults; } } + if (mode === 'active' && options.fixture.nativeStyleAdapterMatrix) { + preparedNativeStyleMedia = await prepareNativeStyleAdapterMedia(); + if (!preparedNativeStyleMedia) { + browserResults = Object.fromEntries(browserGateIds.map((gate) => [gate, { + status: 'blocked', + reason: 'Could not prepare the native style adapter proof image at its authored upload URL.', + }])) as Partial>; + return browserResults; + } + } const fixture = preparedPattern ? { ...options.fixture, @@ -972,7 +1016,10 @@ function createRuntime( : undefined, } : options.fixture; - await writeFile(config, JSON.stringify({ fixture, profile: options.profile, requiredGates, baseUrl: 'http://localhost:8888', mode, publication }), 'utf8'); + const browserFixture = preparedNativeStyleMedia && fixture.frontend + ? { ...fixture, frontend: { ...fixture.frontend, expectedMedia: [preparedNativeStyleMedia.url] } } + : fixture; + await writeFile(config, JSON.stringify({ fixture: browserFixture, profile: options.profile, requiredGates, baseUrl: 'http://localhost:8888', mode, publication }), 'utf8'); const result = await command(process.execPath, [playwrightHelper, '--config', config, '--out', report], { cwd: projectRoot, timeoutMs: PROOF_COMMAND_TIMEOUTS.browser, @@ -1009,7 +1056,7 @@ function createRuntime( })); return [[gate, { ...value, - evidence: [...(value.evidence ?? []), ...artifacts, logs, ...(phaseEvidence ? [phaseEvidence] : [])], + evidence: [...(value.evidence ?? []), ...artifacts, logs, ...(preparedNativeStyleMedia ? [preparedNativeStyleMedia.evidence] : []), ...(phaseEvidence ? [phaseEvidence] : [])], }]]; }))).flat(), ) as Partial>; @@ -1121,6 +1168,37 @@ function createRuntime( return undefined; } }; + + /** + * The utility hero's authored image is a site-relative upload URL, not a + * media-control replacement. Materialize those exact bytes at that URL so + * frontend_media proves the emitted source can load in WordPress. + */ + const prepareNativeStyleAdapterMedia = async (): Promise<{ url: string; evidence: EvidenceReference } | undefined> => { + if (preparedNativeStyleMedia) return preparedNativeStyleMedia; + const php = [ + `$png = base64_decode('${PROOF_IMAGE_BASE64}');`, + "$uploadDirFilter = static function ($uploads) { $uploads['path'] = $uploads['basedir']; $uploads['url'] = $uploads['baseurl']; $uploads['subdir'] = ''; return $uploads; };", + "add_filter('upload_dir', $uploadDirFilter);", + "try { $directory = wp_upload_dir(); $existing = $directory['basedir'] . '/block-runner-editor.png'; $upload = is_file($existing) ? array('file' => $existing, 'url' => $directory['baseurl'] . '/block-runner-editor.png') : wp_upload_bits('block-runner-editor.png', null, $png); } finally { remove_filter('upload_dir', $uploadDirFilter); }", + "if (!empty($upload['error'])) { throw new RuntimeException('Native style adapter proof image upload failed: ' . $upload['error']); }", + "$file = $upload['file'];", + "if (!is_readable($file) || hash_file('sha256', $file) !== hash('sha256', $png)) { throw new RuntimeException('Native style adapter proof image bytes were not retained correctly.'); }", + "$expectedUrl = content_url('/uploads/block-runner-editor.png');", + "if ($upload['url'] !== $expectedUrl) { throw new RuntimeException('Native style adapter proof image upload URL did not match the authored source.'); }", + "echo json_encode(array('url' => $upload['url']));", + ].join(' '); + const { result, evidence } = await wp(php); + if (result.exitCode !== 0) return undefined; + try { + const observed = JSON.parse(result.stdout.trim()) as { url?: unknown }; + if (typeof observed.url !== 'string' || !observed.url) return undefined; + preparedNativeStyleMedia = { url: observed.url, evidence }; + return preparedNativeStyleMedia; + } catch { + return undefined; + } + }; return { async run(context) { if (context.gate === 'headless_validation') { diff --git a/src/types.ts b/src/types.ts index 950f239..345ba02 100644 --- a/src/types.ts +++ b/src/types.ts @@ -32,6 +32,8 @@ export interface ReportItem { source?: SourceLocation; rule?: string; details?: unknown; + /** Stable machine-readable failure category when authoring cannot map native markup. */ + code?: string; } export interface ReportSummary { @@ -134,6 +136,9 @@ export interface AuthorSourceEvidence { export type AuthoredStyleOutcome = 'native' | 'preset' | 'literal' | 'scoped-css' | 'warned' | 'blocked'; export interface AuthoredStyleLedgerEntry { + /** Scanner identity; unlike property/value this cannot collide with a repeated declaration. */ + declarationId?: string; + ruleId?: string; property: string; value: string; outcome: AuthoredStyleOutcome; @@ -145,6 +150,8 @@ export interface AuthoredStyleLedgerEntry { /** Native-node evidence for a conservatively lifted WordPress responsive declaration. */ node?: string; responsive?: 'mobile' | 'tablet'; + /** Compiler-owned native adapter destinations for this original declaration. */ + nativeTargets?: Array<{ node: string; role: 'button-link' | 'button-wrapper-reset' | 'image' | 'caption' | 'grid-container'; selector: string; intrinsic?: { width: string; height: string; aspectRatio: string } }>; } /** The terminal disposition of a source asset. */ diff --git a/test/author.native-style-adapters.test.ts b/test/author.native-style-adapters.test.ts new file mode 100644 index 0000000..cc61654 --- /dev/null +++ b/test/author.native-style-adapters.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'vitest'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { author, collectSourceEvidence } from '../src/index.js'; +import { compileRegisteredBlock } from '../src/authoring/generate.js'; +import { nativeSelectorSubjects } from '../src/author/styles.js'; + +function refs(html: string) { + const entries = collectSourceEvidence(html).structure; + return (tag: string, index = 0) => entries.filter((entry) => entry.tag === tag)[index]!.sourceRef!; +} + +const utilitySourceRoot = path.resolve('benchmarks/authoring/sources/utility'); +const omissions = (html: string, used: ReadonlySet) => collectSourceEvidence(html).structure + .filter((entry) => /^(h[1-6]|p|li|figure|a)$/.test(entry.tag) && entry.sourceRef && !used.has(entry.sourceRef)) + .map((entry) => ({ action: 'omit' as const, sourceRef: entry.sourceRef!, reason: 'This focused adapter case intentionally covers only the native style subjects.' })); + +describe('native source-style adapters', () => { + it('applies shared adapters to the checked-in utility hero, split-feature, and cards sources', async () => { + const hero = await readFile(path.join(utilitySourceRoot, 'hero.html'), 'utf8'); + const heroRef = refs(hero); + const heroUsed = new Set([heroRef('a'), heroRef('a', 1), heroRef('figure')]); + const heroReport = await author(hero, { sourcePath: path.join(utilitySourceRoot, 'hero.html'), author: { name: 'example/utility-hero', styles: { mode: 'css', css: '.grid { display:grid; grid-template-columns:repeat(2,1fr); gap:3rem; } .px-5:hover { transform:translateY(-2px); padding:1.25rem; transition:transform 150ms; } .focus-visible\\:outline:focus-visible { outline:2px solid currentColor; } .w-full { width:100%; } .mt-3 { color:#94a3b8; }' } }, proposal: { structure: [{ id: 'hero', block: 'core/group', sourceRef: heroRef('section'), children: [{ id: 'grid', block: 'core/group', sourceRef: heroRef('div'), children: [{ id: 'buttons', block: 'core/buttons', sourceRef: heroRef('div', 2), children: [{ id: 'download', block: 'core/button', sourceRef: heroRef('a') }, { id: 'guide', block: 'core/button', sourceRef: heroRef('a', 1) }] }, { id: 'image', block: 'core/image', sourceRef: heroRef('figure') }] }] }], sourceDecisions: omissions(hero, heroUsed) } }); + expect(heroReport.ok, JSON.stringify(heroReport.items)).toBe(true); + const heroPlan = heroReport.package!.canonicalPlan!; + expect(heroPlan.coverage!.styles.some((entry) => entry.nativeTargets?.some((target) => target.role === 'button-link'))).toBe(true); + expect(heroPlan.coverage!.styles.some((entry) => entry.nativeTargets?.some((target) => target.role === 'caption'))).toBe(true); + expect(heroPlan.coverage!.styles.some((entry) => entry.nativeTargets?.some((target) => target.intrinsic?.aspectRatio === '1280 / 820'))).toBe(true); + + const split = await readFile(path.join(utilitySourceRoot, 'split-feature.html'), 'utf8'); + const splitRef = refs(split); + const splitUsed = new Set([splitRef('img'), splitRef('a')]); + const splitReport = await author(split, { sourcePath: path.join(utilitySourceRoot, 'split-feature.html'), author: { name: 'example/utility-split', styles: { mode: 'css', css: '.grid { display:grid; grid-template-columns:repeat(2,1fr); gap:2.5rem; } .w-full { width:100%; } .hover\\:text-indigo-900:hover { color:#312e81; }' } }, proposal: { structure: [{ id: 'feature', block: 'core/group', sourceRef: splitRef('section'), children: [{ id: 'image', block: 'core/image', sourceRef: splitRef('img') }, { id: 'buttons', block: 'core/buttons', children: [{ id: 'link', block: 'core/button', sourceRef: splitRef('a') }] }] }], sourceDecisions: omissions(split, splitUsed) } }); + expect(splitReport.ok, JSON.stringify(splitReport.items)).toBe(true); + expect(splitReport.package!.canonicalPlan!.structure[0]!.attributes?.layout).toMatchObject({ type: 'grid' }); + expect(splitReport.package!.canonicalPlan!.coverage!.styles.some((entry) => entry.nativeTargets?.some((target) => target.role === 'image'))).toBe(true); + + const cards = await readFile(path.join(utilitySourceRoot, 'cards.html'), 'utf8'); + const cardsRef = refs(cards); + const cardsUsed = new Set([cardsRef('a'), cardsRef('a', 1), cardsRef('a', 2)]); + const cardsReport = await author(cards, { sourcePath: path.join(utilitySourceRoot, 'cards.html'), author: { name: 'example/utility-cards', styles: { mode: 'css', css: '.grid { display:grid; grid-template-columns:repeat(3,1fr); gap:1.5rem; } .inline-flex:hover { transform:translateY(-2px); }' } }, proposal: { structure: [{ id: 'cards', block: 'core/group', sourceRef: cardsRef('section'), children: [{ id: 'grid', block: 'core/group', sourceRef: cardsRef('div', 1), children: [{ id: 'buttons-1', block: 'core/buttons', children: [{ id: 'card-1', block: 'core/button', sourceRef: cardsRef('a') }] }, { id: 'buttons-2', block: 'core/buttons', children: [{ id: 'card-2', block: 'core/button', sourceRef: cardsRef('a', 1) }] }, { id: 'buttons-3', block: 'core/buttons', children: [{ id: 'card-3', block: 'core/button', sourceRef: cardsRef('a', 2) }] }] }] }], sourceDecisions: omissions(cards, cardsUsed) } }); + expect(cardsReport.ok, JSON.stringify(cardsReport.items)).toBe(true); + expect(cardsReport.package!.canonicalPlan!.structure[0]!.children![0]!.attributes?.layout).toMatchObject({ type: 'grid' }); + expect(cardsReport.package!.canonicalPlan!.coverage!.styles.some((entry) => entry.nativeTargets?.some((target) => target.role === 'button-link'))).toBe(true); + }, 30_000); + + it('keeps button effects off the wrapper and maps direct image/caption and owned grids', async () => { + const html = '
Image
With source
'; + const ref = refs(html); + const report = await author(html, { + author: { name: 'example/native-adapter', styles: { mode: 'css', css: [ + '.grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; }', + '.move:hover { margin-left: 1rem; transform: translateY(-2px); transition: transform 100ms; }', + '.focus-visible\\:ring:focus-visible { outline: 2px solid red; }', + '.media-img { width: 320px; height: 180px; }', + 'figure.figure > img.figure-img { width: 200px; }', + 'figure.figure > figcaption.caption { color: red; }', + ].join('\n') } }, + proposal: { structure: [{ id: 'grid', block: 'core/group', sourceRef: ref('section'), children: [ + { id: 'buttons', block: 'core/buttons', sourceRef: ref('div'), children: [{ id: 'button', block: 'core/button', sourceRef: ref('a') }] }, + { id: 'direct', block: 'core/image', sourceRef: ref('img') }, + { id: 'figure', block: 'core/image', sourceRef: ref('figure') }, + ] }] }, + }); + expect(report.ok, JSON.stringify(report.items)).toBe(true); + const plan = report.package!.canonicalPlan!; + const generated = plan.styles.rules!.filter((rule): rule is Extract => rule.kind === 'style' && Boolean(rule.generated)); + expect(generated.some((rule) => rule.selector.includes('.wp-block-button__link:hover'))).toBe(true); + expect(generated.some((rule) => rule.generated === 'native-adapter-wrapper-reset' && rule.declarations.some((declaration) => declaration.property === 'margin-left' && declaration.value === '0'))).toBe(true); + expect(generated.some((rule) => rule.selector.includes('> figcaption.wp-element-caption'))).toBe(true); + expect(plan.coverage!.styles.some((entry) => entry.nativeTargets?.some((target) => target.role === 'caption'))).toBe(true); + expect(plan.structure[0]!.attributes?.layout).toMatchObject({ type: 'grid' }); + }); + + it('rejects grid declarations without unconditional source display:grid', async () => { + const html = '

Grid

'; + const report = await author(html, { + author: { name: 'example/no-grid-owner', styles: { mode: 'css', css: '@media (max-width: 600px) { .grid { gap: 1rem; } }' } }, + proposal: { structure: [{ id: 'grid', block: 'core/group', sourceRef: refs(html)('section'), attributes: { layout: { type: 'grid' } }, children: [{ id: 'copy', block: 'core/paragraph', sourceRef: refs(html)('p') }] }] }, + }); + expect(report.package).toBeUndefined(); + expect(report.items).toEqual(expect.arrayContaining([expect.objectContaining({ code: 'unresolved-native-style-mapping' })])); + }); + + it('reads complete image class atoms and bounded figure child compounds', () => { + expect(nativeSelectorSubjects('.media-img')).toMatchObject([{ staticSelector: '.media-img', classes: [{ decoded: 'media-img' }] }]); + expect(nativeSelectorSubjects('figure.frame > img.media-img:hover')).toMatchObject([{ + staticSelector: 'figure.frame', relation: 'image', terminalSelector: 'img.media-img', state: 'hover', + }]); + expect(nativeSelectorSubjects('figure.frame > figcaption.caption:focus-visible')).toMatchObject([{ + staticSelector: 'figure.frame', relation: 'caption', terminalSelector: 'figcaption.caption', state: 'focus-visible', + }]); + expect(nativeSelectorSubjects('.focus-visible\\:outline')).toMatchObject([{ classes: [{ decoded: 'focus-visible:outline' }] }]); + }); + + it('rejects unsupported authored grid properties with separate CSS and HTML provenance', async () => { + const html = '

Grid

'; + const report = await author(html, { + sourcePath: '/a deliberately long source path/with spaces/utility grid.html', + author: { name: 'example/unsupported-grid', styles: { mode: 'css', css: '.grid { display: grid; grid-auto-flow: dense; }' } }, + proposal: { structure: [{ id: 'grid', block: 'core/group', sourceRef: refs(html)('section'), children: [{ id: 'copy', block: 'core/paragraph', sourceRef: refs(html)('p') }] }] }, + }); + const item = report.items.find((candidate) => candidate.code === 'unresolved-native-style-mapping')!; + expect(report.package).toBeUndefined(); + expect(item.source).toMatchObject({ path: '/a deliberately long source path/with spaces/utility grid.html' }); + expect(item.details).toMatchObject({ cssSource: { selector: '.grid' }, htmlSource: { path: '/a deliberately long source path/with spaces/utility grid.html' } }); + }); + + it('preserves intrinsic image dimensions as ratio provenance when authored CSS owns sizing', async () => { + const html = ''; + const report = await author(html, { + sourcePath: '/Users/example/a path with spaces/utility/hero intrinsic image source.html', + author: { name: 'example/image-sizing', styles: { mode: 'css', css: '.media-img { width: 100%; }' } }, + proposal: { structure: [{ id: 'image', block: 'core/image', sourceRef: refs(html)('img') }] }, + }); + expect(report.ok, JSON.stringify(report.items)).toBe(true); + const plan = report.package!.canonicalPlan!; + expect(plan.structure[0]!.attributes).toMatchObject({ aspectRatio: '1280 / 820' }); + expect(plan.structure[0]!.attributes).not.toHaveProperty('width'); + expect(plan.structure[0]!.attributes).not.toHaveProperty('height'); + expect(plan.coverage!.styles[0]!.nativeTargets![0]!.intrinsic).toEqual({ width: '1280', height: '820', aspectRatio: '1280 / 820' }); + expect(compileRegisteredBlock(plan).template[0]![1]).toMatchObject({ aspectRatio: '1280 / 820' }); + const forged = structuredClone(plan); + for (const entry of forged.coverage!.styles) for (const target of entry.nativeTargets ?? []) delete target.intrinsic; + forged.structure[0]!.attributes!.width = '1280'; + forged.structure[0]!.attributes!.height = '820'; + expect(() => compileRegisteredBlock(forged)).toThrow(/without overriding generated image CSS/); + }); + + it('locates an unsupported relationship on its matched unwrapped anchor, not the first binding', async () => { + const html = ''; + const report = await author(html, { + sourcePath: '/Users/warden/Library/Application Support/Block Runner/previews/2026-09-05/export/long-project/preview.html', + 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')!; + 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' } }); + }); + + it('retains every matched unwrapped anchor for an unsupported relationship', async () => { + const html = ''; + const report = await author(html, { + sourcePath: '/Users/warden/Library/Application Support/Block Runner/previews/2026-09-05/export/long-project/preview.html', + 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')!; + 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'); + }); + + it('rejects adapter targets whose marker is absent from serialized native markup', async () => { + const html = ''; + const report = await author(html, { + author: { name: 'example/marker-proof', styles: { mode: 'css', css: '.move:hover { color: red; }' } }, + proposal: { structure: [{ id: 'buttons', block: 'core/buttons', sourceRef: refs(html)('div'), children: [{ id: 'button', block: 'core/button', sourceRef: refs(html)('a') }] }] }, + }); + expect(report.ok, JSON.stringify(report.items)).toBe(true); + const plan = structuredClone(report.package!.canonicalPlan!); + plan.structure[0]!.children![0]!.attributes!.className = ''; + expect(() => compileRegisteredBlock(plan)).toThrow(/serialized markup/); + }); + + it('rejects submitted native-target provenance with altered declaration importance', async () => { + const html = ''; + const report = await author(html, { + author: { name: 'example/importance-proof', styles: { mode: 'css', css: '.move:hover { color: red; }' } }, + proposal: { structure: [{ id: 'buttons', block: 'core/buttons', sourceRef: refs(html)('div'), children: [{ id: 'button', block: 'core/button', sourceRef: refs(html)('a') }] }] }, + }); + const plan = structuredClone(report.package!.canonicalPlan!); + plan.coverage!.styles.find((entry) => entry.nativeTargets?.length)!.nativeTargets![0]!.important = true; + expect(() => compileRegisteredBlock(plan)).toThrow(/selector\/provenance/); + }); +}); diff --git a/test/author.proposal.test.ts b/test/author.proposal.test.ts index 7bee20d..55afd64 100644 --- a/test/author.proposal.test.ts +++ b/test/author.proposal.test.ts @@ -207,7 +207,7 @@ describe('author proposal boundary', () => { ])('binds every editable hero unit from %s', async (relative) => { const { html, sourcePath } = await source(relative); const ref = refs(html); - const report = await author(html, { sourcePath, author: { name: 'example/hero' }, proposal: { structure: [{ id: 'hero', block: 'core/group', sourceRef: ref('section'), children: [ + const report = await author(html, { sourcePath, author: { name: 'example/hero', ...(relative === 'utility/hero.html' ? { styles: { mode: 'css' as const, css: '.px-5 { padding: 1.25rem; transition: transform 150ms; } .hover\\:bg-cyan-200:hover { transform: translateY(-2px); } .focus-visible\\:outline:focus-visible { outline: 2px solid currentColor; }' } } : {}) }, proposal: { structure: [{ id: 'hero', block: 'core/group', sourceRef: ref('section'), children: [ { id: 'content', block: 'core/group', sourceRef: ref('div', 1), children: [ { id: 'eyebrow', block: 'core/paragraph', sourceRef: ref('p', 0) }, { id: 'title', block: 'core/heading', sourceRef: ref('h1') }, @@ -230,6 +230,12 @@ describe('author proposal boundary', () => { expect(nodes.get('image')!.attributes).toMatchObject({ url: '/wp-content/uploads/block-runner-editor.png', alt: 'A WordPress editor sidebar with editable block controls', caption: 'Native controls stay with the block, not in a screenshot.' }); expect(plan.fields).toEqual(expect.arrayContaining([expect.objectContaining({ id: 'title-content', mode: 'editable' })])); expect(plan.locking).toEqual({ mode: 'contentOnly' }); + if (relative === 'utility/hero.html') { + const generated = plan.styles.rules?.filter((rule): rule is Extract => rule.kind === 'style' && Boolean(rule.generated)) ?? []; + expect(generated.some((rule) => rule.generated === 'native-adapter-target' && rule.selector.includes('.wp-block-button__link'))).toBe(true); + expect(generated.some((rule) => rule.generated === 'native-adapter-wrapper-reset')).toBe(true); + expect(plan.coverage!.styles.some((entry) => entry.nativeTargets?.some((target) => target.role === 'button-link'))).toBe(true); + } validateSourceContent(html, compileRegisteredBlock(plan).template); }); diff --git a/test/author.styles.scope.test.ts b/test/author.styles.scope.test.ts index 05fc5da..19e13fe 100644 --- a/test/author.styles.scope.test.ts +++ b/test/author.styles.scope.test.ts @@ -1,7 +1,20 @@ import { describe, expect, it } from 'vitest'; -import { scanStylesheet, scopeLocalSelectorList } from '../src/author/styles.js'; +import { nativeSelectorSubjects, scanStylesheet, scopeLocalSelectorList } from '../src/author/styles.js'; describe('component CSS selector scoping', () => { + it('reads complete escaped utility atoms without accepting a longer suffix atom', () => { + const subjects = nativeSelectorSubjects('.focus-visible\\:outline:focus-visible, .focus-visible\\:outline-2:focus-visible'); + expect(subjects?.map((subject) => subject.classes[0]!.decoded)).toEqual(['focus-visible:outline', 'focus-visible:outline-2']); + expect(subjects?.[0]?.classes[0]?.raw).toBe('.focus-visible\\:outline'); + expect(nativeSelectorSubjects('.card > .cta')).toBeUndefined(); + }); + + it('retains each selector-list interaction state and the bounded figure relationship', () => { + const subjects = nativeSelectorSubjects('.hover\\:lift:hover, .focus\\:ring:focus-visible, figure.media > figcaption:focus'); + expect(subjects?.map((subject) => subject.state)).toEqual(['hover', 'focus-visible', 'focus']); + expect(subjects?.[2]).toMatchObject({ staticSelector: 'figure.media', relation: 'caption' }); + }); + it('uses a zero-specificity root boundary while retaining local selector states and lists', () => { const scoped = scopeLocalSelectorList(':where(.card), .card:hover::before', '.wp-block-acme-card'); diff --git a/test/proof-control-setup.test.ts b/test/proof-control-setup.test.ts index 15c5834..99d0457 100644 --- a/test/proof-control-setup.test.ts +++ b/test/proof-control-setup.test.ts @@ -7,15 +7,24 @@ import { runProof } from '../src/proof/runner.js'; import { startWithPreparedStageMount } from '../scripts/proof-control-startup.mjs'; describe('WordPress control startup and retained failures', () => { - it('keeps the wp-env mount schema and media byte contract outside WordPress uploads', () => { + it('keeps the wp-env mount schema and native adapter media byte contract outside WordPress uploads', () => { const config = JSON.parse(readFileSync(new URL('../proof/wp-env.json', import.meta.url), 'utf8')); expect(config.mappings).toEqual({ 'wp-content/block-runner-proof': '.block-runner-proof-stage' }); const runner = readFileSync(new URL('../src/proof/runner.ts', import.meta.url), 'utf8'); + const helper = runner.match(/const prepareNativeStyleAdapterMedia[\s\S]*?\n };/)?.[0]; + expect(helper).toBeDefined(); // These checks intentionally guard WordPress upload failures and retained-byte // corruption, which a successful runtime install cannot deterministically inject. - expect(runner).toContain('wp_upload_bits($filename, null, $png)'); - expect(runner).toContain("if (!empty($upload['error']))"); - expect(runner).toContain("hash_file('sha256', $file) !== hash('sha256', $png)"); + expect(helper).toContain("wp_upload_bits('block-runner-editor.png', null, $png)"); + expect(helper).toContain("if (!empty($upload['error']))"); + expect(helper).toContain("hash_file('sha256', $file) !== hash('sha256', $png)"); + expect(helper).toContain("add_filter('upload_dir', $uploadDirFilter)"); + expect(helper).toContain("remove_filter('upload_dir', $uploadDirFilter)"); + expect(helper).toContain("$uploads['path'] = $uploads['basedir']"); + expect(helper).toContain("$uploads['url'] = $uploads['baseurl']"); + expect(helper).toContain("$uploads['subdir'] = ''"); + expect(helper).toContain("content_url('/uploads/block-runner-editor.png')"); + expect(helper).toContain("$upload['url'] !== $expectedUrl"); expect(runner).not.toContain('file_put_contents($file, $png)'); }); diff --git a/test/proof-field-persistence.test.ts b/test/proof-field-persistence.test.ts index 1cd4f1b..a75380f 100644 --- a/test/proof-field-persistence.test.ts +++ b/test/proof-field-persistence.test.ts @@ -69,6 +69,20 @@ describe('native field persistence scope', () => { expect(check(before, state([heading('wanted', 'Updated plus unexpected text')]), [field], 'acme/hero').ok).toBe(false); }); + it('rejects matching text on the wrong selector block and accepts the resolved selector owner', () => { + const selectorField = { path: 'title', surface: 'richText', selector: 'h1.wp-block-heading[contenteditable="true"]', value: 'Updated' }; + const after = { + contentHash: 'after', treeHash: 'after', content: 'Updated appears somewhere', + tree: [{ name: 'acme/hero', clientId: 'root', innerBlocks: [ + { name: 'core/heading', clientId: 'wrong', attributes: { content: 'Updated' } }, + { name: 'core/heading', clientId: 'target', attributes: { content: 'Unchanged' } }, + ] }], + }; + expect(check(before, after, [selectorField], 'acme/hero', [{ clientId: 'target', ownership: { belongsToRoot: true } }]).ok).toBe(false); + after.tree[0].innerBlocks[1].attributes.content = 'Updated'; + expect(check(before, after, [selectorField], 'acme/hero', [{ clientId: 'target', ownership: { belongsToRoot: true } }]).ok).toBe(true); + }); + it('requires all prepared media properties instead of skipping media persistence', () => { const media = { id: 7, url: 'https://example.test/image.png', alt: 'Description' }; const input = { path: 'image', surface: 'media', media }; diff --git a/test/proof-native-style-adapter-builder.test.ts b/test/proof-native-style-adapter-builder.test.ts new file mode 100644 index 0000000..91ce322 --- /dev/null +++ b/test/proof-native-style-adapter-builder.test.ts @@ -0,0 +1,39 @@ +import { existsSync } from 'node:fs'; +import { mkdtemp, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { buildNativeStyleAdapterProofFixture } from '../scripts/build-pattern-overrides-fixture.js'; + +describe('native style adapter proof fixture', () => { + it('retains and pins the public utility hero package with authored image sizing', async () => { + const outputDir = await mkdtemp(path.join(tmpdir(), 'block-runner-native-style-adapter-builder-')); + const built = await buildNativeStyleAdapterProofFixture(outputDir); + expect(existsSync(built.pluginZip)).toBe(true); + expect(built.fixture.nativeStyleAdapterMatrix).toBeDefined(); + expect(built.fixture.editableFields).toContainEqual(expect.objectContaining({ + path: 'title-content', + surface: 'richText', + selector: 'h1.wp-block-heading[contenteditable="true"]', + })); + expect(existsSync(path.join(outputDir, 'native-style-adapter.original.html'))).toBe(true); + await expect(readFile(path.join(outputDir, 'native-style-adapter.original.html'), 'utf8')).resolves.toContain('Build a WordPress block'); + await expect(readFile(path.join(outputDir, 'native-style-adapter.supplied.css'), 'utf8')).resolves.toContain('.focus-visible\\:outline:focus-visible'); + await expect(readFile(path.join(outputDir, 'native-style-adapter.supplied.css'), 'utf8')).resolves.toContain('width: 100%'); + await expect(readFile(path.join(outputDir, 'native-style-adapter.proposal.json'), 'utf8')).resolves.toContain('core/image'); + await expect(readFile(path.join(outputDir, 'native-style-adapter.canonical-plan.json'), 'utf8')).resolves.toContain('native-adapter-target'); + await expect(readFile(path.join(outputDir, 'native-style-adapter.native.blocks.html'), 'utf8')).resolves.toContain('wp-block-button__link'); + await expect(readFile(path.join(outputDir, 'native-style-adapter.plugin-identity.json'), 'utf8')).resolves.toContain(built.artifact.sha256); + const manifest = JSON.parse(await readFile(built.inputPath, 'utf8')) as { inputs?: Record }; + expect(Object.keys(manifest.inputs ?? {}).sort()).toEqual([ + 'native-style-adapter.canonical-plan.json', + 'native-style-adapter.native.blocks.html', + 'native-style-adapter.original.html', + 'native-style-adapter.plugin-identity.json', + 'native-style-adapter.proposal.json', + 'native-style-adapter.supplied.css', + path.basename(built.pluginZip), + ].sort()); + expect(Object.values(manifest.inputs ?? {}).every((value) => /^sha256:[a-f0-9]{64}$/.test(value))).toBe(true); + }, 360_000); +}); diff --git a/test/proof-real-wordpress.test.ts b/test/proof-real-wordpress.test.ts index 8dec264..073cbce 100644 --- a/test/proof-real-wordpress.test.ts +++ b/test/proof-real-wordpress.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { promisify } from 'node:util'; import { describe, expect, it } from 'vitest'; -import { buildPatternOverridesFixture, buildResponsiveStyleProofFixture } from '../scripts/build-pattern-overrides-fixture.js'; +import { buildNativeStyleAdapterProofFixture, buildPatternOverridesFixture, buildResponsiveStyleProofFixture } from '../scripts/build-pattern-overrides-fixture.js'; import { PROOF_PROFILES, canonicalJson, @@ -34,6 +34,17 @@ type ResponsiveStyleMatrixEvidence = { }>; }; +type NativeStyleAdapterMatrixEvidence = { + scope?: string; + button?: { wrapperNeutral?: boolean; paddingMatches?: boolean; aligned?: boolean; hoverMatches?: boolean; focusMatches?: boolean }; + image?: { + sourceDimensions?: { width?: string; height?: string; aspectRatio?: string }; + observed?: { width?: string | null; height?: string | null; alt?: string | null; inlineWidth?: string; inlineHeight?: string; loaded?: boolean }; + caption?: string; ratioMatches?: boolean; matches?: boolean; + }; + grid?: { matches?: boolean; samples?: Array<{ label?: string; columns?: number; expected?: number }> }; +}; + /** * This deliberately runs only through `npm run test:proof:wordpress`, after * the ordinary repository checks. It is unskipped there: Docker and a daemon @@ -214,6 +225,56 @@ describe('real WordPress generated-pattern full-profile receipt', () => { } }, 480_000); + it('proves the public utility-hero native style adapters after save/reopen and on the published frontend', async () => { + await requireDocker(); + const outputDir = await proofOutputDirectory('native-style-adapter'); + const built = await buildNativeStyleAdapterProofFixture(outputDir); + const result = await runProof({ + profile: 'fidelity-checked', + pluginZip: built.pluginZip, + inputPath: built.inputPath, + markup: built.nativeContainerMarkup, + fixture: built.fixture, + artifact: built.artifact, + outputDir, + }); + const editor = result.receipt.gates.find((gate) => gate.gate === 'editor_reopen'); + const frontend = result.receipt.gates.find((gate) => gate.gate === 'frontend_assets'); + const editorMatrix = (editor?.details as { nativeStyleAdapterMatrix?: NativeStyleAdapterMatrixEvidence } | undefined)?.nativeStyleAdapterMatrix; + const frontendMatrix = (frontend?.details as { nativeStyleAdapterMatrix?: NativeStyleAdapterMatrixEvidence } | undefined)?.nativeStyleAdapterMatrix; + expect(editor?.status, editor?.reason).toBe('pass'); + expect(frontend?.status, frontend?.reason).toBe('pass'); + for (const matrix of [editorMatrix, frontendMatrix]) { + expect(matrix).toMatchObject({ + scope: expect.stringMatching(/editor-canvas|frontend/), + button: { wrapperNeutral: true, paddingMatches: true, aligned: true, hoverMatches: true, focusMatches: true }, + image: { + sourceDimensions: { width: '1280', height: '820', aspectRatio: '1280 / 820' }, + observed: { alt: 'A WordPress editor sidebar with editable block controls', inlineWidth: '', loaded: true }, + caption: 'Native controls stay with the block, not in a screenshot.', ratioMatches: true, renderedRatioMatches: true, sizingMatches: true, matches: true, + }, + grid: { matches: true, samples: expect.arrayContaining([ + expect.objectContaining({ label: 'one-column', columns: 1, expected: 1 }), + expect.objectContaining({ label: 'two-column', columns: 2, expected: 2 }), + ]) }, + }); + } + expect(frontendMatrix?.image?.observed).toMatchObject({ width: null, height: null, inlineWidth: '', inlineHeight: '' }); + const savedContent = (editor?.details as { saved?: { content?: string } } | undefined)?.saved?.content; + expect(savedContent).toContain('wp:image'); + expect(savedContent).not.toMatch(/]+\s(?:width|height)=/); + expect(savedContent).not.toMatch(/]+style="[^"]*(?:width|height)\s*:/); + expect(retainedMediaTypes(editor)).toEqual(expect.arrayContaining(['application/json', 'image/png'])); + expect(retainedMediaTypes(frontend)).toEqual(expect.arrayContaining(['application/json', 'image/png'])); + await expect(readFile(path.join(outputDir, 'native-style-adapter.original.html'), 'utf8')).resolves.toContain('Build a WordPress block'); + await expect(readFile(path.join(outputDir, 'native-style-adapter.supplied.css'), 'utf8')).resolves.toContain('.hover\\:bg-cyan-200:hover'); + await expect(readFile(path.join(outputDir, 'native-style-adapter.proposal.json'), 'utf8')).resolves.toContain('core/button'); + await expect(readFile(path.join(outputDir, 'native-style-adapter.canonical-plan.json'), 'utf8')).resolves.toContain('native-adapter-target'); + await expect(readFile(path.join(outputDir, 'native-style-adapter.native.blocks.html'), 'utf8')).resolves.toContain('wp-block-button__link'); + await expect(readFile(path.join(outputDir, 'native-style-adapter.plugin-identity.json'), 'utf8')).resolves.toContain(built.artifact.sha256); + await expect(readFile(built.inputPath, 'utf8')).resolves.toContain('native-style-adapter.native.blocks.html'); + }, 480_000); + it.each(['editor-verified', 'fidelity-checked', 'pattern-verified'] as const)('executes %s through the real runner and browser', async (profile) => { expect(scopedFixture).toBeDefined(); const built = scopedFixture;