@alpaca-headless/dynamic-renderings 1.0.4694

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.
Files changed (69) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +147 -0
  3. package/dist/client/ClientIslandRuntime.d.ts +1 -0
  4. package/dist/client/ClientIslandRuntime.js +12 -0
  5. package/dist/client/DynamicIsland.d.ts +12 -0
  6. package/dist/client/DynamicIsland.js +55 -0
  7. package/dist/client/index.d.ts +2 -0
  8. package/dist/client/index.js +4 -0
  9. package/dist/css-policy/css-policy.d.mts +1 -0
  10. package/dist/css-policy/css-policy.mjs +1 -0
  11. package/dist/handlers/build.d.ts +4 -0
  12. package/dist/handlers/build.js +99 -0
  13. package/dist/handlers/client.d.ts +6 -0
  14. package/dist/handlers/client.js +159 -0
  15. package/dist/handlers/index.d.ts +3 -0
  16. package/dist/handlers/index.js +6 -0
  17. package/dist/next/index.d.ts +5 -0
  18. package/dist/next/index.js +6 -0
  19. package/dist/next/prefix.d.ts +8 -0
  20. package/dist/next/prefix.js +25 -0
  21. package/dist/next/request.d.ts +31 -0
  22. package/dist/next/request.js +33 -0
  23. package/dist/next/routes.d.ts +16 -0
  24. package/dist/next/routes.js +15 -0
  25. package/dist/next/theme.d.ts +8 -0
  26. package/dist/next/theme.js +26 -0
  27. package/dist/protocol/index.d.ts +3 -0
  28. package/dist/protocol/index.js +2 -0
  29. package/dist/protocol/protocol.d.ts +2 -0
  30. package/dist/protocol/protocol.js +1 -0
  31. package/dist/protocol/types.d.ts +1 -0
  32. package/dist/protocol/types.js +1 -0
  33. package/dist/react/DynamicRendering.d.ts +32 -0
  34. package/dist/react/DynamicRendering.js +88 -0
  35. package/dist/react/DynamicTheme.d.ts +5 -0
  36. package/dist/react/DynamicTheme.js +13 -0
  37. package/dist/react/RichText.d.ts +7 -0
  38. package/dist/react/RichText.js +31 -0
  39. package/dist/react/index.d.ts +9 -0
  40. package/dist/react/index.js +8 -0
  41. package/dist/react/renderer.d.ts +45 -0
  42. package/dist/react/renderer.js +41 -0
  43. package/dist/react/to-react.d.ts +42 -0
  44. package/dist/react/to-react.js +112 -0
  45. package/dist/react/trusted.d.ts +3 -0
  46. package/dist/react/trusted.js +31 -0
  47. package/dist/rich-text/index.d.ts +1 -0
  48. package/dist/rich-text/index.js +1 -0
  49. package/dist/rich-text/sanitizeRichText.d.ts +4 -0
  50. package/dist/rich-text/sanitizeRichText.js +99 -0
  51. package/dist/server/backend.d.ts +25 -0
  52. package/dist/server/backend.js +36 -0
  53. package/dist/server/config.d.ts +98 -0
  54. package/dist/server/config.js +49 -0
  55. package/dist/server/engine.d.ts +83 -0
  56. package/dist/server/engine.js +207 -0
  57. package/dist/server/index.d.ts +12 -0
  58. package/dist/server/index.js +8 -0
  59. package/dist/server/page.d.ts +92 -0
  60. package/dist/server/page.js +235 -0
  61. package/dist/server/sanitize-tree.d.ts +28 -0
  62. package/dist/server/sanitize-tree.js +343 -0
  63. package/dist/server/theme.d.ts +14 -0
  64. package/dist/server/theme.js +38 -0
  65. package/next-config/index.cjs +101 -0
  66. package/next-config/index.d.cts +26 -0
  67. package/next-config/rewrites.cjs +25 -0
  68. package/next-config/rewrites.d.cts +3 -0
  69. package/package.json +108 -0
