diff --git a/README.md b/README.md index 6ec5167..a4239c2 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,8 @@ Gutenberg before it reaches the editor. | Command | What it does | | --- | --- | -| `convert` | Authored HTML to native blocks. The only path that carries CSS. | +| `convert` | Authored HTML to native post-content blocks, including the legacy styling path. | +| `author` | One authored design to a static, registered block package with scoped parity CSS and assets. | | `assemble` | An intent tree — JSON describing which blocks and how they nest — to native blocks, built with `createBlock` so the result cannot be invalid. | | `author preview ` | Validate and render a versioned registered-block AuthoringPlan without writing files. | | `author write --confirm --output-dir ` | Write only the reviewed plan bound to its SHA-256 confirmation and destination. | @@ -165,6 +166,7 @@ Gutenberg before it reaches the editor. ```sh block-runner convert hero.html # blocks to stdout +block-runner author hero.html --name acme/hero --out-dir blocks/hero block-runner assemble intent.json # structure in, blocks out block-runner validate "content/**/*.html" --json block-runner fix post-content.html --out post-content.fixed.html @@ -242,6 +244,11 @@ All commands: | `--wp-user ` | WordPress username for `rest` resolution. | | `--wp-app-password-env ` | Env var holding a WordPress application password. | +`author` generates exactly one block package. It requires `--name ` and writes +to `--out-dir ` (or use `--json` to inspect the package without writing). Its `style.css` +is registered with `block.json`'s `style` field, so parity-critical CSS loads in both editor and +frontend; `editorStyle` is used only for explicitly supplied editor affordances. + `skill --install` adds installation flags: | Flag | Description | @@ -382,6 +389,25 @@ than not offering it. Custom JavaScript is never inlined. A behavior maps to a native interactive block, comes from a block plugin, or is dropped, and every drop or escalation is reported. +### Registered-block CSS and assets + +`author` accepts compiled CSS through `author.styles.css` (or `

Hello

', { + sourcePath: path.join(directory, 'design.html'), + author: { name: 'acme/notice', styles: { mode: 'tailwind', tailwind: graph } }, + }); + expect(authoredFromSource.ok).toBe(true); + expect(authoredFromSource.package?.files['index.js']).toContain('"text": "red"'); + + const rejected = await author('

Hello

', { + sourcePath: path.join(directory, 'design.html'), + author: { name: 'acme/notice', styles: { mode: 'tailwind', css: '.notice { color: blue; }', tailwind: graph } }, + }); + expect(rejected.ok).toBe(false); + expect(rejected.items.map((item) => item.reason).join('\n')).toMatch(/does not match output from pinned Tailwind compiler/i); + }); + + it('reports undeclared custom variants and plugins from the materialized source graph', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'block-runner-author-')); + scratch.push(directory); + await writeFile( + path.join(directory, 'style.css'), + '@tailwind utilities; @custom-variant night (&:where(.night, .night *)); @plugin "tailwind-motion";', + ); + const result = await compileTailwindBuildGraph({ + cssEntries: ['style.css'], + imports: [], + directives: ['@tailwind utilities'], + sources: ['design.html'], + safelist: [], + plugins: [], + environment: {}, + browserTarget: 'defaults', + compiler: { name: 'tailwindcss', version: '4.0.0', compile: () => '.x {}' }, + }, { sourcePath: path.join(directory, 'design.html') }); + + expect(result.verified).toBe(false); + expect(result.issues.map((issue) => issue.reason).join('\n')).toMatch(/custom-variant night|Tailwind plugin tailwind-motion/i); + }); + + it('requires an explicit stylesheet mode before accepting compiled CSS without Tailwind tokens', async () => { + const anonymous = await author('

Hello

', { + author: { name: 'acme/padding' }, + }); + expect(anonymous.ok).toBe(false); + expect(anonymous.items.map((item) => item.reason).join('\n')).toMatch(/styles\.mode.*css.*tailwind/i); + + const unprovenTailwind = await author('

Hello

