diff --git a/dev/test/custom-rule-example.test.ts b/dev/test/custom-rule-example.test.ts new file mode 100644 index 0000000..ff44da0 --- /dev/null +++ b/dev/test/custom-rule-example.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +// @ts-expect-error The shipped JavaScript config intentionally has no declaration file. +import config from '../../examples/custom-rule.mjs'; +import { convert } from '../../src/index.js'; + +describe('custom rule example', () => { + it('converts only marked paragraphs to a native notice paragraph', async () => { + const marked = await convert('

Service update

', { config }); + const unmarked = await convert('

Routine update

', { config }); + + expect(marked.ok).toBe(true); + expect(marked.summary.invalid).toBe(0); + expect(marked.output).toContain('wp:paragraph'); + expect(marked.output).toContain('className":"notice"'); + expect(marked.output).toContain('Service update'); + expect(marked.output).not.toContain('wp:html'); + expect(marked.items.some((item) => item.reason.includes('Custom HTML fallback'))).toBe(false); + + expect(unmarked.ok).toBe(true); + expect(unmarked.summary.invalid).toBe(0); + expect(unmarked.output).toContain('wp:paragraph'); + expect(unmarked.output).toContain('Routine update'); + expect(unmarked.output).not.toContain('className":"notice"'); + expect(unmarked.output).not.toContain('wp:html'); + expect(unmarked.items.some((item) => item.reason.includes('Custom HTML fallback'))).toBe(false); + }); +}); diff --git a/docs/extending.md b/docs/extending.md new file mode 100644 index 0000000..33cabbd --- /dev/null +++ b/docs/extending.md @@ -0,0 +1,48 @@ +# Custom conversion rules + +Block Runner can add a narrow HTML-to-block conversion without changing its built-in rules. A custom rule has an `id`, a `match` function, and an async `emit` function that returns a native block, blocks, or `null`. + +[`examples/custom-rule.mjs`](../examples/custom-rule.mjs) maps only `p[data-notice]` to a `core/paragraph` with the `notice` class. It keeps the paragraph's inner HTML as the block content: + +```js +export default { + rules: { + custom: [{ + id: 'notice-paragraph', + match: (node) => node.matches('p[data-notice]'), + emit: async (node, { wp }) => wp.createBlock('core/paragraph', { + content: node.innerHTML, + className: 'notice', + }), + }], + }, +}; +``` + +From a checkout, build the CLI and run the example with a literal input: + +```sh +npm ci +npm run build +npx --no-install block-runner convert '

Service update

' --config examples/custom-rule.mjs +``` + +The command writes native paragraph markup. The relevant output is: + +```html + +

Service update

+ +``` + +`match` receives each HTML element and decides whether the rule owns it. `emit` receives that element and the conversion context. Use `context.wp.createBlock()` to create Gutenberg blocks. This example supports a simple paragraph and does not sanitise or define a rich-text conversion policy for arbitrary HTML. + +Custom rules are tried before built-in rules. Before rule matching, the walker preserves foreign elements as Custom HTML. When asset preservation is enabled, it also preserves unsupported asset forms. It then uses the first matching custom or built-in rule. A matching rule that returns `null` drops that node; Block Runner does not continue to later rules. If `match` or `emit` throws, Block Runner records a warning and emits the source node as a Custom HTML fallback. + +After `emit`, the shared style mapper applies the source element's own supported styles. The resulting tree then enters the shared finalizer, which resolves media, repairs configured tokens, serializes the blocks, and validates the output. The custom rule does not bypass those steps. + +`rules.order` changes the order of built-in rules, and `rules.disabledDefaults` disables named built-ins. Neither option supplies arbitrary block definitions. A conversion rule is separate from registering a custom WordPress block. Register that block in both the target site and Block Runner's headless environment when your rule emits one. Block Runner does not load arbitrary third-party block definitions. + +`className: 'notice'` adds a class to the paragraph. It does not create CSS or update `theme.json`; provide the matching styles in the WordPress site. + +The npm package includes this [runnable example](https://github.com/humanmade/block-runner/blob/main/examples/custom-rule.mjs). See the repository's [`Rule` type](https://github.com/humanmade/block-runner/blob/main/src/types.ts) for the full hook contract. diff --git a/examples/custom-rule.mjs b/examples/custom-rule.mjs new file mode 100644 index 0000000..a32b8c4 --- /dev/null +++ b/examples/custom-rule.mjs @@ -0,0 +1,14 @@ +export default { + rules: { + custom: [ + { + id: 'notice-paragraph', + match: (node) => node.matches('p[data-notice]'), + emit: async (node, { wp }) => wp.createBlock('core/paragraph', { + content: node.innerHTML, + className: 'notice', + }), + }, + ], + }, +};