Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions PACKAGING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
124 changes: 124 additions & 0 deletions packages/visual-editor-renderer-react/README.md
Original file line number Diff line number Diff line change
@@ -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 (
<article className="prose">
<BlockTree tree={post.content} />
</article>
);
}
```

`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 }) => (
<div className="my-block">{String(attributes.title ?? '')}</div>
));
```

A custom renderer receives:

```ts
interface BlockRendererProps {
name: string;
attributes: Record<string, unknown>;
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 `<DynamicBlock>`,
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
<BlockTree
tree={post.content}
fetchOptions={{
headers: { 'X-CSRF-TOKEN': csrfToken },
}}
/>
```

## 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.
32 changes: 32 additions & 0 deletions packages/visual-editor-renderer-react/package.json
Original file line number Diff line number Diff line change
@@ -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 <me@jacobmartella.com>",
"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"
}
}
125 changes: 125 additions & 0 deletions packages/visual-editor-renderer-react/src/BlockTree.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)
: {};

const innerBlocks = Array.isArray(block.innerBlocks) ? block.innerBlocks.filter(isBlock) : [];
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 (
<Renderer
key={key}
name={name}
attributes={attributes}
innerBlocks={innerBlocks}
>
{renderedChildren === null ? null : <Fragment>{renderedChildren}</Fragment>}
</Renderer>
);
}

return (
<DynamicBlock
key={key}
name={name}
attributes={attributes}
endpoint={endpoint}
fetchOptions={fetchOptions}
/>
);
}

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 };
Loading