', { + author: { name: 'acme/padding', styles: { mode: 'tailwind' } }, + }); + expect(unprovenTailwind.ok).toBe(false); + expect(unprovenTailwind.items.map((item) => item.reason).join('\n')).toMatch(/pinned Tailwind compiler/i); + }); + + it('requires every local, package, and remote CSS import to be materialized', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'block-runner-author-')); + scratch.push(directory); + await writeFile(path.join(directory, 'style.css'), '@import "tailwindcss"; @import url("https://cdn.example/theme.css");'); + + const result = await compileTailwindBuildGraph({ + cssEntries: ['style.css'], + imports: [], + directives: [], + sources: ['design.html'], + safelist: [], + plugins: [], + environment: {}, + browserTarget: 'defaults', + compiler: { name: 'tailwindcss', version: '4.0.0', compile: () => '.p-4 { padding: 1rem; }' }, + }, { sourcePath: path.join(directory, 'design.html') }); + + expect(result.verified).toBe(false); + expect(result.issues.map((issue) => issue.reason).join('\n')).toMatch(/tailwindcss.*not materialized|https:\/\/cdn\.example.*not materialized/i); + }); +}); + +describe('registered-block CSS assets', () => { + it('classifies remote, inline, unsafe, and unlicensed font URLs without fetching them', () => { + const refs = scanCssUrlReferences( + `a{background:url(https://cdn.example/a.png)} b{background:url(data:image/png;base64,AA)} c{mask:url(javascript:alert(1))} @font-face{src:url(font.woff2)}`, + '/design/style.css', + ); + expect(refs.map((ref) => classifyCssUrlReference(ref, { sourcePath: '/design/style.css' }).outcome)).toEqual([ + 'external', + 'external', + 'blocked', + 'unresolved', + ]); + }); + + it('copies local CSS assets once and rewrites every reference to the package asset', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'block-runner-author-')); + scratch.push(directory); + const design = path.join(directory, 'design.html'); + const sourceAsset = path.join(directory, 'logo.svg'); + const destination = path.join(directory, 'block', 'assets'); + await writeFile(design, ''); + await writeFile(sourceAsset, ''); + + const result = await rewriteCssAssets({ + sourcePath: design, + sourceCss: `.logo { background-image: url("./logo.svg"); } .again { mask-image: url(./logo.svg); }`, + destinationAssetDir: destination, + }); + + expect(result.assets.map((asset) => asset.outcome)).toEqual(['copied', 'copied']); + const [first, second] = result.assets; + expect(first).toBeDefined(); + expect(second).toBeDefined(); + expect(first!.rewrittenUrl).toBe(second!.rewrittenUrl); + expect(result.css).not.toContain('./logo.svg'); + expect(await readFile(first!.destinationAssetPath!, 'utf8')).toBe(''); + }); + + it('blocks a relative URL that escapes the stylesheet asset root', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'block-runner-author-')); + scratch.push(directory); + const design = path.join(directory, 'assets', 'design.html'); + const outside = path.join(directory, '.env'); + await mkdir(path.dirname(design), { recursive: true }); + await writeFile(design, ''); + await writeFile(outside, 'do-not-copy'); + + const reference = scanCssUrlReferences('x{background:url(../.env)}', design)[0]!; + expect(classifyCssUrlReference(reference, { sourcePath: design })).toMatchObject({ outcome: 'blocked' }); + }); + + it('reads only image positions from image-set(), not quoted type descriptors', () => { + const refs = scanCssUrlReferences( + 'x { background-image: image-set("photo.avif" 1x type("image/avif"), url("photo.png") 2x type("image/png")); }', + ); + expect(refs.map((reference) => reference.url)).toEqual(['photo.avif', 'photo.png']); + }); +}); + +describe('registered-block authoring parity ledger', () => { + it('maps a stylesheet declaration once to a supported native destination without duplicate CSS', async () => { + const report = await author('

Hello

', { + author: { name: 'acme/notice', styles: { mode: 'css' } }, + }); + + expect(report.ok).toBe(true); + expect(report.styleLedger).toContainEqual(expect.objectContaining({ property: 'color', outcome: 'native' })); + expect(report.package?.files['style.css']).toBeUndefined(); + expect(report.package?.files['index.js']).toContain('"color"'); + }); + + it('refuses to write a package after dropping a blocked selector or declaration', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'block-runner-author-')); + scratch.push(directory); + const outDir = path.join(directory, 'package'); + + const report = await author('

Hello

