From db5e3f5d78ce72b044b3ab7705ccf6c11961a9cd Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Thu, 3 Sep 2026 10:48:07 +0700 Subject: [PATCH 1/4] Translate Tailwind/CSS into destination-native styles and assets Closes #5 --- README.md | 25 +- src/author/assets.ts | 706 +++++++++++++++++++++ src/author/index.ts | 723 +++++++++++++++++++++ src/author/styles.ts | 1324 +++++++++++++++++++++++++++++++++++++++ src/cli.ts | 46 +- src/config/schema.ts | 6 + src/convert/assemble.ts | 29 +- src/convert/dom.ts | 32 +- src/convert/walk.ts | 11 + src/index.ts | 24 + src/styles/apply.ts | 51 +- src/types.ts | 113 +++- test/author.test.ts | 247 ++++++++ 13 files changed, 3319 insertions(+), 18 deletions(-) create mode 100644 src/author/assets.ts create mode 100644 src/author/index.ts create mode 100644 src/author/styles.ts create mode 100644 test/author.test.ts diff --git a/README.md b/README.md index 6ec5167..fd9cfb7 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,22 @@ 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 `'); + 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' }); + }); +}); + +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' }, + }); + + 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' }, + }); + + 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' } }, + ); + + expect(report.ok).toBe(true); + expect(report.package?.files['style.css']).toContain('.\\32xl\\:open.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('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'); + }); +}); From e0c9b68d2c0008e4800593f4fd77468409b507df Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Thu, 3 Sep 2026 13:43:12 +0700 Subject: [PATCH 2/4] Translate Tailwind/CSS into destination-native styles and assets Closes #5 --- README.md | 15 +- package-lock.json | 5 +- package.json | 2 + src/author/assets.ts | 96 ++++++--- src/author/index.ts | 277 ++++++++++++++++++++----- src/author/styles.ts | 442 +++++++++++++++++++++++++++++++++++++--- src/convert/assemble.ts | 27 ++- src/convert/dom.ts | 94 +++------ src/index.ts | 4 + src/styles/apply.ts | 19 ++ src/styles/parse.ts | 8 + src/types.ts | 57 +++++- test/author.test.ts | 241 +++++++++++++++++++++- 13 files changed, 1082 insertions(+), 205 deletions(-) diff --git a/README.md b/README.md index fd9cfb7..a4239c2 100644 --- a/README.md +++ b/README.md @@ -391,12 +391,15 @@ comes from a block plugin, or is dropped, and every drop or escalation is report ### 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); }); }); @@ -178,12 +290,19 @@ describe('registered-block CSS assets', () => { 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' }, + author: { name: 'acme/notice', styles: { mode: 'css' } }, }); expect(report.ok).toBe(true); @@ -199,7 +318,7 @@ describe('registered-block authoring parity ledger', () => { const report = await author('

Hello

', { outDir, - author: { name: 'acme/notice' }, + author: { name: 'acme/notice', styles: { mode: 'css' } }, }); expect(report.ok).toBe(false); @@ -209,15 +328,81 @@ describe('registered-block authoring parity ledger', () => { it('retains escaped class, ID, and attribute selector dependencies through native conversion', async () => { const report = await author( '

Hello

