From 8c7a3ce662c56080f639105d1c7d24f77b436a58 Mon Sep 17 00:00:00 2001 From: Jacob Martella Date: Mon, 20 Apr 2026 10:13:59 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20M10=20=C2=B7=20React=20renderer=20p?= =?UTF-8?q?ackage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the `@artisanpack-ui/visual-editor-renderer-react` npm package for Inertia+React hosts: a `` component that walks the block tree the editor persists, renders every core block from an overridable registry, and falls back to the visual-editor preview endpoint for dynamic blocks. Markup mirrors the M9 Blade renderer one-to-one (classes, attribute allow-lists, URL sanitization) so server- and client-rendered pages stay visually identical. - packages/visual-editor-renderer-react: new npm sub-package - 29 core block renderers: text / media / layout / design families - `registerBlockRenderer(name, component)` for custom + override blocks - `` POSTs to `/visual-editor/api/blocks/preview` on mount - URL sanitizer + attribute coercion helpers ported from the PHP side - 54 vitest tests covering every block, registry, dynamic fetch, sanitizer - vitest.config.ts: include `packages/**/tests` alongside the legacy suite - PACKAGING.md: register the new sub-package in the subtree-split table Closes #320 --- PACKAGING.md | 15 +- .../visual-editor-renderer-react/README.md | 124 ++++ .../visual-editor-renderer-react/package.json | 32 + .../src/BlockTree.tsx | 125 ++++ .../src/DynamicBlock.tsx | 113 ++++ .../src/blocks/core/design.tsx | 281 +++++++++ .../src/blocks/core/layout.tsx | 168 ++++++ .../src/blocks/core/media.tsx | 205 +++++++ .../src/blocks/core/text.tsx | 154 +++++ .../src/blocks/registerCoreBlocks.ts | 92 +++ .../src/blocks/unknownBlock.tsx | 21 + .../visual-editor-renderer-react/src/index.ts | 28 + .../src/registry.ts | 46 ++ .../src/support/attributes.ts | 101 ++++ .../src/support/urlSanitizer.ts | 39 ++ .../visual-editor-renderer-react/src/types.ts | 27 + .../tests/BlockTree.test.tsx | 558 ++++++++++++++++++ .../tests/DynamicBlock.test.tsx | 74 +++ .../tests/helpers.ts | 19 + .../tests/registry.test.ts | 59 ++ .../tests/urlSanitizer.test.ts | 36 ++ .../tsconfig.json | 15 + vitest.config.ts | 1 + 23 files changed, 2326 insertions(+), 7 deletions(-) create mode 100644 packages/visual-editor-renderer-react/README.md create mode 100644 packages/visual-editor-renderer-react/package.json create mode 100644 packages/visual-editor-renderer-react/src/BlockTree.tsx create mode 100644 packages/visual-editor-renderer-react/src/DynamicBlock.tsx create mode 100644 packages/visual-editor-renderer-react/src/blocks/core/design.tsx create mode 100644 packages/visual-editor-renderer-react/src/blocks/core/layout.tsx create mode 100644 packages/visual-editor-renderer-react/src/blocks/core/media.tsx create mode 100644 packages/visual-editor-renderer-react/src/blocks/core/text.tsx create mode 100644 packages/visual-editor-renderer-react/src/blocks/registerCoreBlocks.ts create mode 100644 packages/visual-editor-renderer-react/src/blocks/unknownBlock.tsx create mode 100644 packages/visual-editor-renderer-react/src/index.ts create mode 100644 packages/visual-editor-renderer-react/src/registry.ts create mode 100644 packages/visual-editor-renderer-react/src/support/attributes.ts create mode 100644 packages/visual-editor-renderer-react/src/support/urlSanitizer.ts create mode 100644 packages/visual-editor-renderer-react/src/types.ts create mode 100644 packages/visual-editor-renderer-react/tests/BlockTree.test.tsx create mode 100644 packages/visual-editor-renderer-react/tests/DynamicBlock.test.tsx create mode 100644 packages/visual-editor-renderer-react/tests/helpers.ts create mode 100644 packages/visual-editor-renderer-react/tests/registry.test.ts create mode 100644 packages/visual-editor-renderer-react/tests/urlSanitizer.test.ts create mode 100644 packages/visual-editor-renderer-react/tsconfig.json diff --git a/PACKAGING.md b/PACKAGING.md index 72c9ff6ff..62aa0e63d 100644 --- a/PACKAGING.md +++ b/PACKAGING.md @@ -7,13 +7,14 @@ sub-packages are meant to reach Packagist, because Packagist watches one ## Sub-packages -| Path | Composer name | Status | -|-----------------------------------------|-----------------------------------------------------|-------------| -| `./` | `artisanpack-ui/visual-editor` | published | -| `packages/visual-editor-renderer-blade` | `artisanpack-ui/visual-editor-renderer-blade` | unpublished | - -M10 (React renderer) and M11 (Vue renderer) will add two more sub-packages -under the same `packages/` directory. +| Path | Package name | Registry | Status | +|-----------------------------------------|-----------------------------------------------------------|-----------|-------------| +| `./` | `artisanpack-ui/visual-editor` | Packagist | published | +| `packages/visual-editor-renderer-blade` | `artisanpack-ui/visual-editor-renderer-blade` | Packagist | unpublished | +| `packages/visual-editor-renderer-react` | `@artisanpack-ui/visual-editor-renderer-react` | npm | unpublished | + +M11 (Vue renderer) will add one more sub-package under the same `packages/` +directory. ## Distribution strategy: subtree splits diff --git a/packages/visual-editor-renderer-react/README.md b/packages/visual-editor-renderer-react/README.md new file mode 100644 index 000000000..76e991031 --- /dev/null +++ b/packages/visual-editor-renderer-react/README.md @@ -0,0 +1,124 @@ +# @artisanpack-ui/visual-editor-renderer-react + +React renderer for the ArtisanPack UI visual editor. + +Takes a saved block tree (the JSON shape the editor persists to any +`HasBlockContent` Eloquent model) and renders it to React elements using +per-block components. Dynamic blocks — anything registered with the +visual-editor's `DynamicBlockRegistry` — are fetched from the +`/visual-editor/api/blocks/preview` endpoint on mount and spliced into the +tree, with an unknown-block fallback for anything the server can't render. + +Built for Inertia+React apps. Ships zero styling — markup mirrors the server +Blade renderer (`@artisanpack-ui/visual-editor-renderer-blade`) so the same +theme styles apply on both sides. + +## Installation + +```sh +npm install @artisanpack-ui/visual-editor-renderer-react +``` + +Peer dependencies: React 18 or 19. + +## Usage + +```tsx +import { BlockTree } from '@artisanpack-ui/visual-editor-renderer-react'; + +export default function Post({ post }) { + return ( +
+ +
+ ); +} +``` + +`tree` accepts: + +- An array of blocks in the `{ clientId, name, attributes, innerBlocks }` + shape the visual editor persists. +- A JSON-encoded string of that shape. +- `null` / `undefined` (renders nothing). + +## Props + +| Prop | Type | Default | Notes | +| --- | --- | --- | --- | +| `tree` | `Block[] \| string \| null` | — | Required. | +| `dynamicBlockEndpoint` | `string` | `/visual-editor/api/blocks/preview` | Override if your app prefix is not `visual-editor`. | +| `fetchOptions` | `RequestInit` | `{ credentials: 'same-origin' }` | Merged on top of the default request. Use for CSRF headers etc. | + +## Registering custom renderers + +The shared registry maps a block name to a React component. Register your own +component to add support for a new block — or to override a core one: + +```tsx +import { + registerBlockRenderer, + BlockTree, +} from '@artisanpack-ui/visual-editor-renderer-react'; + +registerBlockRenderer('acme/my-block', ({ attributes }) => ( +
{String(attributes.title ?? '')}
+)); +``` + +A custom renderer receives: + +```ts +interface BlockRendererProps { + name: string; + attributes: Record; + innerBlocks: Block[]; + children?: React.ReactNode; // pre-rendered inner-block React elements +} +``` + +Render `{children}` wherever the inner blocks should appear — BlockTree +rendered them before invoking your component so you don't have to walk the +tree yourself. + +## Dynamic blocks + +Any block name with no registered renderer is rendered via ``, +which POSTs `{ name, attributes }` to the preview endpoint and splices the +returned HTML into the page with `dangerouslySetInnerHTML`. The HTML ships +pre-escaped by the Laravel side (the same controller the editor calls for +preview), so the only injection surface is the dynamic block's own `render()` +method. + +If your app uses CSRF protection, pass the token through `fetchOptions`: + +```tsx + +``` + +## Supported core blocks + +All blocks in the frozen V1 allow-list from the visual-editor package's M5 +audit ship with a React component: + +- Text: paragraph, heading, list, list-item, quote, code, preformatted, + pullquote, verse +- Media: image, gallery, video, audio, file, embed +- Design: cover, media-text, table, separator, spacer, details, search +- Layout: columns, column, group, row, stack, buttons, button + +`core/latest-posts` and any other server-only dynamic block is intentionally +not shipped as a client-side component — those hit the preview endpoint. + +## Status + +This package ships as part of the ArtisanPack UI visual editor V1 (epic +`#309`, milestone M10). During the V1 cycle it lives inside the +`visual-editor` monorepo under `packages/visual-editor-renderer-react/` and +is distributed to npm via a subtree split — see `PACKAGING.md` at the +monorepo root for the release workflow. diff --git a/packages/visual-editor-renderer-react/package.json b/packages/visual-editor-renderer-react/package.json new file mode 100644 index 000000000..cb0670741 --- /dev/null +++ b/packages/visual-editor-renderer-react/package.json @@ -0,0 +1,32 @@ +{ + "name": "@artisanpack-ui/visual-editor-renderer-react", + "version": "1.0.0", + "description": "React renderer for ArtisanPack UI visual-editor block trees. Designed for Inertia+React apps.", + "license": "GPL-3.0-or-later", + "author": "Jacob Martella ", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "files": [ + "src", + "README.md" + ], + "keywords": [ + "react", + "inertia", + "visual-editor", + "gutenberg", + "renderer", + "blocks" + ], + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } +} diff --git a/packages/visual-editor-renderer-react/src/BlockTree.tsx b/packages/visual-editor-renderer-react/src/BlockTree.tsx new file mode 100644 index 000000000..84f1a94f9 --- /dev/null +++ b/packages/visual-editor-renderer-react/src/BlockTree.tsx @@ -0,0 +1,125 @@ +/** + * Public React component for rendering a saved block tree. + * + * Walks a `{ clientId, name, attributes, innerBlocks }` block tree the visual + * editor persists and renders it to React elements. Each block name is looked + * up in the shared block registry; anything unregistered falls through to the + * {@link DynamicBlock} component, which fetches the server-rendered HTML from + * the visual-editor preview endpoint. + * + * `tree` accepts the array form the editor persists, a JSON-encoded string of + * that shape, or `null`/`undefined` for an empty render. + */ + +import { Fragment } from 'react'; +import type { ReactElement, ReactNode } from 'react'; +import { DynamicBlock } from './DynamicBlock'; +import { UnknownBlock } from './blocks/unknownBlock'; +import { getBlockRenderer } from './registry'; +import type { Block } from './types'; + +const DEFAULT_ENDPOINT = '/visual-editor/api/blocks/preview'; + +export interface BlockTreeProps { + tree: Block[] | string | null | undefined; + dynamicBlockEndpoint?: string; + fetchOptions?: RequestInit; +} + +export function BlockTree({ + tree, + dynamicBlockEndpoint = DEFAULT_ENDPOINT, + fetchOptions, +}: BlockTreeProps): ReactElement { + const blocks = normalizeTree(tree); + + return ( + <> + {blocks.map((block, index) => + renderBlock(block, index, dynamicBlockEndpoint, fetchOptions) + )} + + ); +} + +function renderBlock( + block: Block, + index: number, + endpoint: string, + fetchOptions: RequestInit | undefined +): ReactNode { + const name = typeof block.name === 'string' ? block.name.trim() : ''; + + if (name === '') { + return null; + } + + const attributes = + block.attributes !== null && typeof block.attributes === 'object' && !Array.isArray(block.attributes) + ? (block.attributes as Record) + : {}; + + const innerBlocks = Array.isArray(block.innerBlocks) ? block.innerBlocks : []; + const key = typeof block.clientId === 'string' && block.clientId !== '' ? block.clientId : `${name}-${index}`; + + const renderedChildren = + innerBlocks.length === 0 + ? null + : innerBlocks.map((child, childIndex) => + renderBlock(child, childIndex, endpoint, fetchOptions) + ); + + const Renderer = getBlockRenderer(name); + + if (Renderer !== undefined) { + return ( + + {renderedChildren === null ? null : {renderedChildren}} + + ); + } + + return ( + + ); +} + +function normalizeTree(tree: Block[] | string | null | undefined): Block[] { + if (tree === null || tree === undefined) { + return []; + } + + if (typeof tree === 'string') { + try { + const parsed = JSON.parse(tree); + + return Array.isArray(parsed) ? parsed.filter(isBlock) : []; + } catch { + return []; + } + } + + return Array.isArray(tree) ? tree.filter(isBlock) : []; +} + +function isBlock(value: unknown): value is Block { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + typeof (value as { name?: unknown }).name === 'string' + ); +} + +export { UnknownBlock }; diff --git a/packages/visual-editor-renderer-react/src/DynamicBlock.tsx b/packages/visual-editor-renderer-react/src/DynamicBlock.tsx new file mode 100644 index 000000000..fa5e9b869 --- /dev/null +++ b/packages/visual-editor-renderer-react/src/DynamicBlock.tsx @@ -0,0 +1,113 @@ +/** + * Dynamic block renderer. + * + * When a block has no client-side renderer registered, the renderer falls back + * to this component. On mount it POSTs `{ name, attributes }` to the + * visual-editor's `/visual-editor/api/blocks/preview` endpoint and mounts the + * HTML the server returns. If the request fails or the endpoint returns 404 + * (no registered `DynamicBlock`), it renders the unknown-block marker so the + * page layout stays intact and the missing block is easy to spot. + * + * The endpoint is configurable via the `dynamicBlockEndpoint` prop on + * {@link BlockTree}, so hosts on a non-standard prefix can still use the + * renderer without patching it. + */ + +import { useEffect, useMemo, useState } from 'react'; +import { UnknownBlock } from './blocks/unknownBlock'; + +type FetchFn = typeof fetch; + +export interface DynamicBlockProps { + name: string; + attributes: Record; + endpoint: string; + fetchOptions?: RequestInit; + fetchFn?: FetchFn; +} + +interface DynamicBlockState { + status: 'loading' | 'ready' | 'error'; + html: string; +} + +export function DynamicBlock({ + name, + attributes, + endpoint, + fetchOptions, + fetchFn, +}: DynamicBlockProps): JSX.Element { + const [state, setState] = useState({ + status: 'loading', + html: '', + }); + + const attributesJson = useMemo(() => JSON.stringify(attributes), [attributes]); + + useEffect(() => { + const controller = new AbortController(); + const doFetch = fetchFn ?? globalThis.fetch; + + if (typeof doFetch !== 'function') { + setState({ status: 'error', html: '' }); + + return () => controller.abort(); + } + + const headers = new Headers(fetchOptions?.headers); + + if (!headers.has('Accept')) { + headers.set('Accept', 'application/json'); + } + + if (!headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + + const request = doFetch(endpoint, { + method: 'POST', + credentials: 'same-origin', + ...fetchOptions, + headers, + body: `{"name":${JSON.stringify(name)},"attributes":${attributesJson}}`, + signal: controller.signal, + }); + + request + .then(async (response) => { + if (!response.ok) { + throw new Error(`Preview request failed: ${response.status}`); + } + + const data = (await response.json()) as { html?: unknown }; + const html = typeof data.html === 'string' ? data.html : ''; + + setState({ status: 'ready', html }); + }) + .catch((error: unknown) => { + if ((error as { name?: string } | null)?.name === 'AbortError') { + return; + } + + setState({ status: 'error', html: '' }); + }); + + return () => controller.abort(); + }, [name, endpoint, fetchFn, fetchOptions, attributesJson]); + + if (state.status === 'loading') { + return
; + } + + if (state.status === 'error') { + return ; + } + + return ( +
+ ); +} diff --git a/packages/visual-editor-renderer-react/src/blocks/core/design.tsx b/packages/visual-editor-renderer-react/src/blocks/core/design.tsx new file mode 100644 index 000000000..ee6d0fb8c --- /dev/null +++ b/packages/visual-editor-renderer-react/src/blocks/core/design.tsx @@ -0,0 +1,281 @@ +/** + * Design-family core block renderers: separator, spacer, cover, media-text, + * table, details, search. Validates and clamps attributes the same way the + * Blade partials do — dimRatio 0-100, minHeightUnit allowlist, table cell + * alignment allowlist, group tag allowlist — to keep parity with the + * server-side output. + */ + +import { useMemo } from 'react'; +import { + attrArray, + attrBoolean, + attrFloat, + attrInt, + attrRecord, + attrString, + classList, +} from '../../support/attributes'; +import { safeUrl } from '../../support/urlSanitizer'; +import type { BlockRendererProps } from '../../types'; + +const ALLOWED_MIN_HEIGHT_UNITS = ['px', 'em', 'rem', 'vh', 'vw', '%'] as const; +const ALLOWED_CELL_ALIGN = ['left', 'center', 'right', 'justify'] as const; + +export function SeparatorBlock({ attributes }: BlockRendererProps): JSX.Element { + const style = attrString(attributes.style, 'default'); + const className = attrString(attributes.className); + + const classes = classList([ + 'wp-block-separator', + 'has-alpha-channel-opacity', + style === 'wide' ? 'is-style-wide' : null, + style === 'dots' ? 'is-style-dots' : null, + className, + ]); + + return
; +} + +export function SpacerBlock({ attributes }: BlockRendererProps): JSX.Element { + const rawHeight = attributes.height ?? '100px'; + const height = + typeof rawHeight === 'number' + ? `${rawHeight}px` + : typeof rawHeight === 'string' && /^\d+(\.\d+)?$/.test(rawHeight.trim()) + ? `${rawHeight.trim()}px` + : attrString(rawHeight, '100px'); + + return