@@ -0,0 +1,33 @@
1
+ import "server-only";
2
+ import { headers } from "next/headers.js";
3
+ import { getApiHeaders } from "@alpaca-headless/alpaca-headless-nextjs";
4
+ import { normalizeRouteSearchParams, splitProxyPrefix } from "./prefix.js";
5
+ export { normalizeRouteSearchParams, splitProxyPrefix };
6
+ /**
7
+ * Step 4 alone: the CM identity of this request. In a sealed request (the config's requestAuth
8
+ * recognised it) that is exactly the headers requestAuth returned, and no cookie; otherwise the
9
+ * forwarded Sitecore credentials. For heads that resolve their route in a cached function and
10
+ * need the identity there; readRequest() uses the same function. Reading request headers also
11
+ * opts the route out of static rendering.
12
+ */
13
+ export async function requestIdentity(config) {
14
+ const auth = config.requestAuth?.(await headers()) ?? null;
15
+ if (auth)
16
+ return { identity: { apiKey: config.apiKey, headers: new Headers(auth.headers) }, sealed: true };
17
+ return { identity: { apiKey: config.apiKey, headers: await getApiHeaders() }, sealed: false };
18
+ }
19
+ /** Splits the proxy prefix, applies the search-param aliases, builds the CM identity and decides edit mode. */
20
+ export async function readRequest(config, params, searchParams) {
21
+ const { route, prefix } = splitProxyPrefix((await params).path);
22
+ const normalized = normalizeRouteSearchParams(await searchParams);
23
+ const { identity, sealed } = await requestIdentity(config);
24
+ if (sealed) {
25
+ normalized.mode = "preview";
26
+ normalized.sc_mode = "preview";
27
+ }
28
+ const adapted = config.requestAdapter?.((await params).path, normalized);
29
+ const editing = normalized.mode === "edit";
30
+ const request = { route, prefix, searchParams: normalized, identity, editorFrame: editing, editing, ...adapted, sealed };
31
+ // A sealed request is never an editor's frame, whatever the adapter reads from its URL.
32
+ return sealed ? { ...request, editing: false, editorFrame: false } : request;
33
+ }
@@ -0,0 +1,16 @@
1
+ import "server-only";
2
+ import type { DynamicRenderingsConfig } from "../server/config.js";
3
+ type Params<T> = {
4
+ params: Promise<T>;
5
+ };
6
+ /** src/app/api/alpaca/renderings/build/route.ts */
7
+ export declare function buildRoute(config: DynamicRenderingsConfig): {
8
+ POST: (request: Request) => Promise<Response>;
9
+ };
10
+ /** src/app/api/alpaca/renderings/client/[file]/route.ts (the rendering id comes in the query) */
11
+ export declare function clientRoute(config: DynamicRenderingsConfig): {
12
+ GET: (request: Request, { params }: Params<{
13
+ file: string;
14
+ }>) => Promise<Response>;
15
+ };
16
+ export {};
@@ -0,0 +1,15 @@
1
+ import "server-only";
2
+ import { handleBuild } from "../handlers/build.js";
3
+ import { handleClientBundle } from "../handlers/client.js";
4
+ /** src/app/api/alpaca/renderings/build/route.ts */
5
+ export function buildRoute(config) {
6
+ return {
7
+ POST: (request) => handleBuild(config, request),
8
+ };
9
+ }
10
+ /** src/app/api/alpaca/renderings/client/[file]/route.ts (the rendering id comes in the query) */
11
+ export function clientRoute(config) {
12
+ return {
13
+ GET: async (request, { params }) => handleClientBundle(config, request, (await params).file),
14
+ };
15
+ }
@@ -0,0 +1,8 @@
1
+ import "server-only";
2
+ import { type ThemeSource } from "../server/config.js";
3
+ /**
4
+ * The head's theme file, relative to the working directory of the Next server (the project in
5
+ * development, the standalone directory in production). withDynamicRenderings traces
6
+ * src/app/**\/theme.css into standalone output; pass `themeFile` there for another location.
7
+ */
8
+ export declare function nextThemeFile(relativePath: string): ThemeSource;
@@ -0,0 +1,26 @@
1
+ import "server-only";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { ThemeUnavailableError } from "../server/config.js";
5
+ /**
6
+ * The head's theme file, relative to the working directory of the Next server (the project in
7
+ * development, the standalone directory in production). withDynamicRenderings traces
8
+ * src/app/**\/theme.css into standalone output; pass `themeFile` there for another location.
9
+ */
10
+ export function nextThemeFile(relativePath) {
11
+ return {
12
+ describe: relativePath,
13
+ async load() {
14
+ const full = path.join(/*turbopackIgnore: true*/ process.cwd(), relativePath);
15
+ try {
16
+ return await readFile(full, "utf8");
17
+ }
18
+ catch (err) {
19
+ const code = err?.code;
20
+ throw new ThemeUnavailableError(code === "ENOENT"
21
+ ? `theme file not found at ${full}`
22
+ : `theme file at ${full} could not be read (${code ?? (err instanceof Error ? err.message : String(err))})`);
23
+ }
24
+ },
25
+ };
26
+ }
@@ -0,0 +1,3 @@
1
+ export { protocol } from "./protocol.js";
2
+ export type { Protocol, ProtocolLimits } from "./protocol.js";
3
+ export type * from "./types.js";
@@ -0,0 +1,2 @@
1
+ // Plain module: no React, no server-only. The engine and next.config.js may import it.
2
+ export { protocol } from "./protocol.js";
@@ -0,0 +1,2 @@
1
+ export { protocol } from "@alpaca-headless/dynamic-renderings-engine/protocol";
2
+ export type { Protocol, ProtocolLimits } from "@alpaca-headless/dynamic-renderings-engine/protocol";
@@ -0,0 +1 @@
1
+ export { protocol } from "@alpaca-headless/dynamic-renderings-engine/protocol";
@@ -0,0 +1 @@
1
+ export type * from "@alpaca-headless/dynamic-renderings-engine/protocol";
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,32 @@
1
+ import "server-only";
2
+ import { type ReactNode } from "react";
3
+ import type { PreparedComponent } from "../protocol/types.js";
4
+ import { type DynamicPageState } from "../server/page.js";
5
+ import type { DynamicRenderingsConfig } from "../server/config.js";
6
+ import { type ConvertContext, type TrustedComponents } from "./to-react.js";
7
+ type PlaceholderProps = {
8
+ component: any;
9
+ context: any;
10
+ index?: number;
11
+ count?: number;
12
+ };
13
+ export declare function ErrorBox({ title, detail }: {
14
+ title: string;
15
+ detail?: string;
16
+ }): import("react").JSX.Element;
17
+ /**
18
+ * The URL of a rendering's browser bundle. It names everything needed to build the bundle again
19
+ * after a cache miss: its client code's hash and the compiler format in the path, the rendering
20
+ * and the database in the query, plus the page in a sealed request (the CM then checks the
21
+ * preview's access to that page). The path stays short: behind the sealed preview's proxy prefix,
22
+ * which holds a token, it must fit the CM's URL length limit; the query has its own.
23
+ */
24
+ export declare function clientBundleUrl(state: Pick<DynamicPageState, "prefix" | "database" | "sealed" | "pageId">, renderingId: string, clientHash: string): string;
25
+ /** Where this rendering's client code may run on this page, if anywhere. */
26
+ export declare function clientLoading(state: DynamicPageState, prepared: PreparedComponent, editing: boolean): ConvertContext["client"];
27
+ /**
28
+ * Returns the component the SDK's Placeholder mounts for every component whose layout JSON
29
+ * carries a `rendering` reference. It reads the result prepared for this page.
30
+ */
31
+ export declare function createDynamicComponent(config: DynamicRenderingsConfig, state: DynamicPageState, trusted: TrustedComponents): ({ component, context }: PlaceholderProps) => ReactNode;
32
+ export {};
@@ -0,0 +1,88 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import "server-only";
3
+ import { createElement } from "react";
4
+ import { protocol } from "../protocol/protocol.js";
5
+ import { sanitizeTree } from "../server/sanitize-tree.js";
6
+ import { normalizeId } from "../server/page.js";
7
+ import { convertTree } from "./to-react.js";
8
+ /* eslint-enable @typescript-eslint/no-explicit-any */
9
+ export function ErrorBox({ title, detail }) {
10
+ return (_jsxs("div", { role: "note", style: {
11
+ border: "1px dashed #c2410c",
12
+ background: "#fff7ed",
13
+ color: "#7c2d12",
14
+ padding: "12px 16px",
15
+ margin: "8px 0",
16
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
17
+ fontSize: 13,
18
+ lineHeight: 1.45,
19
+ }, children: [_jsx("strong", { style: { display: "block" }, children: title }), detail ? _jsx("span", { style: { whiteSpace: "pre-wrap" }, children: detail.slice(0, 1200) }) : null] }));
20
+ }
21
+ /**
22
+ * The URL of a rendering's browser bundle. It names everything needed to build the bundle again
23
+ * after a cache miss: its client code's hash and the compiler format in the path, the rendering
24
+ * and the database in the query, plus the page in a sealed request (the CM then checks the
25
+ * preview's access to that page). The path stays short: behind the sealed preview's proxy prefix,
26
+ * which holds a token, it must fit the CM's URL length limit; the query has its own.
27
+ */
28
+ export function clientBundleUrl(state, renderingId, clientHash) {
29
+ const query = new URLSearchParams({ id: renderingId });
30
+ if (state.database)
31
+ query.set("database", state.database);
32
+ if (state.sealed && state.pageId)
33
+ query.set("page", state.pageId);
34
+ return `${state.prefix}/api/alpaca/renderings/client/${clientHash}-${protocol.compiledFormat}.js?${query}`;
35
+ }
36
+ /** Where this rendering's client code may run on this page, if anywhere. */
37
+ export function clientLoading(state, prepared, editing) {
38
+ if (editing || state.clientMode === "edit" || !prepared.clientHash)
39
+ return undefined;
40
+ if (state.clientMode === "sealed")
41
+ return { mode: "sealed", src: clientBundleUrl(state, prepared.renderingId, prepared.clientHash) };
42
+ return prepared.approved ? { mode: "approved", src: clientBundleUrl(state, prepared.renderingId, prepared.clientHash) } : undefined;
43
+ }
44
+ /**
45
+ * Returns the component the SDK's Placeholder mounts for every component whose layout JSON
46
+ * carries a `rendering` reference. It reads the result prepared for this page.
47
+ */
48
+ export function createDynamicComponent(config, state, trusted) {
49
+ return function DynamicComponent({ component, context }) {
50
+ const componentId = normalizeId(String(component?.id ?? ""));
51
+ const prepared = state.components.get(componentId);
52
+ const editing = Boolean(context?.isEditing);
53
+ try {
54
+ if (!prepared) {
55
+ return editing ? _jsx(ErrorBox, { title: "Dynamic rendering not prepared", detail: "Reload the page." }) : null;
56
+ }
57
+ if (prepared.preparing) {
58
+ return editing ? _jsx(ErrorBox, { title: "Being prepared", detail: prepared.error }) : null;
59
+ }
60
+ if (prepared.error || !prepared.result || !prepared.buildId) {
61
+ return editing ? _jsx(ErrorBox, { title: "This component cannot be shown", detail: prepared.error }) : null;
62
+ }
63
+ const result = prepared.result;
64
+ if (!result.ok) {
65
+ return editing ? _jsx(ErrorBox, { title: `Rendering failed (${result.code})`, detail: result.message }) : null;
66
+ }
67
+ const tree = sanitizeTree(result.tree, prepared.renderingId, { mediaHosts: config.mediaHosts, limits: config.limits });
68
+ const body = convertTree(tree, {
69
+ component: { ...component, id: componentId },
70
+ renderContext: context,
71
+ nonce: state.nonce,
72
+ editor: config.editor,
73
+ renderingId: prepared.renderingId,
74
+ buildId: prepared.buildId,
75
+ trusted,
76
+ client: clientLoading(state, prepared, editing),
77
+ });
78
+ return (_jsxs(_Fragment, { children: [prepared.css
79
+ ? createElement("style", { href: `rendering-${prepared.buildId}`, precedence: "dynamic-renderings" }, prepared.css)
80
+ : null, _jsxs("div", { "data-parhelia-rendering": prepared.renderingId, ...(editing && config.editor ? { [config.editor.nonceAttribute]: state.nonce } : {}), style: { contain: "layout paint", isolation: "isolate" }, children: [editing && prepared.cssRejected ? _jsx(ErrorBox, { title: "Styles were not applied", detail: prepared.cssRejected }) : null, body] })] }));
81
+ }
82
+ catch (err) {
83
+ // A server-component error would reach the client boundary with its message redacted in
84
+ // production; catching here keeps the explanation visible to editors.
85
+ return editing ? _jsx(ErrorBox, { title: "Rendering failed", detail: err instanceof Error ? err.message : String(err) }) : null;
86
+ }
87
+ };
88
+ }
@@ -0,0 +1,5 @@
1
+ import "server-only";
2
+ import { type ReactNode } from "react";
3
+ import type { DynamicPageState } from "../server/page.js";
4
+ /** The tenant's theme tokens as a :root rule, hoisted into <head> once per theme. */
5
+ export declare function renderTheme(state: DynamicPageState, editing: boolean): ReactNode;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import "server-only";
3
+ import { createElement } from "react";
4
+ import { ErrorBox } from "./DynamicRendering.js";
5
+ /** The tenant's theme tokens as a :root rule, hoisted into <head> once per theme. */
6
+ export function renderTheme(state, editing) {
7
+ const theme = state.theme;
8
+ if (!theme)
9
+ return null;
10
+ if (!theme.css)
11
+ return editing && theme.rejected ? _jsx(ErrorBox, { title: "The site theme was not applied", detail: theme.rejected }) : null;
12
+ return createElement("style", { href: `theme-${theme.hash}`, precedence: "dynamic-theme" }, theme.css);
13
+ }
@@ -0,0 +1,7 @@
1
+ import { RichText as AlpacaRichText } from "@alpaca-headless/alpaca-headless";
2
+ import type { ComponentProps } from "react";
3
+ /**
4
+ * The SDK RichText with the field HTML sanitized on the server first (see
5
+ * rich-text/sanitizeRichText). Rich-text fields of dynamic renderings render through this.
6
+ */
7
+ export declare function RichText({ field, ...props }: ComponentProps<typeof AlpacaRichText>): import("react").JSX.Element | null;
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { RichText as AlpacaRichText } from "@alpaca-headless/alpaca-headless";
3
+ import { sanitizeRichTextValue } from "../rich-text/sanitizeRichText.js";
4
+ // The editor binds inline editing to these; the SDK applies caller props after
5
+ // them, so they are taken from the field only, never from the caller.
6
+ const EDITOR_OWNED_PROPS = new Set([
7
+ "data-fieldid",
8
+ "data-itemid",
9
+ "data-language",
10
+ "data-version",
11
+ "data-is-richtext",
12
+ "contentEditable",
13
+ "dangerouslySetInnerHTML",
14
+ ]);
15
+ /**
16
+ * The SDK RichText with the field HTML sanitized on the server first (see
17
+ * rich-text/sanitizeRichText). Rich-text fields of dynamic renderings render through this.
18
+ */
19
+ export function RichText({ field, ...props }) {
20
+ if (!field)
21
+ return null;
22
+ // The SDK renders jsonValue when present; sanitize the value it would render.
23
+ const rendered = field.jsonValue ?? field;
24
+ const safeField = {
25
+ ...rendered,
26
+ jsonValue: undefined,
27
+ value: sanitizeRichTextValue(rendered.value),
28
+ };
29
+ const safeProps = Object.fromEntries(Object.entries(props).filter(([name]) => !EDITOR_OWNED_PROPS.has(name)));
30
+ return _jsx(AlpacaRichText, { ...safeProps, field: safeField });
31
+ }
@@ -0,0 +1,9 @@
1
+ import "server-only";
2
+ export { createRenderer } from "./renderer.js";
3
+ export type { Renderer, AttachedPage, DynamicRequestInfo } from "./renderer.js";
4
+ export { defaultTrustedComponents } from "./trusted.js";
5
+ export { convertTree } from "./to-react.js";
6
+ export type { TrustedComponents, ConvertContext } from "./to-react.js";
7
+ export { createDynamicComponent, clientBundleUrl, clientLoading, ErrorBox } from "./DynamicRendering.js";
8
+ export { renderTheme } from "./DynamicTheme.js";
9
+ export { RichText } from "./RichText.js";
@@ -0,0 +1,8 @@
1
+ // Server components (React Server Components): render trees become React elements here.
2
+ import "server-only";
3
+ export { createRenderer } from "./renderer.js";
4
+ export { defaultTrustedComponents } from "./trusted.js";
5
+ export { convertTree } from "./to-react.js";
6
+ export { createDynamicComponent, clientBundleUrl, clientLoading, ErrorBox } from "./DynamicRendering.js";
7
+ export { renderTheme } from "./DynamicTheme.js";
8
+ export { RichText } from "./RichText.js";
@@ -0,0 +1,45 @@
1
+ import "server-only";
2
+ import { type ReactNode } from "react";
3
+ import type { BackendIdentity } from "../server/backend.js";
4
+ import type { DynamicRenderingsConfig } from "../server/config.js";
5
+ import { type DynamicPageState, type LayoutPage } from "../server/page.js";
6
+ import type { TrustedComponents } from "./to-react.js";
7
+ /** What the page learned before the layout call (see readRequest in the Next adapter). */
8
+ export type DynamicRequestInfo = {
9
+ identity: BackendIdentity;
10
+ prefix: string;
11
+ /** Whether an editor owns this frame. */
12
+ editorFrame: boolean;
13
+ /** The request came through an editor's isolated preview (the config's requestAuth). */
14
+ sealed?: boolean;
15
+ };
16
+ type RenderContextLike = {
17
+ mode?: string;
18
+ isEditing?: boolean;
19
+ isPreview?: boolean;
20
+ resolveRendering?: unknown;
21
+ page?: unknown;
22
+ } & Record<string, any>;
23
+ export type AttachedPage<C, P> = {
24
+ /** The page to render, after the config's transformPage. */
25
+ page: P;
26
+ /** The render context to render with: it resolves dynamic components. */
27
+ context: C;
28
+ state: DynamicPageState;
29
+ /**
30
+ * Rendered once by the page: the client-code hint for the editor (preview only) and, on pages
31
+ * that load client code, the runtime its bundles take React from.
32
+ */
33
+ head: ReactNode;
34
+ };
35
+ export type Renderer = {
36
+ /** Steps 6 to 9 of the page integration: prepares every dynamic rendering on the page and plugs the resolver into the context. */
37
+ attach<C extends RenderContextLike, P extends LayoutPage>(context: C, page: P, request: DynamicRequestInfo): Promise<AttachedPage<C, P>>;
38
+ /** Step 10: the page body, preceded by the tenant's theme tokens. */
39
+ frame<C extends RenderContextLike, P extends LayoutPage>(attached: AttachedPage<C, P>, body: ReactNode): ReactNode;
40
+ readonly trusted: TrustedComponents;
41
+ };
42
+ export declare function createRenderer(config: DynamicRenderingsConfig, options?: {
43
+ components?: Partial<TrustedComponents>;
44
+ }): Renderer;
45
+ export {};
@@ -0,0 +1,41 @@
1
+ import "server-only";
2
+ import { createElement, Fragment } from "react";
3
+ import { prepareDynamicPage } from "../server/page.js";
4
+ import { ClientIslandRuntime } from "../client/ClientIslandRuntime.js";
5
+ import { clientLoading, createDynamicComponent } from "./DynamicRendering.js";
6
+ import { renderTheme } from "./DynamicTheme.js";
7
+ import { defaultTrustedComponents } from "./trusted.js";
8
+ export function createRenderer(config, options = {}) {
9
+ const trusted = { ...defaultTrustedComponents, ...options.components };
10
+ return {
11
+ trusted,
12
+ async attach(context, page, request) {
13
+ const sealed = Boolean(request.sealed);
14
+ const transformed = config.transformPage ? config.transformPage(page, { sealed, prefix: request.prefix }) : page;
15
+ const baseContext = transformed === page ? context : { ...context, page: transformed };
16
+ const state = await prepareDynamicPage(config, {
17
+ page: transformed,
18
+ mode: String(baseContext.mode ?? "normal"),
19
+ identity: request.identity,
20
+ prefix: request.prefix,
21
+ sealed,
22
+ });
23
+ const DynamicComponent = createDynamicComponent(config, state, trusted);
24
+ const resolved = { ...baseContext, resolveRendering: async () => DynamicComponent };
25
+ const editing = Boolean(resolved.isEditing);
26
+ const hint = config.editor?.clientCodeHintAttribute;
27
+ const loadsBundles = [...state.components.values()].some((prepared) => clientLoading(state, prepared, editing));
28
+ const head = createElement(Fragment, null,
29
+ // Tells the editor that this preview has renderings with client code, so it can offer to
30
+ // run them in its isolated preview. The sanitizer keeps renderings from forging it.
31
+ hint && state.preview && !state.sealed && !editing && state.clientCodeRenderings.length
32
+ ? createElement("script", { type: "application/json", [hint]: state.clientCodeRenderings.join(",") })
33
+ : null, loadsBundles ? createElement(ClientIslandRuntime) : null);
34
+ return { page: transformed, context: resolved, state, head };
35
+ },
36
+ frame(attached, body) {
37
+ const editing = Boolean(attached.context?.isEditing);
38
+ return createElement(Fragment, null, renderTheme(attached.state, editing), body);
39
+ },
40
+ };
41
+ }
@@ -0,0 +1,42 @@
1
+ import { type ComponentType, type ReactNode } from "react";
2
+ import type { EditorMarkers } from "../server/config.js";
3
+ import type { RenderNode } from "../protocol/types.js";
4
+ export type TrustedComponents = {
5
+ Text: ComponentType<any>;
6
+ RichText: ComponentType<any>;
7
+ Picture: ComponentType<any>;
8
+ Link: ComponentType<any>;
9
+ Placeholder: ComponentType<any>;
10
+ EditorFields: ComponentType<any>;
11
+ Icon: (name: string, props: Record<string, unknown>) => ReactNode;
12
+ /** Loads a component of the rendering's client code in the browser (see client/DynamicIsland). */
13
+ ClientIsland: ComponentType<any>;
14
+ };
15
+ export type ConvertContext = {
16
+ component: {
17
+ id: string;
18
+ fields?: Record<string, any>;
19
+ } & Record<string, any>;
20
+ renderContext: {
21
+ isEditing?: boolean;
22
+ isPreview?: boolean;
23
+ dictionary?: Record<string, {
24
+ value?: string;
25
+ } | undefined>;
26
+ } & Record<string, any>;
27
+ nonce: string;
28
+ editor?: Readonly<EditorMarkers>;
29
+ renderingId: string;
30
+ buildId: string;
31
+ trusted: TrustedComponents;
32
+ /**
33
+ * Set only where this rendering's client code may run: its browser bundle and why it may load.
34
+ * Without it every island is its static first paint and the page carries no bundle URL.
35
+ */
36
+ client?: {
37
+ src: string;
38
+ mode: "approved" | "sealed";
39
+ };
40
+ };
41
+ export declare function convert(node: RenderNode, ctx: ConvertContext, key: number | string, islandChildren?: ReactNode): ReactNode;
42
+ export declare function convertTree(nodes: RenderNode[], ctx: ConvertContext): ReactNode;
@@ -0,0 +1,112 @@
1
+ // Turns a sanitized render tree into React elements. Every editor marker in the output is
2
+ // produced here from the component JSON: field markers by the real SDK field components
3
+ // (with the host's descriptor), component and dictionary markers by this file, nested
4
+ // placeholders by the real SDK Placeholder. All of them carry the per-request nonce the
5
+ // rendering code never saw; the editor bridge honours markers inside a dynamic rendering only when
6
+ // they carry it.
7
+ import { createElement, Fragment } from "react";
8
+ /* eslint-enable @typescript-eslint/no-explicit-any */
9
+ function nonceAttributes(ctx) {
10
+ return ctx.editor ? { [ctx.editor.nonceAttribute]: ctx.nonce } : {};
11
+ }
12
+ function slotAttributes(ctx) {
13
+ return ctx.editor ? { [ctx.editor.slotAttribute]: ctx.nonce } : {};
14
+ }
15
+ function fieldByName(ctx, name) {
16
+ const fields = ctx.component.fields ?? {};
17
+ if (Object.prototype.hasOwnProperty.call(fields, name))
18
+ return fields[name];
19
+ const lower = name.toLowerCase();
20
+ const key = Object.keys(fields).find((k) => k.toLowerCase() === lower || fields[k]?.name?.toLowerCase?.() === lower);
21
+ return key ? fields[key] : undefined;
22
+ }
23
+ function convertChildren(nodes, ctx, islandChildren) {
24
+ return (nodes ?? []).map((node, index) => convert(node, ctx, index, islandChildren));
25
+ }
26
+ export function convert(node, ctx, key, islandChildren) {
27
+ if (typeof node === "string")
28
+ return node;
29
+ const { trusted } = ctx;
30
+ switch (node.t) {
31
+ case "el": {
32
+ const props = { key };
33
+ for (const [name, value] of Object.entries(node.props)) {
34
+ if (name === "__parheliaComponentAttrs") {
35
+ if (ctx.renderContext.isEditing) {
36
+ props["data-component-id"] = ctx.component.id;
37
+ Object.assign(props, nonceAttributes(ctx));
38
+ }
39
+ continue;
40
+ }
41
+ props[name] = value;
42
+ }
43
+ const children = convertChildren(node.children, ctx, islandChildren);
44
+ return createElement(node.tag, props, ...(children.length ? children : []));
45
+ }
46
+ case "fragment":
47
+ return createElement(Fragment, { key }, ...convertChildren(node.children, ctx, islandChildren));
48
+ case "field": {
49
+ const field = fieldByName(ctx, node.field);
50
+ if (!field)
51
+ return null;
52
+ const props = { key, field, ...node.props };
53
+ if (ctx.renderContext.isEditing)
54
+ Object.assign(props, nonceAttributes(ctx));
55
+ if (node.kind === "text")
56
+ return createElement(trusted.Text, { ...props, tag: node.tag });
57
+ if (node.kind === "richtext")
58
+ return createElement(trusted.RichText, { ...props, tag: node.tag });
59
+ // The SDK's Picture builds a video's attributes from videoProps, not from the rest props,
60
+ // so the nonce must travel there too or the bridge ignores video fields.
61
+ if (node.kind === "picture") {
62
+ return createElement(trusted.Picture, ctx.renderContext.isEditing ? { ...props, videoProps: nonceAttributes(ctx) } : props);
63
+ }
64
+ return createElement(trusted.Link, props, ...convertChildren(node.children, ctx, islandChildren));
65
+ }
66
+ case "slot": {
67
+ const placeholders = (ctx.component.placeholders ?? []);
68
+ if (!placeholders.some((p) => p.name === node.name))
69
+ return null;
70
+ // Nested components come from the host's own Placeholder; the slot wrapper tells the
71
+ // bridge that markers below it are the host's, not the rendering's.
72
+ return createElement("div", { key, ...slotAttributes(ctx), style: { display: "contents" } }, createElement(trusted.Placeholder, { name: node.name, content: ctx.component, context: ctx.renderContext }));
73
+ }
74
+ case "icon":
75
+ return createElement(Fragment, { key }, trusted.Icon(node.name, node.props));
76
+ case "translate": {
77
+ const text = ctx.renderContext.dictionary?.[node.key]?.value || node.key;
78
+ if (!ctx.renderContext.isEditing)
79
+ return createElement(Fragment, { key }, text);
80
+ return createElement(Fragment, { key }, createElement("script", { "data-dictionary-key-start": node.key, ...nonceAttributes(ctx) }), text, createElement("script", { "data-dictionary-key-end": node.key, ...nonceAttributes(ctx) }));
81
+ }
82
+ case "editorFields": {
83
+ if (!ctx.renderContext.isEditing)
84
+ return null;
85
+ const own = (names) => names.filter((n) => fieldByName(ctx, n) !== undefined);
86
+ const props = { "data-editor-group": node.group, ...nonceAttributes(ctx), key };
87
+ const add = own(node.add);
88
+ const remove = own(node.remove);
89
+ if (add.length)
90
+ props["data-add-fields"] = add.join(",");
91
+ if (remove.length)
92
+ props["data-remove-fields"] = remove.join(",");
93
+ return createElement("script", props);
94
+ }
95
+ case "island": {
96
+ // The server code's children render once, here; the first paint and the loaded component
97
+ // both place them.
98
+ const children = createElement(Fragment, null, ...convertChildren(node.children, ctx, islandChildren));
99
+ const firstPaint = createElement(Fragment, null, ...convertChildren(node.firstPaint, ctx, children));
100
+ if (!ctx.client)
101
+ return createElement(Fragment, { key }, firstPaint);
102
+ return createElement(trusted.ClientIsland, { key, src: ctx.client.src, exportName: node.name, props: node.props, firstPaint, mode: ctx.client.mode }, children);
103
+ }
104
+ case "islandChildren":
105
+ return createElement(Fragment, { key }, islandChildren ?? null);
106
+ default:
107
+ return null;
108
+ }
109
+ }
110
+ export function convertTree(nodes, ctx) {
111
+ return createElement(Fragment, null, ...convertChildren(nodes, ctx));
112
+ }
@@ -0,0 +1,3 @@
1
+ import "server-only";
2
+ import type { TrustedComponents } from "./to-react.js";
3
+ export declare const defaultTrustedComponents: TrustedComponents;
@@ -0,0 +1,31 @@
1
+ import "server-only";
2
+ import { createElement } from "react";
3
+ import { EditorFields, Link, Picture, Placeholder, Text } from "@alpaca-headless/alpaca-headless";
4
+ import { icons } from "lucide-react";
5
+ import { DynamicIsland } from "../client/DynamicIsland.js";
6
+ import { RichText } from "./RichText.js";
7
+ // The components render-tree nodes become: the real SDK field components (they emit the
8
+ // host's own editor markers), the sanitizing RichText, lucide icons by name and the loader of
9
+ // a rendering's client code. A head may override single entries in createRenderer.
10
+ function pascal(name) {
11
+ return name
12
+ .split(/[-_\s]+/)
13
+ .filter(Boolean)
14
+ .map((part) => part[0].toUpperCase() + part.slice(1))
15
+ .join("");
16
+ }
17
+ export const defaultTrustedComponents = {
18
+ Text,
19
+ RichText,
20
+ Picture,
21
+ Link,
22
+ Placeholder,
23
+ EditorFields,
24
+ Icon: (name, props) => {
25
+ const icon = icons[pascal(name)];
26
+ if (!icon)
27
+ return null;
28
+ return createElement(icon, { ...props, "aria-hidden": true });
29
+ },
30
+ ClientIsland: DynamicIsland,
31
+ };
@@ -0,0 +1 @@
1
+ export { sanitizeRichTextHtml, sanitizeRichTextValue } from "./sanitizeRichText.js";
@@ -0,0 +1 @@
1
+ export { sanitizeRichTextHtml, sanitizeRichTextValue } from "./sanitizeRichText.js";
@@ -0,0 +1,4 @@
1
+ /** Filters rich-text HTML down to the formatting markup above. */
2
+ export declare function sanitizeRichTextHtml(html: string): string;
3
+ /** Same for a field value, which may be missing or not a string. */
4
+ export declare function sanitizeRichTextValue<T>(value: T): T | string;