@alpaca-headless/dynamic-renderings-engine 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.
@@ -0,0 +1,13 @@
1
+ // Types for css-policy.mjs (the engine bundles the same module).
2
+ export type CssDiagnostic = { severity?: string; file?: string; line?: number; column?: number; message: string };
3
+ export declare const LAYER: string;
4
+ export declare function normalizeRenderingId(id: string): string;
5
+ export declare function scopeSelectorFor(renderingId: string): string;
6
+ export declare function namePrefixFor(renderingId: string): string;
7
+ export declare function scopeCss(rawCss: string, renderingId: string): { css: string; errors: CssDiagnostic[] };
8
+ export declare function verifyScopedCss(css: string, renderingId: string, maxBytes?: number): { ok: boolean; errors: CssDiagnostic[] };
9
+ export declare function normalizeThemeTokens(text: string | null | undefined): string;
10
+ export declare function parseThemeTokens(
11
+ text: string | null | undefined,
12
+ maxBytes?: number,
13
+ ): { ok: boolean; declarations: { name: string; value: string }[]; css: string; errors: CssDiagnostic[] };
@@ -0,0 +1,396 @@
1
+ // Stylesheet policy for dynamic renderings, shared by the rendering hosts and the render
2
+ // worker (which bundles this module from @alpaca-headless/dynamic-renderings-engine/css-policy):
3
+ // - the worker's build calls scopeCss() on Tailwind's output,
4
+ // - the host calls verifyScopedCss() before inserting a stylesheet, and drops the whole
5
+ // stylesheet on any violation.
6
+ //
7
+ // A rendering's stylesheet may only style descendants of its own wrapper element
8
+ // ([data-parhelia-rendering="<id>"]), may not declare document-global names, and may not
9
+ // load anything (no url(), image-set(), @import, @font-face, backslash escapes).
10
+ import postcss from "postcss";
11
+ import selectorParser from "postcss-selector-parser";
12
+ import valueParser from "postcss-value-parser";
13
+
14
+ export const LAYER = "dynamic-renderings";
15
+
16
+ const CONTAINER_AT_RULES = new Set(["media", "supports", "container", "starting-style"]);
17
+
18
+ const ALLOWED_FUNCTIONS = new Set([
19
+ "", // plain parentheses
20
+ "var", "calc", "calc-size", "min", "max", "clamp", "round", "mod", "rem", "abs", "sign",
21
+ "rgb", "rgba", "hsl", "hsla", "hwb", "lab", "lch", "oklab", "oklch", "color", "color-mix", "light-dark",
22
+ "linear-gradient", "radial-gradient", "conic-gradient",
23
+ "repeating-linear-gradient", "repeating-radial-gradient", "repeating-conic-gradient",
24
+ "translate", "translatex", "translatey", "translatez", "translate3d",
25
+ "rotate", "rotatex", "rotatey", "rotatez", "rotate3d",
26
+ "scale", "scalex", "scaley", "scalez", "scale3d",
27
+ "skew", "skewx", "skewy", "matrix", "matrix3d", "perspective",
28
+ "cubic-bezier", "steps", "linear",
29
+ "repeat", "minmax", "fit-content",
30
+ "blur", "brightness", "contrast", "drop-shadow", "grayscale", "hue-rotate", "invert", "opacity", "saturate", "sepia",
31
+ "inset", "circle", "ellipse", "polygon", "rect", "xywh",
32
+ "counter", "counters",
33
+ ]);
34
+
35
+ const IMAGE_PROPERTIES = new Set([
36
+ "background", "background-image", "border-image", "border-image-source", "mask", "mask-image",
37
+ "mask-border", "mask-border-source", "list-style", "list-style-image", "cursor", "shape-outside",
38
+ "filter", "backdrop-filter", "clip-path", "src", "marker", "marker-start", "marker-mid", "marker-end",
39
+ ]);
40
+
41
+ const FORBIDDEN_PSEUDOS = new Set([":root", ":host", ":host-context", "::slotted", ":scope"]);
42
+
43
+ export function normalizeRenderingId(id) {
44
+ return String(id).replace(/[{}]/g, "").toLowerCase();
45
+ }
46
+
47
+ export function scopeSelectorFor(renderingId) {
48
+ return `[data-parhelia-rendering="${normalizeRenderingId(renderingId)}"]`;
49
+ }
50
+
51
+ export function namePrefixFor(renderingId) {
52
+ return `pr${normalizeRenderingId(renderingId).replace(/-/g, "").slice(0, 8)}-`;
53
+ }
54
+
55
+ function problem(errors, message, node) {
56
+ const line = node && node.source && node.source.start ? node.source.start.line : undefined;
57
+ errors.push({ severity: "error", file: "styles.css", line, column: undefined, message });
58
+ }
59
+
60
+ // Tailwind registers its variables with @property { syntax: "<length>"; ... }. That descriptor,
61
+ // and only there, may hold "<": a quoted list of data type names, nothing else.
62
+ const PROPERTY_SYNTAX = /^"(\*|<[a-z-]+>[+#]?(\s*\|\s*<[a-z-]+>[+#]?)*)"$/i;
63
+
64
+ function isPropertySyntax(prop, value, node) {
65
+ const parent = node && node.parent;
66
+ return (
67
+ prop.toLowerCase() === "syntax" &&
68
+ parent && parent.type === "atrule" && parent.name.toLowerCase() === "property" &&
69
+ PROPERTY_SYNTAX.test(value.trim())
70
+ );
71
+ }
72
+
73
+ function checkValue(prop, value, errors, node) {
74
+ if (isPropertySyntax(prop, value, node)) return;
75
+ if (value.includes("\\")) {
76
+ problem(errors, `CSS value for ${prop} contains a backslash escape, which is not allowed.`, node);
77
+ return;
78
+ }
79
+ if (value.includes("<")) {
80
+ problem(errors, `CSS value for ${prop} contains "<", which is not allowed.`, node);
81
+ return;
82
+ }
83
+ const lowerProp = prop.toLowerCase();
84
+ valueParser(value).walk((part) => {
85
+ if (part.type === "function") {
86
+ const name = part.value.toLowerCase();
87
+ if (!ALLOWED_FUNCTIONS.has(name)) {
88
+ problem(errors, `CSS function ${part.value}() in ${prop} is not allowed.`, node);
89
+ }
90
+ } else if (part.type === "string" && IMAGE_PROPERTIES.has(lowerProp)) {
91
+ problem(errors, `Quoted strings are not allowed in ${prop}.`, node);
92
+ }
93
+ });
94
+ }
95
+
96
+ function checkProperty(prop, errors, node) {
97
+ if (!/^(--[A-Za-z0-9_-]+|-?[a-z][a-z-]*)$/.test(prop)) {
98
+ problem(errors, `CSS property name ${JSON.stringify(prop)} is not allowed.`, node);
99
+ }
100
+ }
101
+
102
+ function renameInValue(value, rename) {
103
+ const parsed = valueParser(value);
104
+ let changed = false;
105
+ parsed.walk((part) => {
106
+ if (part.type === "word") {
107
+ const next = rename(part.value);
108
+ if (next !== part.value) {
109
+ part.value = next;
110
+ changed = true;
111
+ }
112
+ }
113
+ });
114
+ return changed ? valueParser.stringify(parsed.nodes) : value;
115
+ }
116
+
117
+ function isInsideKeyframes(node) {
118
+ for (let parent = node.parent; parent; parent = parent.parent) {
119
+ if (parent.type === "atrule" && /keyframes$/i.test(parent.name)) return true;
120
+ }
121
+ return false;
122
+ }
123
+
124
+ /**
125
+ * Turns Tailwind's output for one rendering into a stylesheet that can only affect that
126
+ * rendering. Returns { css, errors }; any error means the build must fail.
127
+ */
128
+ export function scopeCss(rawCss, renderingId) {
129
+ const errors = [];
130
+ const scope = scopeSelectorFor(renderingId);
131
+ const prefix = namePrefixFor(renderingId);
132
+ let root;
133
+ try {
134
+ root = postcss.parse(rawCss);
135
+ } catch (err) {
136
+ return { css: "", errors: [{ severity: "error", file: "styles.css", message: `CSS could not be parsed: ${err.message}` }] };
137
+ }
138
+
139
+ root.walkComments((comment) => comment.remove());
140
+
141
+ // Flatten every @layer: a rendering must not be able to (re)order the document cascade.
142
+ const layers = [];
143
+ root.walkAtRules(/^layer$/i, (at) => layers.push(at));
144
+ for (const at of layers.reverse()) {
145
+ if (at.nodes && at.nodes.length) at.replaceWith(at.nodes);
146
+ else at.remove();
147
+ }
148
+
149
+ const keyframeNames = new Set();
150
+ const properties = [];
151
+ const keyframes = [];
152
+ const renameCustom = (name) => (name.startsWith("--tw-") ? `--${prefix}${name.slice(5)}` : name);
153
+
154
+ root.walkAtRules((at) => {
155
+ const name = at.name.toLowerCase();
156
+ if (CONTAINER_AT_RULES.has(name)) {
157
+ if (at.params.includes("\\") || /<\//.test(at.params)) problem(errors, `@${at.name} condition is not allowed.`, at);
158
+ return;
159
+ }
160
+ if (name === "keyframes") {
161
+ const original = at.params.trim().replace(/^["']|["']$/g, "");
162
+ if (!/^[A-Za-z0-9_-]+$/.test(original)) {
163
+ problem(errors, `@keyframes name ${JSON.stringify(original)} is not allowed.`, at);
164
+ return;
165
+ }
166
+ keyframeNames.add(original);
167
+ at.params = `${prefix}${original}`;
168
+ keyframes.push(at);
169
+ return;
170
+ }
171
+ if (name === "property") {
172
+ const original = at.params.trim();
173
+ if (!original.startsWith("--tw-")) {
174
+ problem(errors, `@property ${original} is not allowed; only Tailwind's own properties are.`, at);
175
+ return;
176
+ }
177
+ at.params = renameCustom(original);
178
+ properties.push(at);
179
+ return;
180
+ }
181
+ problem(errors, `@${at.name} is not allowed in rendering styles.`, at);
182
+ });
183
+
184
+ root.walkDecls((decl) => {
185
+ checkProperty(decl.prop, errors, decl);
186
+ decl.prop = renameCustom(decl.prop);
187
+ decl.value = renameInValue(decl.value, (word) => (word.startsWith("--tw-") ? renameCustom(word) : word));
188
+ if (/^animation(-name)?$/i.test(decl.prop)) {
189
+ decl.value = renameInValue(decl.value, (word) => (keyframeNames.has(word) ? `${prefix}${word}` : word));
190
+ }
191
+ checkValue(decl.prop, decl.value, errors, decl);
192
+ });
193
+
194
+ root.walkRules((rule) => {
195
+ if (isInsideKeyframes(rule)) return;
196
+ if (rule.selector.includes("<")) {
197
+ problem(errors, "Selectors containing \"<\" are not allowed.", rule);
198
+ return;
199
+ }
200
+ const scoped = [];
201
+ try {
202
+ selectorParser((selectors) => {
203
+ selectors.each((selector) => {
204
+ const first = selector.first;
205
+ if (!first) return;
206
+ if (first.type === "combinator") {
207
+ problem(errors, `Selector ${selector.toString().trim()} starts with a combinator.`, rule);
208
+ return;
209
+ }
210
+ let bad = false;
211
+ selector.walk((part) => {
212
+ if (part.type === "nesting") bad = "uses nesting (&)";
213
+ else if (part.type === "pseudo" && FORBIDDEN_PSEUDOS.has(part.value.toLowerCase())) bad = `uses ${part.value}`;
214
+ else if (part.type === "tag" && /^(html|body|head)$/i.test(part.value)) bad = `targets <${part.value}>`;
215
+ });
216
+ if (bad) {
217
+ problem(errors, `Selector ${selector.toString().trim()} ${bad}.`, rule);
218
+ return;
219
+ }
220
+ scoped.push(`${scope} ${selector.toString().trim()}`);
221
+ });
222
+ }).processSync(rule.selector);
223
+ } catch (err) {
224
+ problem(errors, `Selector ${rule.selector} could not be parsed: ${err.message}`, rule);
225
+ return;
226
+ }
227
+ if (scoped.length === 0) rule.remove();
228
+ else rule.selector = scoped.join(",\n");
229
+ });
230
+
231
+ if (errors.length) return { css: "", errors };
232
+
233
+ for (const at of [...properties, ...keyframes]) at.remove();
234
+ const layer = postcss.atRule({ name: "layer", params: LAYER });
235
+ layer.append(root.nodes.map((n) => n.clone()));
236
+ const out = postcss.root();
237
+ if (layer.nodes.length) out.append(layer);
238
+ out.append(properties);
239
+ out.append(keyframes);
240
+ const css = out.toString();
241
+
242
+ const verified = verifyScopedCss(css, renderingId);
243
+ return verified.ok ? { css, errors: [] } : { css: "", errors: verified.errors };
244
+ }
245
+
246
+ /**
247
+ * Checks a stored stylesheet against the invariants scopeCss() guarantees. The host runs this
248
+ * before every insertion; ok:false means the stylesheet must not be inserted at all.
249
+ */
250
+ export function verifyScopedCss(css, renderingId, maxBytes = 65536) {
251
+ const errors = [];
252
+ if (typeof css !== "string") return { ok: false, errors: [{ message: "Stylesheet is not a string." }] };
253
+ if (css.length > maxBytes) problem(errors, `Stylesheet exceeds ${maxBytes} bytes.`);
254
+ if (/<\//.test(css)) problem(errors, "Stylesheet contains \"</\".");
255
+ if (errors.length) return { ok: false, errors };
256
+
257
+ const scope = scopeSelectorFor(renderingId);
258
+ const prefix = namePrefixFor(renderingId);
259
+ const id = normalizeRenderingId(renderingId);
260
+ let root;
261
+ try {
262
+ root = postcss.parse(css);
263
+ } catch (err) {
264
+ return { ok: false, errors: [{ message: `Stylesheet could not be parsed: ${err.message}` }] };
265
+ }
266
+
267
+ root.each((node) => {
268
+ if (node.type === "comment") return;
269
+ if (node.type !== "atrule") return problem(errors, "Top-level rules must sit inside the rendering layer.", node);
270
+ const name = node.name.toLowerCase();
271
+ if (name === "layer" && node.params.trim() === LAYER && node.nodes) return;
272
+ if (name === "property" && node.params.trim().startsWith(`--${prefix}`)) return;
273
+ if (name === "keyframes" && node.params.trim().startsWith(prefix)) return;
274
+ problem(errors, `Top-level @${node.name} ${node.params} is not allowed.`, node);
275
+ });
276
+
277
+ root.walkAtRules((at) => {
278
+ const name = at.name.toLowerCase();
279
+ if (at.parent === root) return;
280
+ if (CONTAINER_AT_RULES.has(name)) {
281
+ if (at.params.includes("\\")) problem(errors, `@${at.name} condition contains an escape.`, at);
282
+ return;
283
+ }
284
+ if (name === "keyframes" || name === "property" || name === "layer") {
285
+ problem(errors, `@${at.name} must not be nested.`, at);
286
+ return;
287
+ }
288
+ problem(errors, `@${at.name} is not allowed.`, at);
289
+ });
290
+
291
+ root.walkDecls((decl) => {
292
+ checkProperty(decl.prop, errors, decl);
293
+ checkValue(decl.prop, decl.value, errors, decl);
294
+ });
295
+
296
+ root.walkRules((rule) => {
297
+ if (isInsideKeyframes(rule)) return;
298
+ if (rule.selector.includes("<")) {
299
+ problem(errors, "Selector contains \"<\".", rule);
300
+ return;
301
+ }
302
+ try {
303
+ selectorParser((selectors) => {
304
+ selectors.each((selector) => {
305
+ const nodes = selector.nodes;
306
+ const head = nodes[0];
307
+ const next = nodes[1];
308
+ const scoped =
309
+ head &&
310
+ head.type === "attribute" &&
311
+ head.attribute === "data-parhelia-rendering" &&
312
+ head.operator === "=" &&
313
+ head.value === id &&
314
+ next &&
315
+ next.type === "combinator" &&
316
+ (next.value.trim() === "" || next.value.trim() === ">");
317
+ if (!scoped) problem(errors, `Selector ${selector.toString().trim()} is not confined to ${scope}.`, rule);
318
+ selector.walk((part) => {
319
+ if (part.type === "nesting") problem(errors, "Selector uses nesting.", rule);
320
+ if (part.type === "pseudo" && FORBIDDEN_PSEUDOS.has(part.value.toLowerCase())) {
321
+ problem(errors, `Selector uses ${part.value}.`, rule);
322
+ }
323
+ });
324
+ });
325
+ }).processSync(rule.selector);
326
+ } catch (err) {
327
+ problem(errors, `Selector could not be parsed: ${err.message}`, rule);
328
+ }
329
+ let parent = rule.parent;
330
+ while (parent && parent !== root) {
331
+ if (parent.type === "atrule" && parent.name.toLowerCase() === "layer" && parent.parent === root) break;
332
+ parent = parent.parent;
333
+ }
334
+ if (!parent || parent === root) problem(errors, "Rule sits outside the rendering layer.", rule);
335
+ });
336
+
337
+ return { ok: errors.length === 0, errors };
338
+ }
339
+
340
+ const THEME_TOKEN_NAME = /^--[A-Za-z][A-Za-z0-9-]{0,63}$/;
341
+ const RESERVED_TOKEN_PREFIXES = ["--tw-", "--pr", "--parhelia", "--a-editor", "--alpaca", "--sc-"];
342
+
343
+ /** LF-normalized, trimmed theme token text: what the build hashes (the CM hashes the same). */
344
+ export function normalizeThemeTokens(text) {
345
+ return String(text || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim();
346
+ }
347
+
348
+ /**
349
+ * A theme rendering's tokens: custom property declarations only, e.g.
350
+ * --color-brand: oklch(0.62 0.19 255);
351
+ * --font-display: "Space Grotesk", sans-serif;
352
+ * Values follow the stylesheet value rules (no url(), image-set(), backslash or "<"). Names
353
+ * reserved for Tailwind internals, rendering prefixes and the editor are refused. The build
354
+ * adds the tokens to Tailwind's @theme (so utilities such as bg-brand exist) and the host
355
+ * declares them on :root.
356
+ */
357
+ export function parseThemeTokens(text, maxBytes = 16384) {
358
+ const errors = [];
359
+ const source = normalizeThemeTokens(text);
360
+ const fail = () => ({ ok: false, declarations: [], css: "", errors: errors.map((e) => ({ ...e, file: "theme" })) });
361
+ if (!source) return { ok: true, declarations: [], css: "", errors: [] };
362
+ if (source.length > maxBytes) {
363
+ problem(errors, `Theme tokens exceed ${maxBytes} bytes.`);
364
+ return fail();
365
+ }
366
+ let root;
367
+ try {
368
+ root = postcss.parse(`:root{${source}}`);
369
+ } catch (err) {
370
+ problem(errors, `Theme tokens could not be parsed: ${err.reason || err.message}`);
371
+ return fail();
372
+ }
373
+ const rule = root.nodes.length === 1 && root.nodes[0].type === "rule" ? root.nodes[0] : null;
374
+ if (!rule) {
375
+ problem(errors, "Theme tokens must be plain declarations such as --color-brand: #0a84ff;");
376
+ return fail();
377
+ }
378
+ const declarations = [];
379
+ rule.each((node) => {
380
+ if (node.type === "comment") return;
381
+ if (node.type !== "decl") return problem(errors, "Theme tokens may only contain custom property declarations.", node);
382
+ const name = node.prop;
383
+ if (!THEME_TOKEN_NAME.test(name)) return problem(errors, `${JSON.stringify(name)} is not a theme token name (use --name-with-dashes).`, node);
384
+ if (RESERVED_TOKEN_PREFIXES.some((prefix) => name.toLowerCase().startsWith(prefix))) {
385
+ return problem(errors, `Theme token ${name} uses a reserved prefix.`, node);
386
+ }
387
+ if (node.important) return problem(errors, `!important is not allowed in ${name}.`, node);
388
+ const value = node.value.trim();
389
+ if (!value) return problem(errors, `Theme token ${name} has no value.`, node);
390
+ const before = errors.length;
391
+ checkValue(name, value, errors, node);
392
+ if (errors.length === before) declarations.push({ name, value });
393
+ });
394
+ if (errors.length) return fail();
395
+ return { ok: true, declarations, css: declarations.map((d) => `${d.name}: ${d.value};`).join("\n"), errors: [] };
396
+ }