cms-renderer 0.6.13 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -0
- package/dist/lib/ai-preview.d.ts +62 -0
- package/dist/lib/ai-preview.js +114 -0
- package/dist/lib/ai-preview.js.map +1 -0
- package/dist/lib/block-renderer.js +87 -49
- package/dist/lib/block-renderer.js.map +1 -1
- package/dist/lib/custom-schemas.js +126 -42
- package/dist/lib/custom-schemas.js.map +1 -1
- package/dist/lib/docs-markdown.js +49 -1
- package/dist/lib/docs-markdown.js.map +1 -1
- package/dist/lib/markdown-utils.js +46 -1
- package/dist/lib/markdown-utils.js.map +1 -1
- package/dist/lib/parametric-route.js +90 -52
- package/dist/lib/parametric-route.js.map +1 -1
- package/dist/lib/renderer.js +90 -52
- package/dist/lib/renderer.js.map +1 -1
- package/dist/lib/types.d.ts +2 -0
- package/dist/lib/types.js.map +1 -1
- package/package.json +8 -4
package/README.md
CHANGED
|
@@ -31,6 +31,7 @@ Main entry points:
|
|
|
31
31
|
- `cms-renderer/lib/custom-schemas` for fetching custom component metadata and generating Zod code
|
|
32
32
|
- `cms-renderer/lib/docs-markdown` and `cms-renderer/styles/docs-markdown.css` for docs markdown rendering
|
|
33
33
|
- `cms-renderer/lib/refresher` for client-side refresh on CMS updates
|
|
34
|
+
- `cms-renderer/lib/ai-preview` for headless AI preview rendering
|
|
34
35
|
|
|
35
36
|
Additional utility exports:
|
|
36
37
|
|
|
@@ -232,6 +233,36 @@ Also exported:
|
|
|
232
233
|
- `fetchCustomSchemaFields(options, schemaName)`
|
|
233
234
|
- `buildZodSchemas(schemas)`
|
|
234
235
|
|
|
236
|
+
## Headless AI Preview
|
|
237
|
+
|
|
238
|
+
`renderParametricRoute` supports an `ai_preview=<n>` query param that pulls AI-generated block variants from the CMS API. When you want to render preview content **headlessly** — you already have the block content in hand (from a generation webhook, an agent, your own preview endpoint) and don't want a CMS round-trip, query params, or edit-mode overlays — use `aiPreview` from `cms-renderer/lib/ai-preview`.
|
|
239
|
+
|
|
240
|
+
It dispatches through the same component registry as the catch-all route, so previews match production exactly.
|
|
241
|
+
|
|
242
|
+
```tsx
|
|
243
|
+
import { aiPreview } from 'cms-renderer/lib/ai-preview';
|
|
244
|
+
import type { BlockComponentRegistry } from 'cms-renderer/lib/types';
|
|
245
|
+
import HeroBlock from '@/components/HeroBlock';
|
|
246
|
+
|
|
247
|
+
const registry: Partial<BlockComponentRegistry> = {
|
|
248
|
+
'hero-block': HeroBlock,
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// `blocks` is content you already have — e.g. the JSON an agent generated.
|
|
252
|
+
export default function AiPreviewPage({ blocks }) {
|
|
253
|
+
return aiPreview(blocks, { registry });
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
`aiPreview(content, options)`:
|
|
258
|
+
|
|
259
|
+
- `content` — a single block or an array of blocks, each shaped as `{ id?, type, content }`. `type` selects the component from the registry; `content` is passed straight to it. `id` defaults to the block's index.
|
|
260
|
+
- `options.registry` — your `BlockComponentRegistry` (required).
|
|
261
|
+
- `options.routeParams` — optional resolved route params, exposed to components as `routeParams`.
|
|
262
|
+
- `options.path` — optional current path, enabling path-namespaced registry keys like `"/{lang}/blog ArticleBlock"`.
|
|
263
|
+
|
|
264
|
+
Edit overlays are always disabled and nothing is fetched, so the output is plain, public-shaped markup. Unknown block types are skipped (with a development-only warning).
|
|
265
|
+
|
|
235
266
|
## Docs Markdown
|
|
236
267
|
|
|
237
268
|
`cms-renderer` includes a docs-oriented markdown boundary for starter apps and docs sites.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
import { BlockComponentRegistry, ResolvedRouteParams } from './types.js';
|
|
3
|
+
import '@repo/cms-schema/blocks';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Headless AI preview rendering for Profound CMS.
|
|
7
|
+
*
|
|
8
|
+
* `aiPreview` renders CMS block content to a React tree using your component
|
|
9
|
+
* registry, without any CMS fetch, query params, or edit-mode overlays. Use it
|
|
10
|
+
* when you already have AI-generated (or otherwise resolved) block content in
|
|
11
|
+
* hand — for example from a generation webhook, an agent, or your own preview
|
|
12
|
+
* endpoint — and want to render a headless preview of it.
|
|
13
|
+
*
|
|
14
|
+
* This is the programmatic counterpart to the `ai_preview` query param handled
|
|
15
|
+
* by `renderParametricRoute`: same registry-based dispatch, but you supply the
|
|
16
|
+
* content instead of the CMS API.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A single block of preview content. `type` selects the component from the
|
|
21
|
+
* registry; `content` is passed straight to that component.
|
|
22
|
+
*/
|
|
23
|
+
type AiPreviewBlock = {
|
|
24
|
+
/** Stable id used as the React key. Defaults to the block's index. */
|
|
25
|
+
id?: string;
|
|
26
|
+
/** Block type / schema name. Maps to a component in the registry. */
|
|
27
|
+
type: string;
|
|
28
|
+
/** Block content passed to the resolved component. */
|
|
29
|
+
content: Record<string, unknown>;
|
|
30
|
+
};
|
|
31
|
+
type AiPreviewOptions = {
|
|
32
|
+
/** Component registry mapping block types to React components. */
|
|
33
|
+
registry: Partial<BlockComponentRegistry>;
|
|
34
|
+
/** Resolved route parameters, exposed to components as `routeParams`. */
|
|
35
|
+
routeParams?: ResolvedRouteParams;
|
|
36
|
+
/**
|
|
37
|
+
* Current path. Enables path-namespaced component lookup, where registry
|
|
38
|
+
* keys like `"/{lang}/blog ArticleBlock"` are matched against this path.
|
|
39
|
+
*/
|
|
40
|
+
path?: string;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Renders one or more AI-preview blocks headlessly.
|
|
44
|
+
*
|
|
45
|
+
* Components are resolved from `registry` exactly as in the catch-all route,
|
|
46
|
+
* but nothing is fetched and edit overlays are always disabled — the output is
|
|
47
|
+
* plain, public-shaped markup. Unknown block types are skipped (a warning is
|
|
48
|
+
* logged in development).
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```tsx
|
|
52
|
+
* import { aiPreview } from 'cms-renderer/lib/ai-preview';
|
|
53
|
+
* import HeroBlock from '@/components/HeroBlock';
|
|
54
|
+
*
|
|
55
|
+
* export default function PreviewPage({ blocks }) {
|
|
56
|
+
* return aiPreview(blocks, { registry: { 'hero-block': HeroBlock } });
|
|
57
|
+
* }
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
declare function aiPreview(content: AiPreviewBlock | AiPreviewBlock[], { registry, routeParams, path }: AiPreviewOptions): ReactNode;
|
|
61
|
+
|
|
62
|
+
export { type AiPreviewBlock, type AiPreviewOptions, aiPreview };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// lib/block-renderer.tsx
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
function extractContentValues(content, basePath = []) {
|
|
5
|
+
const map = /* @__PURE__ */ new Map();
|
|
6
|
+
function walk(obj, path) {
|
|
7
|
+
if (typeof obj === "string" && obj.trim() !== "") {
|
|
8
|
+
const contentPath = path.join(".");
|
|
9
|
+
const existing = map.get(obj) || [];
|
|
10
|
+
existing.push({ contentPath, value: obj });
|
|
11
|
+
map.set(obj, existing);
|
|
12
|
+
} else if (Array.isArray(obj)) {
|
|
13
|
+
for (let index = 0; index < obj.length; index++) {
|
|
14
|
+
walk(obj[index], [...path, String(index)]);
|
|
15
|
+
}
|
|
16
|
+
} else if (obj && typeof obj === "object") {
|
|
17
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
18
|
+
walk(value, [...path, key]);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
walk(content, basePath);
|
|
23
|
+
return map;
|
|
24
|
+
}
|
|
25
|
+
function pathMatchesPattern(path, pattern) {
|
|
26
|
+
const pathSegs = path.split("/").filter(Boolean);
|
|
27
|
+
const patternSegs = pattern.split("/").filter(Boolean);
|
|
28
|
+
if (pathSegs.length !== patternSegs.length) return false;
|
|
29
|
+
for (let i = 0; i < patternSegs.length; i++) {
|
|
30
|
+
const seg = patternSegs[i];
|
|
31
|
+
if (!seg) return false;
|
|
32
|
+
if (seg.startsWith("{") && seg.endsWith("}") || seg.startsWith("(") && seg.endsWith(")")) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (seg !== pathSegs[i]) return false;
|
|
36
|
+
}
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
function resolveComponent(registry, blockType, path) {
|
|
40
|
+
if (path) {
|
|
41
|
+
for (const key of Object.keys(registry)) {
|
|
42
|
+
if (!key.startsWith("/")) continue;
|
|
43
|
+
const spaceIdx = key.indexOf(" ");
|
|
44
|
+
if (spaceIdx === -1) continue;
|
|
45
|
+
const pathPattern = key.slice(0, spaceIdx);
|
|
46
|
+
const registeredType = key.slice(spaceIdx + 1);
|
|
47
|
+
if (registeredType !== blockType) continue;
|
|
48
|
+
if (pathMatchesPattern(path, pathPattern)) {
|
|
49
|
+
return registry[key];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return registry[blockType];
|
|
54
|
+
}
|
|
55
|
+
function BlockRenderer({
|
|
56
|
+
block,
|
|
57
|
+
registry,
|
|
58
|
+
disableEditable,
|
|
59
|
+
enableContentEditable,
|
|
60
|
+
routeParams,
|
|
61
|
+
path
|
|
62
|
+
}) {
|
|
63
|
+
const Component = resolveComponent(registry, block.type, path);
|
|
64
|
+
if (!Component) {
|
|
65
|
+
if (process.env.NODE_ENV === "development") {
|
|
66
|
+
console.warn(`[BlockRenderer] Unknown block type: ${block.type}`);
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const language = routeParams ? Object.values(routeParams).find((p) => p.schemaName === "language")?.value : void 0;
|
|
71
|
+
const component = /* @__PURE__ */ jsx(Component, { content: block.content, routeParams, language, path });
|
|
72
|
+
if (disableEditable) {
|
|
73
|
+
return component;
|
|
74
|
+
}
|
|
75
|
+
const contentEntries = enableContentEditable ? [...extractContentValues(block.content).values()].flat().map(({ value, contentPath }) => ({ v: value, p: contentPath })) : void 0;
|
|
76
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
77
|
+
/* @__PURE__ */ jsx(
|
|
78
|
+
"span",
|
|
79
|
+
{
|
|
80
|
+
"data-cms-sentinel": "",
|
|
81
|
+
"data-block-id": block.id,
|
|
82
|
+
"data-block-type": block.type,
|
|
83
|
+
"data-content-entries": contentEntries ? JSON.stringify(contentEntries) : void 0,
|
|
84
|
+
style: { display: "none" },
|
|
85
|
+
"aria-hidden": "true"
|
|
86
|
+
}
|
|
87
|
+
),
|
|
88
|
+
component
|
|
89
|
+
] });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// lib/ai-preview.tsx
|
|
93
|
+
import { Fragment as Fragment2, jsx as jsx2 } from "react/jsx-runtime";
|
|
94
|
+
function aiPreview(content, { registry, routeParams, path }) {
|
|
95
|
+
const blocks = Array.isArray(content) ? content : [content];
|
|
96
|
+
return /* @__PURE__ */ jsx2(Fragment2, { children: blocks.map((block, index) => {
|
|
97
|
+
const id = block.id ?? String(index);
|
|
98
|
+
return /* @__PURE__ */ jsx2(
|
|
99
|
+
BlockRenderer,
|
|
100
|
+
{
|
|
101
|
+
block: { id, type: block.type, content: block.content },
|
|
102
|
+
registry,
|
|
103
|
+
disableEditable: true,
|
|
104
|
+
routeParams,
|
|
105
|
+
path
|
|
106
|
+
},
|
|
107
|
+
id
|
|
108
|
+
);
|
|
109
|
+
}) });
|
|
110
|
+
}
|
|
111
|
+
export {
|
|
112
|
+
aiPreview
|
|
113
|
+
};
|
|
114
|
+
//# sourceMappingURL=ai-preview.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../lib/block-renderer.tsx","../../lib/ai-preview.tsx"],"sourcesContent":["/**\n * Block Renderer Component\n *\n * Dispatches block data to the appropriate component using the ComponentMap pattern.\n * This is the main entry point for rendering blocks from the CMS.\n */\n\nimport React from 'react';\nimport { generateCmsOverlayScript } from './cms-overlay-script';\nimport { getCmsParentTargetOrigin } from './cms-post-message';\nimport type { BlockComponentRegistry, BlockData, ResolvedRouteParams } from './types';\n\ntype TextInfo = {\n value: string;\n path: Array<string | number>;\n parentType?: React.ElementType;\n key?: React.Key | null;\n inSvg?: boolean;\n};\n\ntype ElementInfo = {\n element: React.ReactElement;\n path: Array<string | number>;\n};\n\ntype WalkVisitors = {\n /**\n * Called for every string/number child encountered.\n * Return:\n * - same string (or modified)\n * - a ReactNode (e.g. wrap in <span/>)\n */\n onText?: (info: TextInfo) => React.ReactNode;\n\n /**\n * Called for every ReactElement encountered (after children are processed).\n * Return:\n * - same element\n * - a cloned/modified element\n */\n onElement?: (info: ElementInfo) => React.ReactElement;\n};\n\n/**\n * Recursively maps a ReactNode tree, allowing transformations of text nodes and/or elements.\n * SSR-safe: does not touch DOM APIs.\n */\nexport function walkReactNode(\n node: React.ReactNode,\n visitors: WalkVisitors,\n ctx: {\n path?: Array<string | number>;\n parentType?: React.ElementType;\n key?: React.Key | null;\n inSvg?: boolean;\n } = {}\n): React.ReactNode {\n const path = ctx.path ?? [];\n\n // Fast-path primitives\n if (node == null || typeof node === 'boolean') return node;\n\n if (typeof node === 'string' || typeof node === 'number') {\n const value = String(node);\n return visitors.onText\n ? visitors.onText({ value, path, parentType: ctx.parentType, key: ctx.key, inSvg: ctx.inSvg })\n : node;\n }\n\n // Arrays\n if (Array.isArray(node)) {\n return node.map((child, i) => {\n // biome-ignore lint/suspicious/noExplicitAny: React child key access\n const childKey = (child as any)?.key ?? null;\n const result = walkReactNode(child, visitors, {\n path: [...path, i],\n parentType: ctx.parentType,\n key: childKey,\n inSvg: ctx.inSvg,\n });\n // Ensure array children have keys\n if (React.isValidElement(result) && result.key == null) {\n return React.cloneElement(result, { key: childKey ?? `arr-${path.join('-')}-${i}` });\n }\n return result;\n });\n }\n\n // ReactElement (including Fragment)\n if (React.isValidElement(node)) {\n // biome-ignore lint/suspicious/noExplicitAny: React element props access\n const el = node as React.ReactElement<any>;\n const elProps = el.props as Record<string, unknown> | null;\n\n // Track SVG context so we never inject <span> inside SVG subtrees\n const nextInSvg = ctx.inSvg || el.type === 'svg';\n\n // Recurse into children (if any)\n const hasChildren = elProps && 'children' in elProps;\n const nextChildren = hasChildren\n ? React.Children.map(elProps.children as React.ReactNode, (child, i) => {\n // biome-ignore lint/suspicious/noExplicitAny: React child key access\n const childKey = (child as any)?.key ?? null;\n const result = walkReactNode(child, visitors, {\n path: [...path, 'children', i],\n parentType: el.type as React.ElementType,\n key: childKey,\n inSvg: nextInSvg,\n });\n // Ensure children have keys\n if (React.isValidElement(result) && result.key == null) {\n return React.cloneElement(result, { key: childKey ?? `child-${path.join('-')}-${i}` });\n }\n return result;\n })\n : (elProps?.children as React.ReactNode);\n\n // Only clone if children changed (or if you want to force a clone)\n const cloned = hasChildren\n ? React.cloneElement(el, undefined, nextChildren as React.ReactNode)\n : el;\n\n return visitors.onElement ? visitors.onElement({ element: cloned, path }) : cloned;\n }\n\n // Functions, symbols, portals, etc. are rare here; return as-is\n return node;\n}\n\n// -----------------------------------------------------------------------------\n// Content Value Extraction\n// -----------------------------------------------------------------------------\n\ntype ContentMatch = {\n contentPath: string;\n value: string;\n};\n\n/**\n * Extracts all string values from a content object with their paths.\n * Returns a Map where keys are string values and values are arrays of content paths.\n */\nexport function extractContentValues(\n content: Record<string, unknown>,\n basePath: string[] = []\n): Map<string, ContentMatch[]> {\n const map = new Map<string, ContentMatch[]>();\n\n function walk(obj: unknown, path: string[]) {\n if (typeof obj === 'string' && obj.trim() !== '') {\n const contentPath = path.join('.');\n const existing = map.get(obj) || [];\n existing.push({ contentPath, value: obj });\n map.set(obj, existing);\n } else if (Array.isArray(obj)) {\n for (let index = 0; index < obj.length; index++) {\n walk(obj[index], [...path, String(index)]);\n }\n } else if (obj && typeof obj === 'object') {\n for (const [key, value] of Object.entries(obj)) {\n walk(value, [...path, key]);\n }\n }\n }\n\n walk(content, basePath);\n return map;\n}\n\n// -----------------------------------------------------------------------------\n// CmsEditableInit — render once per page in edit mode\n// -----------------------------------------------------------------------------\n\n/**\n * Renders the shared CMS edit-mode styles and click-routing script.\n * Place this once at the top of your page when edit_mode is active.\n */\nexport function CmsEditableInit({\n cmsUrl,\n cmsParentOrigin,\n}: {\n cmsUrl?: string;\n /** Admin window origin when proxied (from ?cms_parent_origin=). */\n cmsParentOrigin?: string;\n}) {\n const targetOrigin = getCmsParentTargetOrigin(cmsUrl, cmsParentOrigin) ?? '';\n\n return (\n <script\n // biome-ignore lint/security/noDangerouslySetInnerHtml: Inline script for CMS overlay functionality\n dangerouslySetInnerHTML={{\n __html: generateCmsOverlayScript(targetOrigin),\n }}\n />\n );\n}\n\n// -----------------------------------------------------------------------------\n// Props\n// -----------------------------------------------------------------------------\n\ninterface BlockRendererProps {\n /**\n * The block data to render.\n * Must have a `type` field that maps to a registered component.\n */\n block: BlockData;\n registry: Partial<BlockComponentRegistry>;\n /**\n * If true, renders the component without any tree walking or editable wrappers.\n */\n disableEditable?: boolean;\n /**\n * If true, wraps matched text nodes in contentEditable CMS spans.\n */\n enableContentEditable?: boolean;\n /**\n * Resolved route parameters from parametric routes.\n * Each key is a param name (e.g., \"country\") with its value, schema name, and full document.\n */\n routeParams?: ResolvedRouteParams;\n /**\n * The current URL path (e.g. \"/en/test\").\n * When provided, enables path-namespaced component lookup.\n * Registry keys like \"/{lang}/test Article\" will be matched against this path,\n * where `{x}` and `(x)` are treated as single-segment wildcards.\n */\n path?: string;\n}\n\n// -----------------------------------------------------------------------------\n// Path-namespaced component resolution\n// -----------------------------------------------------------------------------\n\n/**\n * Returns true if each segment of `path` matches the corresponding segment in\n * `pattern`, where a pattern segment wrapped in `{…}` or `(…)` is a wildcard.\n */\nexport function pathMatchesPattern(path: string, pattern: string): boolean {\n const pathSegs = path.split('/').filter(Boolean);\n const patternSegs = pattern.split('/').filter(Boolean);\n if (pathSegs.length !== patternSegs.length) return false;\n for (let i = 0; i < patternSegs.length; i++) {\n const seg = patternSegs[i];\n if (!seg) return false;\n if ((seg.startsWith('{') && seg.endsWith('}')) || (seg.startsWith('(') && seg.endsWith(')'))) {\n continue;\n }\n if (seg !== pathSegs[i]) return false;\n }\n return true;\n}\n\n/**\n * Resolves the component for `blockType` from the registry.\n *\n * When `path` is provided, keys of the form `\"/{pattern} BlockType\"` are\n * checked first. The first key whose path pattern matches the current path\n * and whose block-type suffix matches `blockType` wins. Falls back to a\n * direct `registry[blockType]` lookup.\n */\nexport function resolveComponent(\n registry: Partial<BlockComponentRegistry>,\n blockType: string,\n path?: string\n): BlockComponentRegistry[string] | undefined {\n if (path) {\n for (const key of Object.keys(registry)) {\n if (!key.startsWith('/')) continue;\n const spaceIdx = key.indexOf(' ');\n if (spaceIdx === -1) continue;\n const pathPattern = key.slice(0, spaceIdx);\n const registeredType = key.slice(spaceIdx + 1);\n if (registeredType !== blockType) continue;\n if (pathMatchesPattern(path, pathPattern)) {\n return registry[key];\n }\n }\n }\n return registry[blockType];\n}\n\n// -----------------------------------------------------------------------------\n// Component\n// -----------------------------------------------------------------------------\n\n/**\n * Renders a single block by dispatching to the appropriate component.\n *\n * Uses the ComponentMap pattern: the block's `type` field determines which\n * component renders the block's `content`.\n *\n * In editable mode, renders a hidden sentinel before the component. The shared\n * CMS overlay script uses that sentinel to stamp attributes on the component's\n * root element without adding a layout-affecting wrapper.\n */\nexport function BlockRenderer({\n block,\n registry,\n disableEditable,\n enableContentEditable,\n routeParams,\n path,\n}: BlockRendererProps) {\n const Component = resolveComponent(registry, block.type, path);\n\n if (!Component) {\n // Log warning in development, render nothing in production\n if (process.env.NODE_ENV === 'development') {\n console.warn(`[BlockRenderer] Unknown block type: ${block.type}`);\n }\n return null;\n }\n\n // Extract language code from route params if any param is bound to the language schema.\n const language = routeParams\n ? Object.values(routeParams).find((p) => p.schemaName === 'language')?.value\n : undefined;\n\n const component = (\n <Component content={block.content} routeParams={routeParams} language={language} path={path} />\n );\n\n if (disableEditable) {\n return component;\n }\n\n const contentEntries = enableContentEditable\n ? [...extractContentValues(block.content as Record<string, unknown>).values()]\n .flat()\n .map(({ value, contentPath }) => ({ v: value, p: contentPath }))\n : undefined;\n\n return (\n <>\n <span\n data-cms-sentinel=\"\"\n data-block-id={block.id}\n data-block-type={block.type}\n data-content-entries={contentEntries ? JSON.stringify(contentEntries) : undefined}\n style={{ display: 'none' }}\n aria-hidden=\"true\"\n />\n {component}\n </>\n );\n}\n","/**\n * Headless AI preview rendering for Profound CMS.\n *\n * `aiPreview` renders CMS block content to a React tree using your component\n * registry, without any CMS fetch, query params, or edit-mode overlays. Use it\n * when you already have AI-generated (or otherwise resolved) block content in\n * hand — for example from a generation webhook, an agent, or your own preview\n * endpoint — and want to render a headless preview of it.\n *\n * This is the programmatic counterpart to the `ai_preview` query param handled\n * by `renderParametricRoute`: same registry-based dispatch, but you supply the\n * content instead of the CMS API.\n */\n\nimport type { ReactNode } from 'react';\nimport { BlockRenderer } from './block-renderer';\nimport type { BlockComponentRegistry, BlockData, ResolvedRouteParams } from './types';\n\n/**\n * A single block of preview content. `type` selects the component from the\n * registry; `content` is passed straight to that component.\n */\nexport type AiPreviewBlock = {\n /** Stable id used as the React key. Defaults to the block's index. */\n id?: string;\n /** Block type / schema name. Maps to a component in the registry. */\n type: string;\n /** Block content passed to the resolved component. */\n content: Record<string, unknown>;\n};\n\nexport type AiPreviewOptions = {\n /** Component registry mapping block types to React components. */\n registry: Partial<BlockComponentRegistry>;\n /** Resolved route parameters, exposed to components as `routeParams`. */\n routeParams?: ResolvedRouteParams;\n /**\n * Current path. Enables path-namespaced component lookup, where registry\n * keys like `\"/{lang}/blog ArticleBlock\"` are matched against this path.\n */\n path?: string;\n};\n\n/**\n * Renders one or more AI-preview blocks headlessly.\n *\n * Components are resolved from `registry` exactly as in the catch-all route,\n * but nothing is fetched and edit overlays are always disabled — the output is\n * plain, public-shaped markup. Unknown block types are skipped (a warning is\n * logged in development).\n *\n * @example\n * ```tsx\n * import { aiPreview } from 'cms-renderer/lib/ai-preview';\n * import HeroBlock from '@/components/HeroBlock';\n *\n * export default function PreviewPage({ blocks }) {\n * return aiPreview(blocks, { registry: { 'hero-block': HeroBlock } });\n * }\n * ```\n */\nexport function aiPreview(\n content: AiPreviewBlock | AiPreviewBlock[],\n { registry, routeParams, path }: AiPreviewOptions\n): ReactNode {\n const blocks = Array.isArray(content) ? content : [content];\n\n return (\n <>\n {blocks.map((block, index) => {\n const id = block.id ?? String(index);\n return (\n <BlockRenderer\n key={id}\n block={{ id, type: block.type, content: block.content } as BlockData}\n registry={registry}\n disableEditable\n routeParams={routeParams}\n path={path}\n />\n );\n })}\n </>\n );\n}\n"],"mappings":"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","names":["Fragment","jsx"]}
|
|
@@ -21,7 +21,7 @@ function generateCmsOverlayScript(cmsParentOrigin) {
|
|
|
21
21
|
border-radius: 2px;
|
|
22
22
|
}
|
|
23
23
|
[data-cms-editable]:not([data-cms-block]):hover {
|
|
24
|
-
outline: 2px solid #
|
|
24
|
+
outline: 2px solid var(--component-accent, #A78BFA);
|
|
25
25
|
outline-offset: 2px;
|
|
26
26
|
}
|
|
27
27
|
#cms-overlay-root {
|
|
@@ -38,15 +38,38 @@ function generateCmsOverlayScript(cmsParentOrigin) {
|
|
|
38
38
|
}
|
|
39
39
|
.cms-block-outline {
|
|
40
40
|
position: fixed;
|
|
41
|
-
|
|
41
|
+
box-sizing: border-box;
|
|
42
|
+
border: 4px solid var(--component-accent, #A78BFA);
|
|
42
43
|
border-radius: 4px;
|
|
43
44
|
pointer-events: none;
|
|
44
45
|
z-index: 99997;
|
|
45
46
|
transition: all 0.15s ease;
|
|
46
47
|
}
|
|
47
48
|
.cms-block-outline.cms-outline-selected {
|
|
48
|
-
border-color: #
|
|
49
|
-
border-width:
|
|
49
|
+
border-color: var(--component-accent, #A78BFA);
|
|
50
|
+
border-width: 4px;
|
|
51
|
+
}
|
|
52
|
+
.cms-block-label {
|
|
53
|
+
position: absolute;
|
|
54
|
+
top: 0;
|
|
55
|
+
left: 0;
|
|
56
|
+
display: none;
|
|
57
|
+
max-width: 240px;
|
|
58
|
+
padding: 2px 8px;
|
|
59
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
60
|
+
font-size: 12px;
|
|
61
|
+
font-weight: 600;
|
|
62
|
+
line-height: 1.4;
|
|
63
|
+
color: #fff;
|
|
64
|
+
background: var(--component-accent, #A78BFA);
|
|
65
|
+
border-radius: 4px 4px 4px 0;
|
|
66
|
+
white-space: nowrap;
|
|
67
|
+
overflow: hidden;
|
|
68
|
+
text-overflow: ellipsis;
|
|
69
|
+
pointer-events: none;
|
|
70
|
+
}
|
|
71
|
+
.cms-block-label.cms-label-visible {
|
|
72
|
+
display: block;
|
|
50
73
|
}
|
|
51
74
|
.cms-block-cursor {
|
|
52
75
|
position: fixed;
|
|
@@ -211,15 +234,19 @@ function generateCmsOverlayScript(cmsParentOrigin) {
|
|
|
211
234
|
cursor.className = 'cms-block-cursor';
|
|
212
235
|
overlayRoot.appendChild(cursor);
|
|
213
236
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
237
|
+
function createOutline(extraClass) {
|
|
238
|
+
var el = document.createElement('div');
|
|
239
|
+
el.className = 'cms-block-outline' + (extraClass ? ' ' + extraClass : '');
|
|
240
|
+
el.style.display = 'none';
|
|
241
|
+
var label = document.createElement('span');
|
|
242
|
+
label.className = 'cms-block-label';
|
|
243
|
+
el.appendChild(label);
|
|
244
|
+
overlayRoot.appendChild(el);
|
|
245
|
+
return el;
|
|
246
|
+
}
|
|
218
247
|
|
|
219
|
-
var
|
|
220
|
-
selectedOutline
|
|
221
|
-
selectedOutline.style.display = 'none';
|
|
222
|
-
overlayRoot.appendChild(selectedOutline);
|
|
248
|
+
var outline = createOutline();
|
|
249
|
+
var selectedOutline = createOutline('cms-outline-selected');
|
|
223
250
|
|
|
224
251
|
var toolbar = document.createElement('div');
|
|
225
252
|
toolbar.className = 'cms-block-toolbar';
|
|
@@ -247,35 +274,19 @@ function generateCmsOverlayScript(cmsParentOrigin) {
|
|
|
247
274
|
var toolbarVisible = false;
|
|
248
275
|
var selectedBlockId = null;
|
|
249
276
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function preserveEditParamsOnNavigation(e) {
|
|
266
|
-
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
|
|
267
|
-
return;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
var anchor = e.target.closest('a[href]');
|
|
271
|
-
if (!anchor || (anchor.target && anchor.target !== '_self') || anchor.hasAttribute('download')) {
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
var href = anchor.getAttribute('href');
|
|
276
|
-
var decorated = decoratePreviewUrl(href);
|
|
277
|
-
if (decorated && decorated !== href) {
|
|
278
|
-
anchor.setAttribute('href', decorated);
|
|
277
|
+
// In edit mode we hijack every link/button activation. Following a link or
|
|
278
|
+
// firing a button's own handler makes inline editing on that element
|
|
279
|
+
// impossible (the click navigates away or triggers an action instead of
|
|
280
|
+
// placing the caret). We block the default action and stop the event from
|
|
281
|
+
// reaching the element's own listeners, while still letting our selection /
|
|
282
|
+
// edit routing run below.
|
|
283
|
+
function blockInteractiveActivation(e) {
|
|
284
|
+
var interactive = e.target.closest(
|
|
285
|
+
'a[href], button, [role="button"], input[type="submit"], input[type="button"], input[type="reset"]'
|
|
286
|
+
);
|
|
287
|
+
if (interactive) {
|
|
288
|
+
e.preventDefault();
|
|
289
|
+
e.stopPropagation();
|
|
279
290
|
}
|
|
280
291
|
}
|
|
281
292
|
|
|
@@ -295,7 +306,11 @@ function generateCmsOverlayScript(cmsParentOrigin) {
|
|
|
295
306
|
setTimeout(sendReadySignal, 1500);
|
|
296
307
|
|
|
297
308
|
function getBlockElement(blockId) {
|
|
298
|
-
|
|
309
|
+
// Match the real block element, not the hidden sentinel <span> that shares
|
|
310
|
+
// the same data-block-id and precedes it in the DOM. The sentinel is
|
|
311
|
+
// display:none (a 0x0 rect), so selecting it would paint the outline at the
|
|
312
|
+
// top-left corner. Only the stamped block carries data-cms-block.
|
|
313
|
+
return document.querySelector('[data-cms-block][data-block-id="' + blockId + '"]');
|
|
299
314
|
}
|
|
300
315
|
|
|
301
316
|
function getAllBlocks() {
|
|
@@ -310,17 +325,38 @@ function generateCmsOverlayScript(cmsParentOrigin) {
|
|
|
310
325
|
return -1;
|
|
311
326
|
}
|
|
312
327
|
|
|
328
|
+
function formatBlockType(type) {
|
|
329
|
+
if (!type) return 'Block';
|
|
330
|
+
return type
|
|
331
|
+
.replace(/[-_]+/g, ' ')
|
|
332
|
+
.replace(/\\b\\w/g, function(c) { return c.toUpperCase(); });
|
|
333
|
+
}
|
|
334
|
+
|
|
313
335
|
function updateOutline(el, outlineEl) {
|
|
336
|
+
var label = outlineEl.querySelector('.cms-block-label');
|
|
314
337
|
if (!el) {
|
|
315
338
|
outlineEl.style.display = 'none';
|
|
339
|
+
if (label) label.classList.remove('cms-label-visible');
|
|
316
340
|
return;
|
|
317
341
|
}
|
|
318
342
|
var rect = el.getBoundingClientRect();
|
|
343
|
+
// A 0x0 rect means the element isn't laid out yet (or is hidden). Hide the
|
|
344
|
+
// outline instead of drawing a zero-size box with a floating label at the
|
|
345
|
+
// top-left corner.
|
|
346
|
+
if (rect.width === 0 && rect.height === 0) {
|
|
347
|
+
outlineEl.style.display = 'none';
|
|
348
|
+
if (label) label.classList.remove('cms-label-visible');
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
319
351
|
outlineEl.style.display = 'block';
|
|
320
352
|
outlineEl.style.top = rect.top + 'px';
|
|
321
353
|
outlineEl.style.left = rect.left + 'px';
|
|
322
354
|
outlineEl.style.width = rect.width + 'px';
|
|
323
355
|
outlineEl.style.height = rect.height + 'px';
|
|
356
|
+
if (label) {
|
|
357
|
+
label.textContent = formatBlockType(el.getAttribute('data-block-type'));
|
|
358
|
+
label.classList.add('cms-label-visible');
|
|
359
|
+
}
|
|
324
360
|
}
|
|
325
361
|
|
|
326
362
|
function positionToolbar(x, y) {
|
|
@@ -397,17 +433,19 @@ function generateCmsOverlayScript(cmsParentOrigin) {
|
|
|
397
433
|
});
|
|
398
434
|
|
|
399
435
|
document.addEventListener('click', function(e) {
|
|
400
|
-
|
|
436
|
+
// The floating toolbar is interactive (and its buttons are real <button>s);
|
|
437
|
+
// bail out before any blocking so its own handlers run.
|
|
438
|
+
if (e.target.closest('[data-cms-toolbar]')) return;
|
|
401
439
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
440
|
+
blockInteractiveActivation(e);
|
|
441
|
+
|
|
442
|
+
if (toolbarVisible) {
|
|
443
|
+
var activeBlock = e.target.closest('[data-cms-block]');
|
|
444
|
+
if (!activeBlock || activeBlock.getAttribute('data-block-id') !== currentBlockId) {
|
|
405
445
|
hideToolbar();
|
|
406
446
|
}
|
|
407
447
|
}
|
|
408
448
|
|
|
409
|
-
if (e.target.closest('[data-cms-toolbar]')) return;
|
|
410
|
-
|
|
411
449
|
var editable = e.target.closest('[data-cms-editable]');
|
|
412
450
|
if (editable) {
|
|
413
451
|
postToParent({
|
|
@@ -664,7 +702,7 @@ function BlockRenderer({
|
|
|
664
702
|
return null;
|
|
665
703
|
}
|
|
666
704
|
const language = routeParams ? Object.values(routeParams).find((p) => p.schemaName === "language")?.value : void 0;
|
|
667
|
-
const component = /* @__PURE__ */ jsx(Component, { content: block.content, routeParams, language });
|
|
705
|
+
const component = /* @__PURE__ */ jsx(Component, { content: block.content, routeParams, language, path });
|
|
668
706
|
if (disableEditable) {
|
|
669
707
|
return component;
|
|
670
708
|
}
|