@alpaca-headless/dynamic-renderings 1.0.4695 → 1.0.4697

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 CHANGED
@@ -4,7 +4,8 @@ Optional dynamic renderings for Alpaca Headless: React components stored as
4
4
  source on Sitecore items, compiled and run by the host itself through
5
5
  `@alpaca-headless/dynamic-renderings-engine` (compile threads and QuickJS render threads inside the
6
6
  head's process), and turned back into React. Design and threat model:
7
- `Headless/dynamic-renderings.md` in the Alpaca repository. No Parhelia installation is required.
7
+ `Headless/dynamic-renderings.md` in the Alpaca repository. No Parhelia installation is required; a head
8
+ the Parhelia editor serves adds the `/parhelia` subpaths (see below).
8
9
 
9
10
  ## Requirements
10
11
 
@@ -99,9 +100,11 @@ Whether the browser also loads the real component is decided per page:
99
100
 
100
101
  | Page | Client code |
101
102
  | --- | --- |
102
- | edit mode | never: the first paint only |
103
103
  | a sealed request (`requestAuth` recognised it) | every island loads, inside an opaque-origin frame only |
104
- | anything else | islands of renderings the CM reports as `approved` |
104
+ | anything else, edit mode included | islands of renderings the CM reports as `approved` |
105
+
106
+ A loaded component always gets `editing: false`, in the edit canvas too; the first paint renders
107
+ with `editing: true`.
105
108
 
106
109
  A bundle URL is `{prefix}/api/alpaca/renderings/client/{clientHash}-{compiledFormat}.js?id={renderingId}&database={db}`
107
110
  (plus `&page={pageId}` when sealed), where `clientHash` is the first 16 hex digits of the SHA-256
@@ -125,6 +128,30 @@ Editor integrations configure the rest in `defineDynamicRenderings`:
125
128
  - `editor.clientCodeHintAttribute`: in preview mode, `dynamic.head` lists the renderings with
126
129
  client code in `<script type="application/json" {attribute}="{id},...">`.
127
130
 
131
+ ## Heads the Parhelia editor serves
132
+
133
+ Everything Parhelia-specific lives under `/parhelia`: the editor's markers, the CM's proxy
134
+ prefixes (`/parhelia/proxy/{name}/...`, `/parhelia/preview/{token}/{name}/...`) and the sealed
135
+ "Run client code" preview (its token as the request's identity, and its media). Use its
136
+ `defineDynamicRenderings` in place of the one from `/server`, and its `withDynamicRenderings`:
137
+
138
+ ```ts
139
+ // src/dynamic.config.ts
140
+ import { defineDynamicRenderings } from "@alpaca-headless/dynamic-renderings/parhelia";
141
+
142
+ // src/app/api/parhelia/media/[code]/[...path]/route.ts
143
+ import { previewMediaRoute } from "@alpaca-headless/dynamic-renderings/parhelia/next";
144
+ export const { GET } = previewMediaRoute(dynamicConfig);
145
+ ```
146
+
147
+ ```js
148
+ // next.config.js
149
+ const { withDynamicRenderings } = require("@alpaca-headless/dynamic-renderings/parhelia/next/config");
150
+ ```
151
+
152
+ Everything else (`readRequest`, `buildRoute`, `clientRoute`, `createRenderer`) comes from the
153
+ subpaths below as in any head.
154
+
128
155
  ## Subpaths
129
156
 
130
157
  | Subpath | Contents | Loads in |
@@ -139,6 +166,9 @@ Editor integrations configure the rest in `defineDynamicRenderings`:
139
166
  | `/handlers/rewrites` | the CORS exclusion source and the bundle route rewrites | plain Node |
140
167
  | `/next` | `readRequest`, `requestIdentity`, `buildRoute`, `clientRoute`, `nextThemeFile` | Next.js server |
141
168
  | `/next/config` | `withDynamicRenderings` (CommonJS and ESM) | plain Node |
169
+ | `/parhelia` | the Parhelia editor preset: `defineDynamicRenderings`, the preview token's `requestAuth`, `rewriteMediaUrls`, `splitProxyPrefix`, `handlePreviewMedia` | server |
170
+ | `/parhelia/next` | `previewMediaRoute` | Next.js server |
171
+ | `/parhelia/next/config` | `withDynamicRenderings` with the Parhelia CM's proxy prefixes and the preview media rewrite | plain Node |
142
172
 
143
173
  ## Versions
144
174
 
@@ -3,9 +3,11 @@ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
3
3
  // A component of a rendering's client code. It always shows the first paint the engine rendered
4
4
  // (static, sanitized) and replaces it with the real component once the browser bundle loaded.
5
5
  // The server passes this only where client code may run: `approved` for a rendering an
6
- // administrator approved (never in the edit canvas), `sealed` for an editor's isolated preview.
6
+ // administrator approved (the edit canvas included), `sealed` for an editor's isolated preview.
7
7
  // A sealed island also checks that it really runs in an opaque-origin document, where its code
8
- // cannot reach the editor, a CM response or any cookie.
8
+ // cannot reach the editor, a CM response or any cookie. The loaded component always gets
9
+ // `editing: false` and runs as on the live page, in the edit canvas too; `editing: true` is the
10
+ // first paint's (a static render).
9
11
  import { Component, useEffect, useState } from "react";
10
12
  class IslandBoundary extends Component {
11
13
  state = { failed: false };
@@ -0,0 +1,22 @@
1
+ import { type DynamicRenderingsConfig, type DynamicRenderingsOptions } from "../server/config.js";
2
+ export { splitProxyPrefix } from "./prefix.js";
3
+ export { rewriteMediaUrls } from "./preview-media.js";
4
+ export { handlePreviewMedia } from "./media.js";
5
+ /** The header the CM's preview proxy adds to every request of the sealed preview frame. */
6
+ export declare const PREVIEW_TOKEN_HEADER = "x-parhelia-preview-token";
7
+ /**
8
+ * The sealed preview ("Run client code"): a request carrying the preview token reads the CM as the
9
+ * token's subject and nothing else. The proxy strips every cookie; the token is the whole identity.
10
+ */
11
+ export declare function parheliaRequestAuth(headers: Headers): {
12
+ headers: {
13
+ "X-Parhelia-Preview-Token": string;
14
+ };
15
+ } | null;
16
+ /**
17
+ * defineDynamicRenderings as the Parhelia editor needs it: its nonce and slot markers, the
18
+ * client-code hint for the preview strip, pages served through the CM's proxies
19
+ * (/parhelia/proxy/{name}/..., /parhelia/preview/{token}/{name}/...), and the sealed preview's
20
+ * token identity and media URLs. Options given here win.
21
+ */
22
+ export declare function defineDynamicRenderings(options: DynamicRenderingsOptions): DynamicRenderingsConfig;
@@ -0,0 +1,42 @@
1
+ // Dynamic renderings in a head the Parhelia CM and editor serve. Everything Parhelia-specific about
2
+ // the runtime is here: the editor's markers, the CM's proxy prefixes and the sealed "Run client
3
+ // code" preview. The rest of the package knows no editor.
4
+ import { defineDynamicRenderings as defineAlpacaDynamicRenderings } from "../server/config.js";
5
+ import { splitProxyPrefix } from "./prefix.js";
6
+ import { rewriteMediaUrls } from "./preview-media.js";
7
+ export { splitProxyPrefix } from "./prefix.js";
8
+ export { rewriteMediaUrls } from "./preview-media.js";
9
+ export { handlePreviewMedia } from "./media.js";
10
+ /** The header the CM's preview proxy adds to every request of the sealed preview frame. */
11
+ export const PREVIEW_TOKEN_HEADER = "x-parhelia-preview-token";
12
+ /**
13
+ * The sealed preview ("Run client code"): a request carrying the preview token reads the CM as the
14
+ * token's subject and nothing else. The proxy strips every cookie; the token is the whole identity.
15
+ */
16
+ export function parheliaRequestAuth(headers) {
17
+ const token = headers.get(PREVIEW_TOKEN_HEADER);
18
+ return token ? { headers: { "X-Parhelia-Preview-Token": token } } : null;
19
+ }
20
+ /**
21
+ * defineDynamicRenderings as the Parhelia editor needs it: its nonce and slot markers, the
22
+ * client-code hint for the preview strip, pages served through the CM's proxies
23
+ * (/parhelia/proxy/{name}/..., /parhelia/preview/{token}/{name}/...), and the sealed preview's
24
+ * token identity and media URLs. Options given here win.
25
+ */
26
+ export function defineDynamicRenderings(options) {
27
+ return defineAlpacaDynamicRenderings({
28
+ editor: { nonceAttribute: "data-parhelia-nonce", slotAttribute: "data-parhelia-slot", clientCodeHintAttribute: "data-parhelia-has-client-code" },
29
+ // The editor appends ?parhelia=1 in both edit and preview mode, so edit mode is only forced
30
+ // when the requested mode is not explicitly preview or normal. A sealed request is never an
31
+ // editor frame; readRequest overrides this for it.
32
+ requestAdapter(path, search) {
33
+ const editorFrame = search.parhelia === "1";
34
+ return { ...splitProxyPrefix(path), editorFrame, editing: editorFrame && search.mode !== "preview" && search.mode !== "normal" };
35
+ },
36
+ requestAuth: parheliaRequestAuth,
37
+ // The sealed frame sends no cookies, so its media URLs go through the head's media route,
38
+ // which reads them from the CM with the preview token.
39
+ transformPage: (page, { sealed, prefix }) => (sealed ? rewriteMediaUrls(page, prefix) : page),
40
+ ...options,
41
+ });
42
+ }
@@ -0,0 +1,3 @@
1
+ import type { DynamicRenderingsConfig } from "../server/config.js";
2
+ /** GET /api/parhelia/media/{code}/{...path}: one media file of the sealed preview. */
3
+ export declare function handlePreviewMedia(config: DynamicRenderingsConfig, request: Request, code: string, path: string[]): Promise<Response>;
@@ -0,0 +1,37 @@
1
+ // Media for the sealed preview. That frame has an opaque origin and sends no cookies, so its
2
+ // page's media URLs point here (see server/preview-media.ts), and this route reads the original
3
+ // media path from the CM with the preview token. The CM serves it as the token's subject with the
4
+ // media item's own access rules. Reached only through the CM's preview proxy, which adds the token
5
+ // header; without it the route answers 404.
6
+ const PREFIXES = {
7
+ s: "/sitecore/shell/-/media/",
8
+ m: "/-/media/",
9
+ j: "/-/jssmedia/",
10
+ };
11
+ const PASS_HEADERS = ["content-type", "content-length", "content-range", "accept-ranges", "last-modified", "etag"];
12
+ /** GET /api/parhelia/media/{code}/{...path}: one media file of the sealed preview. */
13
+ export async function handlePreviewMedia(config, request, code, path) {
14
+ const auth = config.requestAuth?.(request.headers) ?? null;
15
+ const prefix = PREFIXES[code];
16
+ if (!auth || !prefix || !path?.length || path.some((segment) => !segment || segment === "." || segment === ".." || /[\\\0]/.test(segment))) {
17
+ return new Response(null, { status: 404 });
18
+ }
19
+ const headers = new Headers(auth.headers);
20
+ const range = request.headers.get("range");
21
+ if (range)
22
+ headers.set("range", range);
23
+ const search = new URL(request.url).search;
24
+ // Redirects (a login page) are failures, never followed.
25
+ const upstream = await fetch(config.layoutServiceUrl() + prefix + path.map(encodeURIComponent).join("/") + search, { headers, redirect: "manual", cache: "no-store" });
26
+ const type = upstream.headers.get("content-type") || "";
27
+ if ((upstream.status !== 200 && upstream.status !== 206) || type.includes("text/html")) {
28
+ return new Response(null, { status: upstream.status === 401 || upstream.status === 403 ? upstream.status : 404 });
29
+ }
30
+ const out = new Headers({ "cache-control": "private, max-age=300", "x-content-type-options": "nosniff" });
31
+ for (const name of PASS_HEADERS) {
32
+ const value = upstream.headers.get(name);
33
+ if (value)
34
+ out.set(name, value);
35
+ }
36
+ return new Response(upstream.body, { status: upstream.status, headers: out });
37
+ }
@@ -0,0 +1,10 @@
1
+ import type { DynamicRenderingsConfig } from "../server/config.js";
2
+ /** src/app/api/parhelia/media/[code]/[...path]/route.ts */
3
+ export declare function previewMediaRoute(config: DynamicRenderingsConfig): {
4
+ GET: (request: Request, { params }: {
5
+ params: Promise<{
6
+ code: string;
7
+ path: string[];
8
+ }>;
9
+ }) => Promise<Response>;
10
+ };
@@ -0,0 +1,10 @@
1
+ import { handlePreviewMedia } from "./media.js";
2
+ /** src/app/api/parhelia/media/[code]/[...path]/route.ts */
3
+ export function previewMediaRoute(config) {
4
+ return {
5
+ GET: async (request, { params }) => {
6
+ const { code, path } = await params;
7
+ return handlePreviewMedia(config, request, code, path);
8
+ },
9
+ };
10
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The Parhelia CM's proxies are prefix-preserving: editor requests arrive as
3
+ * /parhelia/proxy/{name}/{route}, the sealed preview's as /parhelia/preview/{token}/{name}/{route}.
4
+ * Editor requests carry sc_itemid and resolve by item ID regardless of path; stripping the prefix
5
+ * also makes bare navigation under the proxy resolve the right route.
6
+ */
7
+ export declare function splitProxyPrefix(path: string[] | undefined): {
8
+ route: string | string[];
9
+ prefix: string;
10
+ };
@@ -0,0 +1,21 @@
1
+ // Pure helper (no Next or SDK imports), shared by the Parhelia preset and tests.
2
+ /**
3
+ * The Parhelia CM's proxies are prefix-preserving: editor requests arrive as
4
+ * /parhelia/proxy/{name}/{route}, the sealed preview's as /parhelia/preview/{token}/{name}/{route}.
5
+ * Editor requests carry sc_itemid and resolve by item ID regardless of path; stripping the prefix
6
+ * also makes bare navigation under the proxy resolve the right route.
7
+ */
8
+ export function splitProxyPrefix(path) {
9
+ const routePath = path ?? [];
10
+ const head = routePath[0]?.toLowerCase();
11
+ const kind = routePath[1]?.toLowerCase();
12
+ const prefixLength = head === "parhelia" ? (kind === "proxy" ? 3 : kind === "preview" ? 4 : 0) : 0;
13
+ if (prefixLength && routePath.length >= prefixLength) {
14
+ const strippedPath = routePath.slice(prefixLength);
15
+ return {
16
+ route: strippedPath.length > 0 ? strippedPath : "/",
17
+ prefix: "/" + routePath.slice(0, prefixLength).map(encodeURIComponent).join("/"),
18
+ };
19
+ }
20
+ return { route: routePath.length > 0 ? routePath : "/", prefix: "" };
21
+ }
@@ -0,0 +1 @@
1
+ export declare function rewriteMediaUrls<T>(value: T, prefix: string): T;
@@ -0,0 +1,24 @@
1
+ // The sandboxed interactive preview has an opaque origin and sends no cookies, so a master
2
+ // media URL (/sitecore/shell/-/media/...) would reach the CM anonymously and end on the login
3
+ // page. In that mode every root-relative media URL in the page data is moved under the preview
4
+ // prefix; the CM's preview proxy passes it to this host, and the media route
5
+ // (the head's route src/app/api/parhelia/media, previewMediaRoute) reads the original path from the CM with the preview token, so
6
+ // the request reads as the token's subject. The query, including the media hash, is untouched.
7
+ //
8
+ // The moved URL must not contain "-/media/": Sitecore treats any request path containing a media
9
+ // trigger as a media request, so the original prefix is encoded as a short letter instead
10
+ // (s = /sitecore/shell/-/media/, m = /-/media/, j = /-/jssmedia/).
11
+ const MEDIA_URL = /(?<![\w/.:-])\/(sitecore\/shell\/-\/media\/|-\/media\/|-\/jssmedia\/)/g;
12
+ const CODES = {
13
+ "sitecore/shell/-/media/": "s",
14
+ "-/media/": "m",
15
+ "-/jssmedia/": "j",
16
+ };
17
+ export function rewriteMediaUrls(value, prefix) {
18
+ if (!prefix || value === null || value === undefined)
19
+ return value;
20
+ const json = JSON.stringify(value);
21
+ if (!json.includes("-/media/") && !json.includes("-/jssmedia/"))
22
+ return value;
23
+ return JSON.parse(json.replace(MEDIA_URL, (_match, kind) => `${prefix}/_media/${CODES[kind]}/`));
24
+ }
@@ -22,7 +22,10 @@ export declare function ErrorBox({ title, detail }: {
22
22
  * which holds a token, it must fit the CM's URL length limit; the query has its own.
23
23
  */
24
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. */
25
+ /**
26
+ * Where this rendering's client code may run on this page, if anywhere: approved code everywhere,
27
+ * the edit canvas included (the approval is the review), and any code in the sealed preview.
28
+ */
26
29
  export declare function clientLoading(state: DynamicPageState, prepared: PreparedComponent, editing: boolean): ConvertContext["client"];
27
30
  /**
28
31
  * Returns the component the SDK's Placeholder mounts for every component whose layout JSON
@@ -33,11 +33,14 @@ export function clientBundleUrl(state, renderingId, clientHash) {
33
33
  query.set("page", state.pageId);
34
34
  return `${state.prefix}/api/alpaca/renderings/client/${clientHash}-${protocol.compiledFormat}.js?${query}`;
35
35
  }
36
- /** Where this rendering's client code may run on this page, if anywhere. */
36
+ /**
37
+ * Where this rendering's client code may run on this page, if anywhere: approved code everywhere,
38
+ * the edit canvas included (the approval is the review), and any code in the sealed preview.
39
+ */
37
40
  export function clientLoading(state, prepared, editing) {
38
- if (editing || state.clientMode === "edit" || !prepared.clientHash)
41
+ if (!prepared.clientHash)
39
42
  return undefined;
40
- if (state.clientMode === "sealed")
43
+ if (state.clientMode === "sealed" && !editing)
41
44
  return { mode: "sealed", src: clientBundleUrl(state, prepared.renderingId, prepared.clientHash) };
42
45
  return prepared.approved ? { mode: "approved", src: clientBundleUrl(state, prepared.renderingId, prepared.clientHash) } : undefined;
43
46
  }
@@ -39,10 +39,10 @@ export type PreparedTheme = {
39
39
  rejected?: string;
40
40
  };
41
41
  /**
42
- * Which client code loads in the browser: none in edit mode (first paints only), all of it in a
43
- * sealed request, and otherwise only that of approved renderings.
42
+ * Which client code loads in the browser: all of it in a sealed request (never in edit mode), and
43
+ * otherwise only that of approved renderings, the edit canvas included.
44
44
  */
45
- export type ClientMode = "edit" | "sealed" | "approved";
45
+ export type ClientMode = "sealed" | "approved";
46
46
  export type DynamicPageState = {
47
47
  nonce: string;
48
48
  mode: string;
@@ -16,7 +16,7 @@ export function emptyDynamicPage(mode, prefix = "", { sealed = false, pageId = "
16
16
  editing,
17
17
  preview: mode === "preview",
18
18
  sealed,
19
- clientMode: editing ? "edit" : sealed ? "sealed" : "approved",
19
+ clientMode: sealed && !editing ? "sealed" : "approved",
20
20
  components: new Map(),
21
21
  prefix,
22
22
  pageId,
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ // next.config.js wrapper for heads the Parhelia CM serves: withDynamicRenderings with the CM's
3
+ // proxy prefixes, plus the sealed preview's media rewrite. Plain CommonJS, like index.cjs: it loads
4
+ // in ordinary Node.
5
+ const alpaca = require("./index.cjs");
6
+
7
+ /** The editor proxy and the sealed preview proxy; bundle URLs under them reach the bundle route. */
8
+ const ROUTE_PREFIXES = ["/parhelia/proxy/:host", "/parhelia/preview/:token/:host"];
9
+
10
+ /** A sealed preview page's media URLs (see rewriteMediaUrls) go to the head's media route. */
11
+ const PREVIEW_MEDIA_REWRITE = {
12
+ source: "/parhelia/preview/:token/:host/_media/:code/:path*",
13
+ destination: "/api/parhelia/media/:code/:path*",
14
+ };
15
+
16
+ /**
17
+ * @param {import("next").NextConfig} nextConfig
18
+ * @param {{ themeFile?: string, routePrefixes?: string[] }} [options] as withDynamicRenderings';
19
+ * routePrefixes adds to the Parhelia prefixes.
20
+ */
21
+ function withDynamicRenderings(nextConfig = {}, options = {}) {
22
+ const own = nextConfig.rewrites;
23
+ return alpaca.withDynamicRenderings(
24
+ {
25
+ ...nextConfig,
26
+ async rewrites() {
27
+ const rules = typeof own === "function" ? await own() : own;
28
+ return alpaca.prependRewrites(rules, [PREVIEW_MEDIA_REWRITE]);
29
+ },
30
+ },
31
+ { ...options, routePrefixes: [...ROUTE_PREFIXES, ...(options.routePrefixes || [])] },
32
+ );
33
+ }
34
+
35
+ module.exports = { ...alpaca, withDynamicRenderings, ROUTE_PREFIXES, PREVIEW_MEDIA_REWRITE };
@@ -0,0 +1,14 @@
1
+ import type { NextConfig } from "next";
2
+ import type { DynamicRenderingsNextOptions } from "./index.cjs";
3
+
4
+ export { prependRewrites, corsExclusionSource, ENGINE_PACKAGE } from "./index.cjs";
5
+ export type { DynamicRenderingsNextOptions };
6
+
7
+ /**
8
+ * withDynamicRenderings for a head the Parhelia CM serves: the CM's proxy prefixes
9
+ * (/parhelia/proxy/:host, /parhelia/preview/:token/:host) and the sealed preview's media rewrite
10
+ * to /api/parhelia/media.
11
+ */
12
+ export declare function withDynamicRenderings(nextConfig?: NextConfig, options?: DynamicRenderingsNextOptions): NextConfig;
13
+ export declare const ROUTE_PREFIXES: readonly string[];
14
+ export declare const PREVIEW_MEDIA_REWRITE: { source: string; destination: string };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alpaca-headless/dynamic-renderings",
3
- "version": "1.0.4695",
3
+ "version": "1.0.4697",
4
4
  "description": "Optional Alpaca Headless dynamic renderings: source client, safe React rendering, client code and Next.js integration.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -47,6 +47,18 @@
47
47
  "types": "./next-config/index.d.cts",
48
48
  "default": "./next-config/index.cjs"
49
49
  },
50
+ "./parhelia": {
51
+ "types": "./dist/parhelia/index.d.ts",
52
+ "import": "./dist/parhelia/index.js"
53
+ },
54
+ "./parhelia/next": {
55
+ "types": "./dist/parhelia/next.d.ts",
56
+ "import": "./dist/parhelia/next.js"
57
+ },
58
+ "./parhelia/next/config": {
59
+ "types": "./next-config/parhelia.d.cts",
60
+ "default": "./next-config/parhelia.cjs"
61
+ },
50
62
  "./package.json": "./package.json"
51
63
  },
52
64
  "files": [
@@ -67,7 +79,7 @@
67
79
  "acceptance": "node scripts/acceptance.mjs"
68
80
  },
69
81
  "dependencies": {
70
- "@alpaca-headless/dynamic-renderings-engine": "1.0.4695",
82
+ "@alpaca-headless/dynamic-renderings-engine": "1.0.4697",
71
83
  "lucide-react": "^1.38.0",
72
84
  "postcss": "^8.5.26",
73
85
  "postcss-selector-parser": "^7.1.6",