@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.
- package/LICENSE +8 -0
- package/README.md +147 -0
- package/dist/client/ClientIslandRuntime.d.ts +1 -0
- package/dist/client/ClientIslandRuntime.js +12 -0
- package/dist/client/DynamicIsland.d.ts +12 -0
- package/dist/client/DynamicIsland.js +55 -0
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +4 -0
- package/dist/css-policy/css-policy.d.mts +1 -0
- package/dist/css-policy/css-policy.mjs +1 -0
- package/dist/handlers/build.d.ts +4 -0
- package/dist/handlers/build.js +99 -0
- package/dist/handlers/client.d.ts +6 -0
- package/dist/handlers/client.js +159 -0
- package/dist/handlers/index.d.ts +3 -0
- package/dist/handlers/index.js +6 -0
- package/dist/next/index.d.ts +5 -0
- package/dist/next/index.js +6 -0
- package/dist/next/prefix.d.ts +8 -0
- package/dist/next/prefix.js +25 -0
- package/dist/next/request.d.ts +31 -0
- package/dist/next/request.js +33 -0
- package/dist/next/routes.d.ts +16 -0
- package/dist/next/routes.js +15 -0
- package/dist/next/theme.d.ts +8 -0
- package/dist/next/theme.js +26 -0
- package/dist/protocol/index.d.ts +3 -0
- package/dist/protocol/index.js +2 -0
- package/dist/protocol/protocol.d.ts +2 -0
- package/dist/protocol/protocol.js +1 -0
- package/dist/protocol/types.d.ts +1 -0
- package/dist/protocol/types.js +1 -0
- package/dist/react/DynamicRendering.d.ts +32 -0
- package/dist/react/DynamicRendering.js +88 -0
- package/dist/react/DynamicTheme.d.ts +5 -0
- package/dist/react/DynamicTheme.js +13 -0
- package/dist/react/RichText.d.ts +7 -0
- package/dist/react/RichText.js +31 -0
- package/dist/react/index.d.ts +9 -0
- package/dist/react/index.js +8 -0
- package/dist/react/renderer.d.ts +45 -0
- package/dist/react/renderer.js +41 -0
- package/dist/react/to-react.d.ts +42 -0
- package/dist/react/to-react.js +112 -0
- package/dist/react/trusted.d.ts +3 -0
- package/dist/react/trusted.js +31 -0
- package/dist/rich-text/index.d.ts +1 -0
- package/dist/rich-text/index.js +1 -0
- package/dist/rich-text/sanitizeRichText.d.ts +4 -0
- package/dist/rich-text/sanitizeRichText.js +99 -0
- package/dist/server/backend.d.ts +25 -0
- package/dist/server/backend.js +36 -0
- package/dist/server/config.d.ts +98 -0
- package/dist/server/config.js +49 -0
- package/dist/server/engine.d.ts +83 -0
- package/dist/server/engine.js +207 -0
- package/dist/server/index.d.ts +12 -0
- package/dist/server/index.js +8 -0
- package/dist/server/page.d.ts +92 -0
- package/dist/server/page.js +235 -0
- package/dist/server/sanitize-tree.d.ts +28 -0
- package/dist/server/sanitize-tree.js +343 -0
- package/dist/server/theme.d.ts +14 -0
- package/dist/server/theme.js +38 -0
- package/next-config/index.cjs +101 -0
- package/next-config/index.d.cts +26 -0
- package/next-config/rewrites.cjs +25 -0
- package/next-config/rewrites.d.cts +3 -0
- package/package.json +108 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// The allowlist every render tree from the engine passes before it becomes React.
|
|
2
|
+
// Anything not listed here is dropped. The tree is rebuilt from scratch; no object from the
|
|
3
|
+
// engine's JSON is reused, so nothing can smuggle extra keys through.
|
|
4
|
+
//
|
|
5
|
+
// Two kinds of attributes are refused because the editor bridge reads them: the markers it
|
|
6
|
+
// builds page structure from (data-itemid, data-component-start, ...) and the class names it
|
|
7
|
+
// treats specially (.scpm chrome, .a-editor-mce). Only the host emits those, from the
|
|
8
|
+
// component JSON. test/dynamic-renderings.test.tsx extracts every such name from the
|
|
9
|
+
// bridge source and checks this denylist still covers it.
|
|
10
|
+
import { protocol } from "../protocol/protocol.js";
|
|
11
|
+
export const ALLOWED_TAGS = new Set([
|
|
12
|
+
"a", "abbr", "address", "article", "aside", "audio", "b", "bdi", "bdo", "blockquote", "br",
|
|
13
|
+
"button", "caption", "cite", "col", "colgroup", "data", "dd", "del", "details", "dfn", "div",
|
|
14
|
+
"dl", "dt", "em", "figcaption", "figure", "footer", "h1", "h2", "h3", "h4", "h5", "h6",
|
|
15
|
+
"header", "hgroup", "hr", "i", "img", "ins", "kbd", "li", "main", "mark", "nav", "ol", "p",
|
|
16
|
+
"picture", "pre", "q", "rp", "rt", "ruby", "s", "samp", "section", "small", "source", "span",
|
|
17
|
+
"strong", "sub", "summary", "sup", "table", "tbody", "td", "tfoot", "th", "thead", "time",
|
|
18
|
+
"tr", "track", "u", "ul", "var", "video", "wbr",
|
|
19
|
+
]);
|
|
20
|
+
const VOID_TAGS = new Set(["br", "col", "hr", "img", "source", "track", "wbr"]);
|
|
21
|
+
// Elements the host may render a text or rich-text field as. The SDK accepts any tag name.
|
|
22
|
+
export const TEXT_TAGS = new Set(["p", "span", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "dt", "dd", "figcaption", "blockquote", "strong", "em", "small", "time"]);
|
|
23
|
+
export const RICH_TEXT_TAGS = new Set(["div", "section", "article"]);
|
|
24
|
+
const GLOBAL_PROPS = new Set(["className", "id", "title", "lang", "dir", "role", "hidden", "tabIndex", "style"]);
|
|
25
|
+
const TAG_PROPS = {
|
|
26
|
+
a: ["href", "target", "rel"],
|
|
27
|
+
img: ["src", "srcSet", "sizes", "alt", "width", "height", "loading", "decoding"],
|
|
28
|
+
source: ["src", "srcSet", "type", "media", "sizes"],
|
|
29
|
+
video: ["src", "poster", "controls", "muted", "loop", "autoPlay", "playsInline", "preload", "width", "height"],
|
|
30
|
+
audio: ["src", "controls", "muted", "loop", "autoPlay", "preload"],
|
|
31
|
+
track: ["src", "kind", "srcLang", "label", "default"],
|
|
32
|
+
td: ["colSpan", "rowSpan", "headers"],
|
|
33
|
+
th: ["colSpan", "rowSpan", "scope", "headers"],
|
|
34
|
+
time: ["dateTime"],
|
|
35
|
+
details: ["open"],
|
|
36
|
+
button: ["type", "disabled"],
|
|
37
|
+
ol: ["start", "reversed", "type"],
|
|
38
|
+
li: ["value"],
|
|
39
|
+
q: ["cite"],
|
|
40
|
+
blockquote: ["cite"],
|
|
41
|
+
del: ["cite"],
|
|
42
|
+
ins: ["cite"],
|
|
43
|
+
data: ["value"],
|
|
44
|
+
col: ["span"],
|
|
45
|
+
colgroup: ["span"],
|
|
46
|
+
};
|
|
47
|
+
// Attributes whose value is an id reference; ids are prefixed per rendering so a rendering
|
|
48
|
+
// can never claim an id the page or the bridge relies on.
|
|
49
|
+
const ID_REFERENCE_PROPS = new Set(["aria-labelledby", "aria-describedby", "aria-controls", "aria-owns", "aria-activedescendant", "headers"]);
|
|
50
|
+
const URL_PROPS = new Set(["href", "src", "srcSet", "poster", "cite"]);
|
|
51
|
+
/** data-* attributes the editor bridge (or the host itself) reads. */
|
|
52
|
+
export const DENIED_DATA_ATTRIBUTE = new RegExp("^data-(" +
|
|
53
|
+
[
|
|
54
|
+
"itemid", "item-id", "fieldid", "fieldname", "language", "version", "database",
|
|
55
|
+
"component.*", "rendering.*", "renderingid", "placeholder.*", "dictionary-.*",
|
|
56
|
+
"editor-.*", "add-fields", "remove-fields", "is-rich.*", "parhelia.*", "typeid",
|
|
57
|
+
"layoutid", "editable", "variant", "target.*", "orientation", "description",
|
|
58
|
+
"parameters.*", "shared-.*", "has-.*", "type", "skip-.*", "island", "internal",
|
|
59
|
+
"querystring", "paste-.*",
|
|
60
|
+
].join("|") +
|
|
61
|
+
")$", "i");
|
|
62
|
+
/** Class names the bridge or Sitecore chrome react to. */
|
|
63
|
+
export function isDeniedClass(name) {
|
|
64
|
+
return /^sc[A-Z]/.test(name) || /^scpm$/i.test(name) || /^(a-editor|parhelia|alpaca)/i.test(name);
|
|
65
|
+
}
|
|
66
|
+
const ALLOWED_STYLE_FUNCTIONS = new Set([
|
|
67
|
+
"var", "calc", "min", "max", "clamp", "rgb", "rgba", "hsl", "hsla", "oklch", "oklab", "color-mix",
|
|
68
|
+
"linear-gradient", "radial-gradient", "conic-gradient", "translate", "translatex", "translatey",
|
|
69
|
+
"rotate", "scale", "scalex", "scaley", "skew", "skewx", "skewy", "repeat", "minmax", "cubic-bezier",
|
|
70
|
+
]);
|
|
71
|
+
function safeUrl(value, prop, ctx) {
|
|
72
|
+
if (typeof value !== "string")
|
|
73
|
+
return undefined;
|
|
74
|
+
const trimmed = value.trim();
|
|
75
|
+
if (!trimmed || trimmed.length > 4096)
|
|
76
|
+
return undefined;
|
|
77
|
+
if (prop === "srcSet") {
|
|
78
|
+
const parts = trimmed.split(",").map((part) => {
|
|
79
|
+
const [url, ...descriptor] = part.trim().split(/\s+/);
|
|
80
|
+
const ok = safeUrl(url, "src", ctx);
|
|
81
|
+
return ok ? [ok, ...descriptor].join(" ") : undefined;
|
|
82
|
+
});
|
|
83
|
+
return parts.every(Boolean) ? parts.join(", ") : undefined;
|
|
84
|
+
}
|
|
85
|
+
if (prop === "href") {
|
|
86
|
+
// Relative links come from link fields (built by the host), never from element props.
|
|
87
|
+
return /^(https?:|mailto:|tel:)/i.test(trimmed) ? trimmed : undefined;
|
|
88
|
+
}
|
|
89
|
+
// src, poster, cite: media paths or configured media hosts only. Nothing ever points at the
|
|
90
|
+
// CM origin, where an image request would carry the editor's cookies.
|
|
91
|
+
if (/^\/-\/(media|jssmedia)\//i.test(trimmed) && !trimmed.includes(".."))
|
|
92
|
+
return trimmed;
|
|
93
|
+
try {
|
|
94
|
+
const url = new URL(trimmed);
|
|
95
|
+
if (url.protocol === "https:" && ctx.mediaHosts.includes(url.host.toLowerCase()))
|
|
96
|
+
return url.toString();
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// not an absolute URL
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
function safeStyle(value) {
|
|
104
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
105
|
+
return undefined;
|
|
106
|
+
const out = {};
|
|
107
|
+
for (const [prop, raw] of Object.entries(value)) {
|
|
108
|
+
if (!/^(--[A-Za-z0-9-]+|[a-zA-Z]+)$/.test(prop))
|
|
109
|
+
continue;
|
|
110
|
+
if (typeof raw === "number") {
|
|
111
|
+
if (Number.isFinite(raw))
|
|
112
|
+
out[prop] = raw;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (typeof raw !== "string" || raw.length > 500)
|
|
116
|
+
continue;
|
|
117
|
+
if (/[\\<@]|\/\*/.test(raw))
|
|
118
|
+
continue;
|
|
119
|
+
let ok = true;
|
|
120
|
+
for (const match of raw.matchAll(/([a-zA-Z-]*)\s*\(/g)) {
|
|
121
|
+
if (!ALLOWED_STYLE_FUNCTIONS.has((match[1] ?? "").toLowerCase())) {
|
|
122
|
+
ok = false;
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (ok)
|
|
127
|
+
out[prop] = raw;
|
|
128
|
+
}
|
|
129
|
+
return Object.keys(out).length ? out : undefined;
|
|
130
|
+
}
|
|
131
|
+
function prefixId(value, ctx) {
|
|
132
|
+
return value
|
|
133
|
+
.split(/\s+/)
|
|
134
|
+
.filter((v) => /^[A-Za-z][\w-]{0,99}$/.test(v))
|
|
135
|
+
.map((v) => `${ctx.idPrefix}${v}`)
|
|
136
|
+
.join(" ");
|
|
137
|
+
}
|
|
138
|
+
export function sanitizeProps(tag, props, ctx) {
|
|
139
|
+
const out = {};
|
|
140
|
+
if (!props || typeof props !== "object")
|
|
141
|
+
return out;
|
|
142
|
+
const allowed = TAG_PROPS[tag] || [];
|
|
143
|
+
for (const [name, value] of Object.entries(props)) {
|
|
144
|
+
if (name === "__parheliaComponentAttrs") {
|
|
145
|
+
if (value === true)
|
|
146
|
+
out.__parheliaComponentAttrs = true;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (value === null || value === undefined)
|
|
150
|
+
continue;
|
|
151
|
+
const isAria = /^aria-[a-z]+$/.test(name);
|
|
152
|
+
const isData = /^data-[a-z0-9-]+$/.test(name) && !DENIED_DATA_ATTRIBUTE.test(name);
|
|
153
|
+
if (!GLOBAL_PROPS.has(name) && !allowed.includes(name) && !isAria && !isData)
|
|
154
|
+
continue;
|
|
155
|
+
if (name === "style") {
|
|
156
|
+
const style = safeStyle(value);
|
|
157
|
+
if (style)
|
|
158
|
+
out.style = style;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (typeof value === "object" || typeof value === "function")
|
|
162
|
+
continue;
|
|
163
|
+
if (URL_PROPS.has(name)) {
|
|
164
|
+
const url = safeUrl(value, name, ctx);
|
|
165
|
+
if (url)
|
|
166
|
+
out[name] = url;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (name === "className") {
|
|
170
|
+
if (typeof value !== "string")
|
|
171
|
+
continue;
|
|
172
|
+
const classes = value.split(/\s+/).filter((c) => c && c.length < 200 && !isDeniedClass(c));
|
|
173
|
+
if (classes.length)
|
|
174
|
+
out.className = classes.join(" ");
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (name === "id") {
|
|
178
|
+
if (typeof value === "string" && /^[A-Za-z][\w-]{0,99}$/.test(value))
|
|
179
|
+
out.id = `${ctx.idPrefix}${value}`;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (ID_REFERENCE_PROPS.has(name)) {
|
|
183
|
+
if (typeof value === "string") {
|
|
184
|
+
const ids = prefixId(value, ctx);
|
|
185
|
+
if (ids)
|
|
186
|
+
out[name] = ids;
|
|
187
|
+
}
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (name === "tabIndex") {
|
|
191
|
+
if (value === 0 || value === -1 || value === "0" || value === "-1")
|
|
192
|
+
out.tabIndex = Number(value);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (name === "target") {
|
|
196
|
+
if (value === "_blank")
|
|
197
|
+
out.target = "_blank";
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (name === "type" && tag === "button") {
|
|
201
|
+
out.type = "button";
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (typeof value === "string" && value.length > 2000)
|
|
205
|
+
continue;
|
|
206
|
+
out[name] = value;
|
|
207
|
+
}
|
|
208
|
+
if (tag === "a" && out.target === "_blank")
|
|
209
|
+
out.rel = "noopener noreferrer";
|
|
210
|
+
if (tag === "button")
|
|
211
|
+
out.type = "button";
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
function sanitizeNode(node, ctx, depth, budget) {
|
|
215
|
+
if (typeof node === "string")
|
|
216
|
+
return node.length > 100_000 ? node.slice(0, 100_000) : node;
|
|
217
|
+
if (!node || typeof node !== "object" || Array.isArray(node))
|
|
218
|
+
return null;
|
|
219
|
+
budget.nodes += 1;
|
|
220
|
+
if (budget.nodes > ctx.limits.treeNodes || depth > ctx.limits.treeDepth) {
|
|
221
|
+
throw new Error("The render tree is too large.");
|
|
222
|
+
}
|
|
223
|
+
const n = node;
|
|
224
|
+
const children = (value) => sanitizeChildren(value, ctx, depth + 1, budget);
|
|
225
|
+
switch (n.t) {
|
|
226
|
+
case "el": {
|
|
227
|
+
const tag = typeof n.tag === "string" ? n.tag.toLowerCase() : "";
|
|
228
|
+
if (!ALLOWED_TAGS.has(tag))
|
|
229
|
+
return null;
|
|
230
|
+
return { t: "el", tag, props: sanitizeProps(tag, n.props, ctx), children: VOID_TAGS.has(tag) ? [] : children(n.children) };
|
|
231
|
+
}
|
|
232
|
+
case "field": {
|
|
233
|
+
if (typeof n.field !== "string" || !["text", "richtext", "picture", "link"].includes(n.kind))
|
|
234
|
+
return null;
|
|
235
|
+
const kind = n.kind;
|
|
236
|
+
const props = n.props && typeof n.props === "object" ? n.props : {};
|
|
237
|
+
const clean = {};
|
|
238
|
+
if (typeof props.className === "string") {
|
|
239
|
+
const classes = props.className.split(/\s+/).filter((c) => c && !isDeniedClass(c));
|
|
240
|
+
if (classes.length)
|
|
241
|
+
clean.className = classes.join(" ");
|
|
242
|
+
}
|
|
243
|
+
if (kind === "picture") {
|
|
244
|
+
if (typeof props.sizes === "string" && props.sizes.length < 300)
|
|
245
|
+
clean.sizes = props.sizes;
|
|
246
|
+
if (props.loading === "lazy" || props.loading === "eager")
|
|
247
|
+
clean.loading = props.loading;
|
|
248
|
+
}
|
|
249
|
+
const tagSet = kind === "richtext" ? RICH_TEXT_TAGS : kind === "text" ? TEXT_TAGS : null;
|
|
250
|
+
const tag = tagSet && typeof n.tag === "string" && tagSet.has(n.tag) ? n.tag : undefined;
|
|
251
|
+
return {
|
|
252
|
+
t: "field",
|
|
253
|
+
kind,
|
|
254
|
+
field: n.field,
|
|
255
|
+
...(tag ? { tag } : {}),
|
|
256
|
+
props: clean,
|
|
257
|
+
...(kind === "link" ? { children: children(n.children) } : {}),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
case "slot":
|
|
261
|
+
return typeof n.name === "string" ? { t: "slot", name: n.name } : null;
|
|
262
|
+
case "icon": {
|
|
263
|
+
if (typeof n.name !== "string" || !/^[A-Za-z0-9-]{1,60}$/.test(n.name))
|
|
264
|
+
return null;
|
|
265
|
+
const props = (n.props || {});
|
|
266
|
+
const clean = {};
|
|
267
|
+
if (typeof props.className === "string")
|
|
268
|
+
clean.className = props.className.split(/\s+/).filter((c) => c && !isDeniedClass(c)).join(" ");
|
|
269
|
+
if (typeof props.size === "number" && props.size > 0 && props.size <= 256)
|
|
270
|
+
clean.size = props.size;
|
|
271
|
+
if (typeof props.strokeWidth === "number" && props.strokeWidth > 0 && props.strokeWidth <= 8)
|
|
272
|
+
clean.strokeWidth = props.strokeWidth;
|
|
273
|
+
return { t: "icon", name: n.name, props: clean };
|
|
274
|
+
}
|
|
275
|
+
case "translate":
|
|
276
|
+
return typeof n.key === "string" && n.key.length < 300 ? { t: "translate", key: n.key } : null;
|
|
277
|
+
case "editorFields": {
|
|
278
|
+
const names = (value) => (Array.isArray(value) ? value.filter((v) => typeof v === "string") : []);
|
|
279
|
+
return { t: "editorFields", group: typeof n.group === "string" ? n.group : "Content", add: names(n.add), remove: names(n.remove) };
|
|
280
|
+
}
|
|
281
|
+
case "island": {
|
|
282
|
+
// A component of the rendering's client code, by export name. Its props reach the browser
|
|
283
|
+
// as JSON; its first paint is markup like any other and passes the same rules.
|
|
284
|
+
if (typeof n.name !== "string" || !/^[A-Za-z_$][\w$]{0,99}$/.test(n.name))
|
|
285
|
+
return null;
|
|
286
|
+
const props = jsonOnly(n.props, 0);
|
|
287
|
+
return {
|
|
288
|
+
t: "island",
|
|
289
|
+
name: n.name,
|
|
290
|
+
props: props && typeof props === "object" && !Array.isArray(props) ? props : {},
|
|
291
|
+
children: children(n.children),
|
|
292
|
+
firstPaint: children(n.firstPaint),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
case "islandChildren":
|
|
296
|
+
return { t: "islandChildren" };
|
|
297
|
+
case "fragment":
|
|
298
|
+
return { t: "fragment", children: children(n.children) };
|
|
299
|
+
default:
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function jsonOnly(value, depth) {
|
|
304
|
+
if (depth > 16)
|
|
305
|
+
return undefined;
|
|
306
|
+
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
307
|
+
return value;
|
|
308
|
+
if (typeof value === "number")
|
|
309
|
+
return Number.isFinite(value) ? value : undefined;
|
|
310
|
+
if (Array.isArray(value))
|
|
311
|
+
return value.slice(0, 1000).map((v) => jsonOnly(v, depth + 1));
|
|
312
|
+
if (value && typeof value === "object") {
|
|
313
|
+
const out = {};
|
|
314
|
+
for (const [k, v] of Object.entries(value)) {
|
|
315
|
+
if (k === "__proto__" || k === "constructor" || k === "prototype")
|
|
316
|
+
continue;
|
|
317
|
+
out[k] = jsonOnly(v, depth + 1);
|
|
318
|
+
}
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
function sanitizeChildren(value, ctx, depth, budget) {
|
|
324
|
+
if (!Array.isArray(value))
|
|
325
|
+
return [];
|
|
326
|
+
const out = [];
|
|
327
|
+
for (const child of value) {
|
|
328
|
+
const clean = sanitizeNode(child, ctx, depth, budget);
|
|
329
|
+
if (clean !== null)
|
|
330
|
+
out.push(clean);
|
|
331
|
+
}
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
/** Sanitizes a whole component tree. Throws when it exceeds the node or depth limits. */
|
|
335
|
+
export function sanitizeTree(tree, renderingId, options = {}) {
|
|
336
|
+
const ctx = {
|
|
337
|
+
renderingId,
|
|
338
|
+
idPrefix: `pr-${renderingId.replace(/[^a-z0-9]/gi, "").slice(0, 8)}-`,
|
|
339
|
+
mediaHosts: options.mediaHosts ?? [],
|
|
340
|
+
limits: options.limits ?? protocol.limits,
|
|
341
|
+
};
|
|
342
|
+
return sanitizeChildren(tree, ctx, 0, { nodes: 0 });
|
|
343
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { type DynamicRenderingsConfig } from "./config.js";
|
|
3
|
+
/** SHA-256 of the LF-normalized theme text. */
|
|
4
|
+
export declare function hashThemeCss(css: string): string;
|
|
5
|
+
/**
|
|
6
|
+
* The theme a compile runs against: this host's theme file plus, when the tenant has a theme
|
|
7
|
+
* rendering, its tokens. The engine's compile result reports the same value.
|
|
8
|
+
*/
|
|
9
|
+
export declare function combineThemeHash(themeFileHash: string, themeTokens?: string | null): string;
|
|
10
|
+
/** The theme's text and hash, cached for ten seconds. Throws ThemeUnavailableError. */
|
|
11
|
+
export declare function loadTheme(config: DynamicRenderingsConfig): Promise<{
|
|
12
|
+
css: string;
|
|
13
|
+
hash: string;
|
|
14
|
+
}>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { normalizeThemeTokens } from "../css-policy/css-policy.mjs";
|
|
4
|
+
import { ThemeUnavailableError } from "./config.js";
|
|
5
|
+
const cache = new WeakMap();
|
|
6
|
+
const sha256Hex = (text) => createHash("sha256").update(text).digest("hex");
|
|
7
|
+
/** SHA-256 of the LF-normalized theme text. */
|
|
8
|
+
export function hashThemeCss(css) {
|
|
9
|
+
return sha256Hex(css.replace(/\r\n/g, "\n"));
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The theme a compile runs against: this host's theme file plus, when the tenant has a theme
|
|
13
|
+
* rendering, its tokens. The engine's compile result reports the same value.
|
|
14
|
+
*/
|
|
15
|
+
export function combineThemeHash(themeFileHash, themeTokens) {
|
|
16
|
+
const tokens = normalizeThemeTokens(themeTokens ?? "");
|
|
17
|
+
return tokens ? sha256Hex(`${themeFileHash}:${sha256Hex(tokens)}`) : themeFileHash;
|
|
18
|
+
}
|
|
19
|
+
/** The theme's text and hash, cached for ten seconds. Throws ThemeUnavailableError. */
|
|
20
|
+
export async function loadTheme(config) {
|
|
21
|
+
const cached = cache.get(config);
|
|
22
|
+
if (cached && Date.now() - cached.at < 10_000)
|
|
23
|
+
return cached;
|
|
24
|
+
let css;
|
|
25
|
+
try {
|
|
26
|
+
css = await config.theme.load();
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
if (err instanceof ThemeUnavailableError)
|
|
30
|
+
throw err;
|
|
31
|
+
throw new ThemeUnavailableError(`The theme (${config.theme.describe}) could not be read: ${err instanceof Error ? err.message : String(err)}`);
|
|
32
|
+
}
|
|
33
|
+
if (typeof css !== "string")
|
|
34
|
+
throw new ThemeUnavailableError(`The theme (${config.theme.describe}) returned no text.`);
|
|
35
|
+
const state = { css, hash: hashThemeCss(css), at: Date.now() };
|
|
36
|
+
cache.set(config, state);
|
|
37
|
+
return state;
|
|
38
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// next.config.js wrapper for dynamic renderings. Plain CommonJS on purpose: next.config.js loads
|
|
3
|
+
// in ordinary Node, where anything importing "server-only" throws, so this file imports nothing
|
|
4
|
+
// of the package's server code.
|
|
5
|
+
const fs = require("node:fs");
|
|
6
|
+
const path = require("node:path");
|
|
7
|
+
const { corsExclusionSource, clientBundleRewrites } = require("./rewrites.cjs");
|
|
8
|
+
|
|
9
|
+
const ENGINE_PACKAGE = "@alpaca-headless/dynamic-renderings-engine";
|
|
10
|
+
const DEFAULT_THEME_TRACE = "./src/app/**/theme.css";
|
|
11
|
+
const CATCH_ALL_SOURCES = new Set(["/:path*", "/(.*)", "/:path(.*)"]);
|
|
12
|
+
const ALL_ROUTES = ["/*", "/**/*"];
|
|
13
|
+
|
|
14
|
+
function setsAllowOrigin(rule) {
|
|
15
|
+
return Array.isArray(rule && rule.headers) && rule.headers.some((h) => String(h.key).toLowerCase() === "access-control-allow-origin");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A head's catch-all "Access-Control-Allow-Origin" rule would also cover /api/alpaca/*.
|
|
20
|
+
* Such rules get the exclusion source instead; rules with a narrower source are kept as they are.
|
|
21
|
+
*/
|
|
22
|
+
function excludeDynamicApi(rules) {
|
|
23
|
+
return (rules || []).map((rule) =>
|
|
24
|
+
setsAllowOrigin(rule) && CATCH_ALL_SOURCES.has(rule.source) ? { ...rule, source: corsExclusionSource } : rule,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function resolve(value) {
|
|
29
|
+
return typeof value === "function" ? value() : value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The head's own rewrites (an array or Next's phased object) with `before` checked first:
|
|
34
|
+
* beforeFiles, ahead of the pages, so a catch-all page never answers those paths.
|
|
35
|
+
*/
|
|
36
|
+
function prependRewrites(own, before) {
|
|
37
|
+
if (!before.length) return own || [];
|
|
38
|
+
if (!own) return { beforeFiles: before, afterFiles: [], fallback: [] };
|
|
39
|
+
if (Array.isArray(own)) return { beforeFiles: before, afterFiles: own, fallback: [] };
|
|
40
|
+
return { ...own, beforeFiles: [...before, ...(own.beforeFiles || [])] };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The engine's files as a trace glob relative to the project: the node_modules entry the app
|
|
45
|
+
* resolves at runtime (hoisted or not), not its real path, so standalone output keeps the layout
|
|
46
|
+
* Node resolves from.
|
|
47
|
+
*/
|
|
48
|
+
function engineTrace(projectDir) {
|
|
49
|
+
// Resolve from the runtime package too: an editor adapter can be a file: link in
|
|
50
|
+
// a sibling workspace. Walking only the head's ancestors misses its engine.
|
|
51
|
+
for (const start of [projectDir, __dirname]) {
|
|
52
|
+
for (let dir = start; ; dir = path.dirname(dir)) {
|
|
53
|
+
const candidate = path.join(dir, "node_modules", ...ENGINE_PACKAGE.split("/"));
|
|
54
|
+
if (fs.existsSync(path.join(candidate, "package.json"))) {
|
|
55
|
+
const relative = path.relative(projectDir, candidate).split(path.sep).join("/");
|
|
56
|
+
const base = relative.startsWith(".") ? relative : `./${relative}`;
|
|
57
|
+
return [`${base}/package.json`, `${base}/dist/**/*`];
|
|
58
|
+
}
|
|
59
|
+
if (path.dirname(dir) === dir) break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function addTraces(tracing, routes, globs) {
|
|
66
|
+
for (const route of routes) tracing[route] = [...new Set([...(tracing[route] || []), ...globs])];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {import("next").NextConfig} nextConfig
|
|
71
|
+
* @param {{ themeFile?: string, routePrefixes?: string[] }} [options] themeFile: the theme's path
|
|
72
|
+
* relative to the project, when it is not src/app/**\/theme.css. routePrefixes: path patterns
|
|
73
|
+
* (e.g. "/editor/proxy/:host") a proxy serves pages under; bundle URLs under them reach the
|
|
74
|
+
* bundle route.
|
|
75
|
+
*/
|
|
76
|
+
function withDynamicRenderings(nextConfig = {}, options = {}) {
|
|
77
|
+
const themeTrace = options.themeFile ? `./${String(options.themeFile).replace(/^\.?\//, "")}` : DEFAULT_THEME_TRACE;
|
|
78
|
+
const tracing = { ...(nextConfig.outputFileTracingIncludes || {}) };
|
|
79
|
+
// Pages and the build route read the theme at runtime and start the engine's threads from its
|
|
80
|
+
// dist folder; standalone output must contain both.
|
|
81
|
+
const engine = engineTrace(process.cwd());
|
|
82
|
+
if (!engine) console.warn(`[dynamic-renderings] ${ENGINE_PACKAGE} is not installed; dynamic renderings cannot compile or render.`);
|
|
83
|
+
addTraces(tracing, ALL_ROUTES, engine ? [themeTrace, ...engine] : [themeTrace]);
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
...nextConfig,
|
|
87
|
+
// The engine starts worker threads from files next to its own module; a bundled copy would
|
|
88
|
+
// point them at the bundle instead.
|
|
89
|
+
serverExternalPackages: [...new Set([...(nextConfig.serverExternalPackages || []), ENGINE_PACKAGE])],
|
|
90
|
+
outputFileTracingIncludes: tracing,
|
|
91
|
+
async headers() {
|
|
92
|
+
const own = await resolve(nextConfig.headers);
|
|
93
|
+
return excludeDynamicApi(own || []);
|
|
94
|
+
},
|
|
95
|
+
async rewrites() {
|
|
96
|
+
return prependRewrites(await resolve(nextConfig.rewrites), clientBundleRewrites(options.routePrefixes));
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { withDynamicRenderings, prependRewrites, corsExclusionSource, ENGINE_PACKAGE };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { NextConfig } from "next";
|
|
2
|
+
|
|
3
|
+
export type DynamicRenderingsNextOptions = {
|
|
4
|
+
/** The theme's path relative to the project, when it is not src/app/**\/theme.css. */
|
|
5
|
+
themeFile?: string;
|
|
6
|
+
/**
|
|
7
|
+
* Path patterns a proxy serves this head's pages under, e.g. "/editor/proxy/:host". Bundle URLs
|
|
8
|
+
* under them (`{prefix}/api/alpaca/renderings/client/...`) are rewritten to the bundle route.
|
|
9
|
+
*/
|
|
10
|
+
routePrefixes?: readonly string[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type Rewrite = { source: string; destination: string; [key: string]: unknown };
|
|
14
|
+
type Rewrites = Rewrite[] | { beforeFiles?: Rewrite[]; afterFiles?: Rewrite[]; fallback?: Rewrite[] };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Loads @alpaca-headless/dynamic-renderings-engine unbundled (its threads start from files next to it),
|
|
18
|
+
* traces the engine and the theme file into standalone output, keeps the head's catch-all CORS
|
|
19
|
+
* rule off /api/alpaca/* (also under a prefix), and adds the bundle route's rewrites for
|
|
20
|
+
* `routePrefixes`. The head's own headers(), rewrites() and other settings are kept.
|
|
21
|
+
*/
|
|
22
|
+
export declare function withDynamicRenderings(nextConfig?: NextConfig, options?: DynamicRenderingsNextOptions): NextConfig;
|
|
23
|
+
/** The head's rewrites with `before` checked ahead of the pages (as beforeFiles). */
|
|
24
|
+
export declare function prependRewrites(own: Rewrites | undefined | null, before: Rewrite[]): Rewrites;
|
|
25
|
+
export declare const corsExclusionSource: string;
|
|
26
|
+
export declare const ENGINE_PACKAGE: "@alpaca-headless/dynamic-renderings-engine";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Routing data a rendering host needs, as plain CommonJS: next.config.js loads in ordinary Node,
|
|
3
|
+
// and CommonJS loads from ESM while the reverse does not.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A header rule source that matches every path except the dynamic-renderings API, also under a
|
|
7
|
+
* proxy prefix (/some/prefix/api/alpaca/...). The build route is for the CM only, and the proxy in
|
|
8
|
+
* front of a prefixed bundle URL sets its own CORS headers.
|
|
9
|
+
*/
|
|
10
|
+
const corsExclusionSource = "/:path((?!(?:.+/)?api/alpaca/).*)";
|
|
11
|
+
|
|
12
|
+
const CLIENT_BUNDLE_PATH = "/api/alpaca/renderings/client/:file";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Rewrites that let bundle URLs work under each proxy prefix a page may be served under. A prefix
|
|
16
|
+
* is a path pattern such as "/editor/proxy/:host"; only the bundle route is reachable this way.
|
|
17
|
+
*/
|
|
18
|
+
function clientBundleRewrites(routePrefixes) {
|
|
19
|
+
return (routePrefixes || []).map((prefix) => ({
|
|
20
|
+
source: `${String(prefix).replace(/\/+$/, "")}${CLIENT_BUNDLE_PATH}`,
|
|
21
|
+
destination: CLIENT_BUNDLE_PATH,
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = { corsExclusionSource, clientBundleRewrites, CLIENT_BUNDLE_PATH };
|
package/package.json
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alpaca-headless/dynamic-renderings",
|
|
3
|
+
"version": "1.0.4694",
|
|
4
|
+
"description": "Optional Alpaca Headless dynamic renderings: source client, safe React rendering, client code and Next.js integration.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
"./protocol": {
|
|
11
|
+
"types": "./dist/protocol/index.d.ts",
|
|
12
|
+
"import": "./dist/protocol/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./css-policy": {
|
|
15
|
+
"types": "./dist/css-policy/css-policy.d.mts",
|
|
16
|
+
"import": "./dist/css-policy/css-policy.mjs"
|
|
17
|
+
},
|
|
18
|
+
"./rich-text": {
|
|
19
|
+
"types": "./dist/rich-text/index.d.ts",
|
|
20
|
+
"import": "./dist/rich-text/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./server": {
|
|
23
|
+
"types": "./dist/server/index.d.ts",
|
|
24
|
+
"import": "./dist/server/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./react": {
|
|
27
|
+
"types": "./dist/react/index.d.ts",
|
|
28
|
+
"import": "./dist/react/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./client": {
|
|
31
|
+
"types": "./dist/client/index.d.ts",
|
|
32
|
+
"import": "./dist/client/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./handlers": {
|
|
35
|
+
"types": "./dist/handlers/index.d.ts",
|
|
36
|
+
"import": "./dist/handlers/index.js"
|
|
37
|
+
},
|
|
38
|
+
"./handlers/rewrites": {
|
|
39
|
+
"types": "./next-config/rewrites.d.cts",
|
|
40
|
+
"default": "./next-config/rewrites.cjs"
|
|
41
|
+
},
|
|
42
|
+
"./next": {
|
|
43
|
+
"types": "./dist/next/index.d.ts",
|
|
44
|
+
"import": "./dist/next/index.js"
|
|
45
|
+
},
|
|
46
|
+
"./next/config": {
|
|
47
|
+
"types": "./next-config/index.d.cts",
|
|
48
|
+
"default": "./next-config/index.cjs"
|
|
49
|
+
},
|
|
50
|
+
"./package.json": "./package.json"
|
|
51
|
+
},
|
|
52
|
+
"files": [
|
|
53
|
+
"dist/",
|
|
54
|
+
"next-config/",
|
|
55
|
+
"README.md",
|
|
56
|
+
"LICENSE"
|
|
57
|
+
],
|
|
58
|
+
"sideEffects": [
|
|
59
|
+
"./dist/client/ClientIslandRuntime.js"
|
|
60
|
+
],
|
|
61
|
+
"scripts": {
|
|
62
|
+
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && node scripts/copy-assets.mjs",
|
|
63
|
+
"build:native": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsgo -p tsconfig.build.json && node scripts/copy-assets.mjs",
|
|
64
|
+
"check-types": "tsc --noEmit",
|
|
65
|
+
"check-types:native": "tsgo --noEmit",
|
|
66
|
+
"test": "vitest run",
|
|
67
|
+
"acceptance": "node scripts/acceptance.mjs"
|
|
68
|
+
},
|
|
69
|
+
"dependencies": {
|
|
70
|
+
"@alpaca-headless/dynamic-renderings-engine": "1.0.4694",
|
|
71
|
+
"lucide-react": "^1.38.0",
|
|
72
|
+
"postcss": "^8.5.26",
|
|
73
|
+
"postcss-selector-parser": "^7.1.6",
|
|
74
|
+
"postcss-value-parser": "^4.2.0",
|
|
75
|
+
"sanitize-html": "^2.17.7",
|
|
76
|
+
"server-only": "^0.0.1"
|
|
77
|
+
},
|
|
78
|
+
"peerDependencies": {
|
|
79
|
+
"@alpaca-headless/alpaca-headless": ">=1.0.4656",
|
|
80
|
+
"@alpaca-headless/alpaca-headless-nextjs": ">=1.0.4656",
|
|
81
|
+
"next": ">=16",
|
|
82
|
+
"react": ">=19.2.0 <20",
|
|
83
|
+
"react-dom": ">=19.2.0 <20"
|
|
84
|
+
},
|
|
85
|
+
"peerDependenciesMeta": {
|
|
86
|
+
"@alpaca-headless/alpaca-headless-nextjs": {
|
|
87
|
+
"optional": true
|
|
88
|
+
},
|
|
89
|
+
"next": {
|
|
90
|
+
"optional": true
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
"devDependencies": {
|
|
94
|
+
"@alpaca-headless/alpaca-headless": "^1.0.4656",
|
|
95
|
+
"@alpaca-headless/alpaca-headless-nextjs": "^1.0.4656",
|
|
96
|
+
"@types/node": "^26.4.0",
|
|
97
|
+
"@types/react": "19.2.18",
|
|
98
|
+
"@types/react-dom": "19.2.5",
|
|
99
|
+
"@types/sanitize-html": "^2.16.1",
|
|
100
|
+
"@typescript/native": "npm:typescript@7.0.2",
|
|
101
|
+
"next": "16.3.4",
|
|
102
|
+
"react": "19.2.8",
|
|
103
|
+
"react-dom": "19.2.8",
|
|
104
|
+
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
105
|
+
"vitest": "^4.1.11"
|
|
106
|
+
},
|
|
107
|
+
"license": "SEE LICENSE IN LICENSE"
|
|
108
|
+
}
|