', { + outDir, + author: { name: 'acme/notice', styles: { mode: 'css' } }, + }); + + expect(report.ok).toBe(false); + await expect(readFile(path.join(outDir, 'block.json'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('retains escaped class, ID, and attribute selector dependencies through native conversion', async () => { + const report = await author( + '

Hello

', + { author: { name: 'acme/notice', styles: { mode: 'css' } } }, + ); + + expect(report.ok).toBe(true); + expect(report.package?.files['style.css']).toContain('.\\32xl\\:open:is(#hero, .block-runner-selector-id-'); + expect(report.package?.files['index.js']).toContain('"className": "2xl:open block-runner-selector-id-'); + expect(report.package?.files['index.js']).toContain('block-runner-selector-attribute-'); + }); + + it('preserves stylesheet ownership when one selector maps natively for only some matching elements', async () => { + const report = await author( + '

Outer inner

', + { author: { name: 'acme/notice', styles: { mode: 'css' } } }, + ); + + expect(report.ok).toBe(true); + expect(report.styleLedger?.filter((entry) => entry.property === 'color')).toEqual([ + expect.objectContaining({ outcome: 'scoped-css' }), + ]); + expect(report.package?.files['style.css']).toContain('.wp-block-acme-notice .notice { color: red; }'); + expect(report.package?.files['index.js']).not.toContain('"color": "red"'); + }); + + it('keeps an identical conditional declaration in residual CSS instead of aliasing a native top-level rule', async () => { + const report = await author( + '

Hello

', + { author: { name: 'acme/notice', styles: { mode: 'css' } } }, + ); + + expect(report.ok).toBe(true); + expect(report.styleLedger).toEqual(expect.arrayContaining([ + expect.objectContaining({ property: 'color', outcome: 'native', atRules: [] }), + expect.objectContaining({ property: 'color', outcome: 'scoped-css', atRules: ['@media (min-width: 40rem)'] }), + ])); + expect(report.package?.files['style.css']).toContain('@media (min-width: 40rem)'); + expect(report.package?.files['style.css']).toContain('.wp-block-acme-notice .notice { color: red; }'); + }); + + it('suppresses rewritten mixed declarations with the same identity used by final conversion', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'block-runner-author-')); + scratch.push(directory); + const design = path.join(directory, 'design.html'); + const outDir = path.join(directory, 'package'); + await writeFile(design, ''); + await writeFile(path.join(directory, 'photo.png'), 'photo'); + + const report = await author( + '

Hero

Fallback', + { sourcePath: design, outDir, author: { name: 'acme/notice', styles: { mode: 'css' } } }, + ); + + expect(report.ok).toBe(true); + expect(report.styleLedger).toContainEqual(expect.objectContaining({ property: 'background-image', outcome: 'scoped-css' })); + expect(report.package?.files['style.css']).toContain('./assets/'); + expect(report.package?.files['index.js']).not.toContain('"url": "./assets/'); + }); + + it('blocks invalid attribute selectors before emitting a marker dependency and retains ID specificity', () => { + const transport = createSelectorDependencyTransport(); + const idScoped = scopeStylesheet(scanStylesheet('#hero { color: red; }'), { + root: '.wp-block-acme-notice', + selectorTransform: transport.rewrite, + }); + expect(idScoped.css).toContain(':is(#hero, .block-runner-selector-id-'); + + const invalidTransport = createSelectorDependencyTransport(); + const invalid = scopeStylesheet(scanStylesheet('[data-state=] { color: red; }'), { + root: '.wp-block-acme-notice', + selectorTransform: invalidTransport.rewrite, + }); + expect(invalid.css).toBe(''); + expect(invalid.ledger).toContainEqual(expect.objectContaining({ outcome: 'blocked', reason: expect.stringMatching(/invalid attribute selector/i) })); + expect(invalidTransport.dependencies).toEqual([]); + }); + + it('accounts for inline CSS and rewrites srcset assets retained in Custom HTML', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'block-runner-author-')); + scratch.push(directory); + const design = path.join(directory, 'design.html'); + const source = path.join(directory, 'photo.png'); + const outDir = path.join(directory, 'package'); + await writeFile(design, ''); + await writeFile(source, 'photo'); + + const report = await author( + '

Hello

', + { + sourcePath: design, + outDir, + author: { name: 'acme/notice' }, + }, + ); + + expect(report.ok).toBe(true); + expect(report.styleLedger).toContainEqual(expect.objectContaining({ property: 'color', outcome: 'native' })); + expect(report.styleLedger).toContainEqual(expect.objectContaining({ property: 'background-image', outcome: 'literal' })); + expect(report.assets?.filter((asset) => asset.reference === 'photo.png')).toHaveLength(4); + expect(report.package?.files['index.js']).toContain('srcset='); + expect(report.package?.files['index.js']).toContain('image-set('); + expect(await readFile(path.join(outDir, 'block.json'), 'utf8')).toContain('acme/notice'); + }); + + it('accounts for object data and SVG href asset forms', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'block-runner-author-')); + scratch.push(directory); + const design = path.join(directory, 'design.html'); + const source = path.join(directory, 'photo.png'); + await writeFile(design, ''); + await writeFile(source, 'photo'); + + const report = await author( + '', + { sourcePath: design, outDir: path.join(directory, 'package'), author: { name: 'acme/assets' } }, + ); + + expect(report.assets?.filter((asset) => asset.reference.startsWith('photo.png'))).toHaveLength(3); + expect(report.assets?.filter((asset) => asset.reference.startsWith('photo.png')).every((asset) => asset.outcome === 'copied')).toBe(true); + }); + + it('accounts for SVG presentation URLs, SVG href variants, and link href asset forms', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'block-runner-author-')); + scratch.push(directory); + const design = path.join(directory, 'design.html'); + await writeFile(design, ''); + await writeFile(path.join(directory, 'photo.png'), 'photo'); + + const report = await author( + 'Text', + { + sourcePath: design, + outDir: path.join(directory, 'package'), + author: { name: 'acme/assets', styles: { mode: 'css', css: ' ' } }, + }, + ); + + expect(report.ok).toBe(true); + const assetReferences = report.assets?.filter((asset) => asset.reference.startsWith('photo.png')) ?? []; + expect(assetReferences).toHaveLength(8); + expect(assetReferences.every((asset) => asset.outcome === 'copied')).toBe(true); + expect(assetReferences.map((asset) => asset.kind)).toEqual(expect.arrayContaining(['image', 'stylesheet', 'other'])); + }); + + it('accounts for SVG gradient, pattern, and animation href references without treating SVG links as navigation', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'block-runner-author-')); + scratch.push(directory); + const design = path.join(directory, 'design.html'); + const outDir = path.join(directory, 'package'); + await writeFile(design, ''); + await writeFile(path.join(directory, 'gradients.svg'), ''); + + const report = await author( + `Guide + + + + + + + + + `, + { sourcePath: design, outDir, author: { name: 'acme/assets' } }, + ); + + expect(report.ok).toBe(true); + const assets = report.assets ?? []; + expect(assets).toHaveLength(8); + expect(assets.filter((asset) => asset.reference.startsWith('gradients.svg')).every((asset) => asset.outcome === 'copied')).toBe(true); + expect(assets).toContainEqual(expect.objectContaining({ + reference: 'https://cdn.example/gradients.svg#radial', + outcome: 'external', + })); + expect(assets).toContainEqual(expect.objectContaining({ reference: '#pattern', outcome: 'external' })); + expect(assets.some((asset) => asset.reference === 'guide.pdf')).toBe(false); + expect(report.package?.files['index.js']).toContain('./assets/'); + }); + + it('blocks an unrecognized SVG href form instead of silently leaving it source-relative', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'block-runner-author-')); + scratch.push(directory); + const design = path.join(directory, 'design.html'); + const outDir = path.join(directory, 'package'); + await writeFile(design, ''); + await writeFile(path.join(directory, 'unknown.svg'), ''); + + const report = await author( + '', + { sourcePath: design, outDir, author: { name: 'acme/assets' } }, + ); + + expect(report.ok).toBe(false); + expect(report.assets).toContainEqual(expect.objectContaining({ + reference: 'unknown.svg#content', + outcome: 'blocked', + reason: expect.stringMatching(/foreignObject.*xlink:href.*recognized/i), + })); + await expect(readFile(path.join(outDir, 'block.json'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + }); +});