', - { author: { name: 'acme/notice' } }, + { author: { name: 'acme/notice', styles: { mode: 'css' } } }, ); expect(report.ok).toBe(true); - expect(report.package?.files['style.css']).toContain('.\\32xl\\:open.block-runner-selector-id-'); + 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); @@ -244,4 +429,44 @@ describe('registered-block authoring parity ledger', () => { 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'])); + }); }); From c92379f815368dcc8bd1acc243bc1cf47283ca52 Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Thu, 3 Sep 2026 15:23:22 +0700 Subject: [PATCH 3/4] Translate Tailwind/CSS into destination-native styles and assets Closes #5 --- package-lock.json | 38 +---------------------- src/author/index.ts | 75 +++++++++++++++++++++++++++++++++++++++------ test/author.test.ts | 57 ++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 47 deletions(-) diff --git a/package-lock.json b/package-lock.json index 038f61e..fdccc3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4575,7 +4575,7 @@ "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -6874,24 +6874,6 @@ "node": ">= 12" } }, - "node_modules/tsup/node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/tsx": { "version": "4.23.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", @@ -7265,24 +7247,6 @@ } } }, - "node_modules/vitest/node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/src/author/index.ts b/src/author/index.ts index 6ece06a..1d4f778 100644 --- a/src/author/index.ts +++ b/src/author/index.ts @@ -491,8 +491,13 @@ async function rewriteMarkupAssets(input: string, options: RewriteMarkupAssetsOp reference: string, kind: AssetLedgerEntry['kind'], ): Promise => { + // Legacy SVG font-face-uri references do not necessarily carry a modern font extension. Put + // those through a synthetic @font-face so the shared classifier preserves its font-license + // rules rather than mistaking one for a generic image asset. const processed = await rewriteCssAssets({ - sourceCss: `x{background-image:url(${JSON.stringify(reference)})}`, + sourceCss: kind === 'font' + ? `@font-face{src:url(${JSON.stringify(reference)})}` + : `x{background-image:url(${JSON.stringify(reference)})}`, sourcePath: options.write ? options.sourcePath : undefined, destinationAssetDir: options.destinationAssetDir, allowFontLicense: false, @@ -564,9 +569,29 @@ async function rewriteMarkupAssets(input: string, options: RewriteMarkupAssetsOp } } - for (const { name: attribute, kind } of assetAttributesFor(element)) { + const assetAttributes = assetAttributesFor(element); + if (element.namespaceURI === 'http://www.w3.org/2000/svg') { + const knownAssetAttributes = new Set(assetAttributes.map(({ name }) => name.toLowerCase())); + for (const attribute of [...element.attributes]) { + const name = attribute.name.toLowerCase(); + // SVG links are not all assets: `` remains a normal navigational link. Every other + // element must be in the semantic table below before its href can pass through. This is + // deliberately fail-closed because an unknown link could otherwise leave a source-relative + // dependency in the generated package with no ledger outcome. + if (isSvgHrefAttribute(name) && element.localName.toLowerCase() !== 'a' && !knownAssetAttributes.has(name)) { + assets.push({ + reference: attribute.value, + kind: 'other', + outcome: 'blocked', + reason: `SVG <${element.localName}> ${attribute.name} is not a recognized asset/reference attribute`, + }); + } + } + } + + for (const { name: attribute, kind } of assetAttributes) { const value = element.getAttribute(attribute); - if (!value?.trim()) continue; + if (value === null) continue; const rewritten = await processReference(value, kind); if (rewritten) { element.setAttribute(attribute, rewritten); @@ -614,6 +639,41 @@ interface AssetAttribute { kind: AssetLedgerEntry['kind']; } +const SVG_HREF_ASSET_KINDS: Readonly> = { + // External image resources. + image: 'image', + feimage: 'image', + // SVG 2 reference attributes. These can point at an external SVG document as well as an + // element in this document, so they must receive the same classification/copy treatment as an + // ordinary asset reference instead of being inferred from a class or left source-relative. + animate: 'other', + animatecolor: 'other', + animatemotion: 'other', + animatetransform: 'other', + discard: 'other', + lineargradient: 'other', + mpath: 'other', + pattern: 'other', + radialgradient: 'other', + set: 'other', + textpath: 'other', + use: 'other', + // SVG 1.1/XLink forms remain in authored assets. Keep their kinds explicit rather than + // silently downgrading a legacy cursor/font/reference into an untracked string attribute. + altglyph: 'other', + 'color-profile': 'other', + cursor: 'image', + 'definition-src': 'font', + filter: 'other', + 'font-face-uri': 'font', + tref: 'other', + glyphref: 'other', +}; + +function isSvgHrefAttribute(name: string): boolean { + return name === 'href' || name === 'xlink:href'; +} + /** * Asset-bearing attributes are not interchangeable: HTML anchors use `href` for navigation while * SVG `` is a concrete image dependency. Keep the table element/namespace-aware so @@ -623,13 +683,8 @@ interface AssetAttribute { function assetAttributesFor(element: Element): AssetAttribute[] { const tag = element.localName.toLowerCase(); if (element.namespaceURI === 'http://www.w3.org/2000/svg') { - if (tag === 'image' || tag === 'feimage') { - return [{ name: 'href', kind: 'image' }, { name: 'xlink:href', kind: 'image' }]; - } - if (tag === 'use' || tag === 'mpath' || tag === 'textpath') { - return [{ name: 'href', kind: 'other' }, { name: 'xlink:href', kind: 'other' }]; - } - return []; + const kind = SVG_HREF_ASSET_KINDS[tag]; + return kind ? [{ name: 'href', kind }, { name: 'xlink:href', kind }] : []; } switch (tag) { diff --git a/test/author.test.ts b/test/author.test.ts index 77e5027..262c452 100644 --- a/test/author.test.ts +++ b/test/author.test.ts @@ -469,4 +469,61 @@ describe('registered-block authoring parity ledger', () => { 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' }); + }); }); From c4058bd06b3c26de8342ec7cdb6bb73bcf02ba35 Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Fri, 4 Sep 2026 08:57:08 +0700 Subject: [PATCH 4/4] Resolve authoring merge conflicts --- src/cli.ts | 25 +++++++++++++++++++------ src/index.ts | 2 +- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 5a0029e..81f4b27 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,7 +10,7 @@ import fg from 'fast-glob'; import { canonicalize } from './gate/canonicalize.js'; import { validate } from './gate/validate.js'; import { convert } from './convert/assemble.js'; -import { author } from './author/index.js'; +import { author as generateRegisteredBlock } from './author/index.js'; import { realize } from './intent/index.js'; import { loadConfig } from './config/load.js'; import { collectSiteContext } from './context/run.js'; @@ -97,19 +97,27 @@ addTokenOptions( process.exitCode = report.ok ? 0 : 1; }); +const author = program.command('author').description('Review and materialize a versioned registered-block authoring plan.'); + addTokenOptions( addWpCredentialOptions( - addSharedOptions(program.command('author ').description('Generate one static, registered block package.'), { + addSharedOptions(program.command('generate-author ', { hidden: true }), { output: false, }), ), { styling: false }, ) - .requiredOption('--name ', 'registered block name, for example acme/hero') + .option('--name ', 'registered block name, for example acme/hero') .option('--title ', 'block title (defaults from the slug)') .option('--category <category>', 'block category (default: widgets)') .option('--out-dir <path>', 'write the generated package and copied assets to this directory') .action(async (htmlOrStdin: string, options: CliOptions) => { + if (!htmlOrStdin) { + program.error('error: author needs exactly one design input'); + } + if (!options.name) { + program.error("error: required option '--name <namespace/slug>' not specified"); + } const apiOptions = normalizeOptions(options); const inputs = await readInputs(htmlOrStdin, { allowInline: true }); if (inputs.length !== 1) { @@ -122,7 +130,7 @@ addTokenOptions( if (!input) { return; } - const report = await author(input.content, { + const report = await generateRegisteredBlock(input.content, { ...apiOptions, sourcePath: input.path, outDir: options.outDir, @@ -305,8 +313,6 @@ program } }); -const author = program.command('author').description('Review and materialize a versioned registered-block authoring plan.'); - author .command('preview <planOrStdin>') .description('Validate and render an authoring plan without writing files.') @@ -400,6 +406,7 @@ author async function main(): Promise<void> { try { + routeDirectAuthorInvocation(process.argv); rejectAssembleStylingOptions(process.argv); await program.parseAsync(process.argv); } catch (error) { @@ -419,6 +426,12 @@ async function main(): Promise<void> { } } +function routeDirectAuthorInvocation(argv: string[]): void { + if (argv[2] === 'author' && argv[3] && !['preview', 'write'].includes(argv[3])) { + argv[2] = 'generate-author'; + } +} + function addSharedOptions(command: Command, options: { output?: boolean } = {}): Command { const withCommon = command .option('--config <path>', 'path to block-runner config') diff --git a/src/index.ts b/src/index.ts index 1e4bcde..ffe24a2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,7 +47,7 @@ export type { AuthoringNodeLock, AuthoringPattern, AuthoringPatternOverride, - AuthoringPlan, + AuthoringPlan as GeneratedAuthoringPlan, AuthoringStructureNode, AuthoringStyleOutcome, AuthoringStyleOutcomeKind,