@particle-academy/fancy-cms-ui 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/editor.cjs CHANGED
@@ -16,6 +16,12 @@ function hasNodeMap(value) {
16
16
  const nodes = value.nodes;
17
17
  return typeof nodes === "object" && nodes !== null && !Array.isArray(nodes);
18
18
  }
19
+ function withNodeDefaults(node) {
20
+ const props = typeof node.props === "object" && node.props !== null ? node.props : {};
21
+ const style = typeof node.style === "object" && node.style !== null ? node.style : { base: {} };
22
+ const base = typeof style.base === "object" && style.base !== null ? style.base : {};
23
+ return props === node.props && style === node.style && base === style.base ? node : { ...node, props, style: { ...style, base } };
24
+ }
19
25
  var DEFAULT_BREAKPOINTS = {
20
26
  base: 0,
21
27
  md: 768,
@@ -32,6 +38,15 @@ function emptyDoc(id, slug = "/") {
32
38
  };
33
39
  }
34
40
 
41
+ // src/document/own.ts
42
+ var has = Object.prototype.hasOwnProperty;
43
+ function own(map, key) {
44
+ return map != null && has.call(map, key) ? map[key] : void 0;
45
+ }
46
+ function setOwn(map, key, value) {
47
+ Object.defineProperty(map, key, { value, writable: true, enumerable: true, configurable: true });
48
+ }
49
+
35
50
  // src/document/migrate.ts
36
51
  function normalizeDoc(input3) {
37
52
  if (hasNodeMap(input3)) return input3;
@@ -49,7 +64,7 @@ function migrateDocVerbose(input3) {
49
64
  const ordered = [];
50
65
  const seen = /* @__PURE__ */ new Set();
51
66
  for (const id of sections) {
52
- if (!normalized.nodes[id]) {
67
+ if (!own(normalized.nodes, id)) {
53
68
  danglingIds.push(id);
54
69
  continue;
55
70
  }
@@ -63,7 +78,7 @@ function migrateDocVerbose(input3) {
63
78
  let prev = null;
64
79
  for (const id of finalOrder) {
65
80
  const key = fancyDocCommons.fractionalKey(prev, null);
66
- nodes[id] = { ...nodes[id], order: key };
81
+ setOwn(nodes, id, { ...own(nodes, id), order: key });
67
82
  prev = key;
68
83
  }
69
84
  const { sections: _dropped, ...rest } = legacy;
@@ -92,7 +107,7 @@ function descendantIds(doc, id) {
92
107
  return fancyDocCommons.descendantsOf(doc, id);
93
108
  }
94
109
  function patchNode(doc, id, patch) {
95
- const node = doc.nodes[id];
110
+ const node = own(doc.nodes, id);
96
111
  return {
97
112
  ...doc,
98
113
  nodes: { ...doc.nodes, [id]: { ...node, ...patch } },
@@ -106,8 +121,8 @@ function reduce(doc, op, opts = {}) {
106
121
  };
107
122
  switch (op.t) {
108
123
  case "insert_node": {
109
- if (doc.nodes[op.node.id]) return invalid("node id already exists");
110
- if (op.node.parent !== null && !doc.nodes[op.node.parent]) return invalid("parent not found");
124
+ if (own(doc.nodes, op.node.id)) return invalid("node id already exists");
125
+ if (op.node.parent !== null && !own(doc.nodes, op.node.parent)) return invalid("parent not found");
111
126
  return {
112
127
  ...doc,
113
128
  nodes: { ...doc.nodes, [op.node.id]: op.node },
@@ -115,11 +130,11 @@ function reduce(doc, op, opts = {}) {
115
130
  };
116
131
  }
117
132
  case "remove_node": {
118
- if (!doc.nodes[op.id]) return invalid("node not found");
133
+ if (!own(doc.nodes, op.id)) return invalid("node not found");
119
134
  const dead = /* @__PURE__ */ new Set([op.id, ...descendantIds(doc, op.id)]);
120
135
  const nodes = {};
121
136
  for (const id of Object.keys(doc.nodes)) {
122
- if (!dead.has(id)) nodes[id] = doc.nodes[id];
137
+ if (!dead.has(id)) setOwn(nodes, id, own(doc.nodes, id));
123
138
  }
124
139
  return {
125
140
  ...doc,
@@ -128,10 +143,10 @@ function reduce(doc, op, opts = {}) {
128
143
  };
129
144
  }
130
145
  case "move_node": {
131
- const node = doc.nodes[op.id];
146
+ const node = own(doc.nodes, op.id);
132
147
  if (!node) return invalid("node not found");
133
148
  if (op.parent === op.id) return invalid("cannot parent a node to itself");
134
- if (op.parent !== null && !doc.nodes[op.parent]) return invalid("new parent not found");
149
+ if (op.parent !== null && !own(doc.nodes, op.parent)) return invalid("new parent not found");
135
150
  if (op.parent !== null && descendantIds(doc, op.id).includes(op.parent)) {
136
151
  return invalid("move would create a cycle");
137
152
  }
@@ -142,34 +157,35 @@ function reduce(doc, op, opts = {}) {
142
157
  };
143
158
  }
144
159
  case "set_node_props": {
145
- if (!doc.nodes[op.id]) return invalid("node not found");
160
+ const node = own(doc.nodes, op.id);
161
+ if (!node) return invalid("node not found");
146
162
  return patchNode(doc, op.id, {
147
- props: { ...doc.nodes[op.id].props, ...op.patch }
163
+ props: { ...node.props, ...op.patch }
148
164
  });
149
165
  }
150
166
  case "set_layout": {
151
- if (!doc.nodes[op.id]) return invalid("node not found");
167
+ if (!own(doc.nodes, op.id)) return invalid("node not found");
152
168
  return patchNode(doc, op.id, { layout: op.layout });
153
169
  }
154
170
  case "set_style": {
155
- const node = doc.nodes[op.id];
171
+ const node = own(doc.nodes, op.id);
156
172
  if (!node) return invalid("node not found");
157
- const prev = node.style[op.breakpoint] ?? {};
173
+ const prev = own(node.style, op.breakpoint) ?? {};
158
174
  return patchNode(doc, op.id, {
159
175
  style: { ...node.style, [op.breakpoint]: { ...prev, ...op.patch } }
160
176
  });
161
177
  }
162
178
  case "set_constraints": {
163
- const node = doc.nodes[op.id];
179
+ const node = own(doc.nodes, op.id);
164
180
  if (!node) return invalid("node not found");
165
181
  const base = node.constraints ?? { base: { width: "hug", height: "hug" } };
166
- const prev = base[op.breakpoint] ?? {};
182
+ const prev = own(base, op.breakpoint) ?? {};
167
183
  return patchNode(doc, op.id, {
168
184
  constraints: { ...base, [op.breakpoint]: { ...prev, ...op.patch } }
169
185
  });
170
186
  }
171
187
  case "set_animation": {
172
- const node = doc.nodes[op.id];
188
+ const node = own(doc.nodes, op.id);
173
189
  if (!node) return invalid("node not found");
174
190
  const list = node.animations ?? [];
175
191
  const idx = list.findIndex((a) => a.id === op.anim.id);
@@ -178,14 +194,14 @@ function reduce(doc, op, opts = {}) {
178
194
  });
179
195
  }
180
196
  case "remove_animation": {
181
- const node = doc.nodes[op.id];
197
+ const node = own(doc.nodes, op.id);
182
198
  if (!node) return invalid("node not found");
183
199
  return patchNode(doc, op.id, {
184
200
  animations: (node.animations ?? []).filter((a) => a.id !== op.animId)
185
201
  });
186
202
  }
187
203
  case "set_action": {
188
- const node = doc.nodes[op.id];
204
+ const node = own(doc.nodes, op.id);
189
205
  if (!node) return invalid("node not found");
190
206
  const list = node.actions ?? [];
191
207
  let actions;
@@ -201,13 +217,13 @@ function reduce(doc, op, opts = {}) {
201
217
  case "reorder_roots": {
202
218
  const currentRoots = childrenOf(doc, null);
203
219
  const current = new Set(currentRoots.map((n) => n.id));
204
- const same = op.order.length === currentRoots.length && op.order.every((id) => current.has(id));
220
+ const same = op.order.length === currentRoots.length && new Set(op.order).size === op.order.length && op.order.every((id) => current.has(id));
205
221
  if (!same) return invalid("reorder must be a permutation of the current roots");
206
222
  const nodes = { ...doc.nodes };
207
223
  let prev = null;
208
224
  for (const id of op.order) {
209
225
  prev = fancyDocCommons.fractionalKey(prev, null);
210
- nodes[id] = { ...nodes[id], order: prev };
226
+ setOwn(nodes, id, { ...own(nodes, id), order: prev });
211
227
  }
212
228
  return { ...doc, nodes, seq: doc.seq + 1 };
213
229
  }
@@ -290,15 +306,58 @@ function useLatestRef(value) {
290
306
  }
291
307
 
292
308
  // src/render/css.ts
293
- var selectorFor = (id) => `[data-cms="${id}"]`;
309
+ function cssString(value) {
310
+ let out = "";
311
+ for (const ch of value) {
312
+ const code = ch.codePointAt(0);
313
+ if (ch === "\\" || ch === '"') out += `\\${ch}`;
314
+ else if (code < 32 || code === 127 || ch === "<") out += `\\${code.toString(16)} `;
315
+ else out += ch;
316
+ }
317
+ return out;
318
+ }
319
+ var selectorFor = (id) => `[data-cms="${cssString(id)}"]`;
320
+ var BASE_CSS = [
321
+ "/* fancy-cms base */",
322
+ ":where([data-cms]) {\n box-sizing: border-box;\n}",
323
+ ":where(h1, h2, h3, h4, h5, h6, p, ul, ol)[data-cms] {\n margin: 0;\n}",
324
+ ":where(ul, ol)[data-cms] {\n padding-left: 1.25em;\n}",
325
+ ":where(a[data-cms]) {\n color: inherit;\n}",
326
+ ":where([data-cms] > img) {\n display: block;\n height: 100%;\n object-fit: cover;\n width: 100%;\n}",
327
+ ':where([data-cms] > img[data-cms-fit="contain"]) {\n object-fit: contain;\n}',
328
+ ':where([data-cms] > img[data-cms-fit="fill"]) {\n object-fit: fill;\n}',
329
+ ':where([data-cms] > img[data-cms-fit="none"]) {\n object-fit: none;\n}',
330
+ ':where([data-cms] > img[data-cms-fit="scale-down"]) {\n object-fit: scale-down;\n}',
331
+ ":where([data-cms-button]) {\n align-items: center;\n border: 1px solid var(--cms-color-primary, #7c3aed);\n border-radius: 8px;\n cursor: pointer;\n display: inline-flex;\n font-weight: 600;\n gap: 6px;\n justify-content: center;\n line-height: 1.2;\n padding: 10px 18px;\n text-decoration: none;\n}",
332
+ ':where([data-cms-button="primary"]) {\n background: var(--cms-color-primary, #7c3aed);\n color: #ffffff;\n}',
333
+ ':where([data-cms-button="outline"]) {\n background: transparent;\n color: var(--cms-color-primary, #7c3aed);\n}',
334
+ ':where([data-cms-button="ghost"]) {\n background: transparent;\n border-color: currentColor;\n color: inherit;\n}',
335
+ ":where([data-cms-callout]) {\n background: #3b82f614;\n border-left: 3px solid #3b82f6;\n border-radius: 10px;\n padding: 12px 14px;\n}",
336
+ ':where([data-cms-callout="success"]) {\n background: #10b98114;\n border-left-color: #10b981;\n}',
337
+ ':where([data-cms-callout="warning"]) {\n background: #f59e0b14;\n border-left-color: #f59e0b;\n}',
338
+ ':where([data-cms-callout="danger"]) {\n background: #ef444414;\n border-left-color: #ef4444;\n}',
339
+ ":where([data-cms] > hr) {\n border: none;\n border-top: 1px solid #e2e8f0;\n margin: 0;\n}",
340
+ ":where([data-cms] > pre) {\n background: #0b1220;\n border-radius: 10px;\n color: #e2e8f0;\n font-family: ui-monospace, monospace;\n font-size: 13px;\n margin: 0;\n overflow-x: auto;\n padding: 12px 14px;\n}"
341
+ ].join("\n\n");
342
+ var UNITS = /* @__PURE__ */ new Set(["px", "%", "vw", "vh", "rem", "fr"]);
294
343
  function lengthToCss(len2) {
295
- return `${len2.value}${len2.unit}`;
344
+ if (typeof len2 !== "object" || len2 === null) return null;
345
+ const { value, unit } = len2;
346
+ if (typeof value !== "number" || !Number.isFinite(value) || typeof unit !== "string" || !UNITS.has(unit)) return null;
347
+ return `${value}${unit}`;
296
348
  }
297
349
  function sizeToCss(size) {
298
350
  if (size === "fill") return "100%";
299
351
  if (size === "hug") return "auto";
300
352
  return lengthToCss(size);
301
353
  }
354
+ function numberToCss(value) {
355
+ return typeof value === "number" && Number.isFinite(value) ? String(value) : null;
356
+ }
357
+ function putLen(d, props, len2) {
358
+ const css = lengthToCss(len2);
359
+ if (css !== null) for (const p of props) d[p] = css;
360
+ }
302
361
  var ALIGN = {
303
362
  start: "flex-start",
304
363
  center: "center",
@@ -312,51 +371,150 @@ var JUSTIFY = {
312
371
  between: "space-between",
313
372
  around: "space-around"
314
373
  };
374
+ function safeCssValue(value) {
375
+ if (/[{}<]/.test(value)) return null;
376
+ let depth = 0;
377
+ let quote = null;
378
+ for (const ch of value) {
379
+ if (quote) {
380
+ if (ch === quote) quote = null;
381
+ } else if (ch === '"' || ch === "'") {
382
+ quote = ch;
383
+ } else if (ch === "(") {
384
+ depth++;
385
+ } else if (ch === ")") {
386
+ depth = Math.max(0, depth - 1);
387
+ } else if (ch === ";" && depth === 0) {
388
+ return null;
389
+ }
390
+ }
391
+ return value;
392
+ }
393
+ function put(d, prop, value) {
394
+ if (value === void 0) return;
395
+ const safe = safeCssValue(value);
396
+ if (safe !== null) d[prop] = safe;
397
+ }
398
+ var OVERFLOW = /* @__PURE__ */ new Set(["visible", "hidden", "auto"]);
399
+ var TEXT_TRANSFORM = /* @__PURE__ */ new Set(["none", "uppercase", "lowercase", "capitalize"]);
400
+ var BACKGROUND_SIZE = /* @__PURE__ */ new Set(["cover", "contain", "auto"]);
401
+ var TEXT_ALIGN = /* @__PURE__ */ new Set(["left", "center", "right", "justify"]);
402
+ var DIRECTIONS = /* @__PURE__ */ new Set(["row", "column"]);
403
+ function keyword(map, value) {
404
+ return typeof value === "string" && Object.prototype.hasOwnProperty.call(map, value) ? map[value] : null;
405
+ }
406
+ function inSet(set, value) {
407
+ return typeof value === "string" && set.has(value) ? value : null;
408
+ }
315
409
  function styleDecls(style) {
316
410
  const d = {};
317
- if (style.padding) d["padding"] = lengthToCss(style.padding);
318
- if (style.margin) d["margin"] = lengthToCss(style.margin);
319
- if (style.radius) d["border-radius"] = lengthToCss(style.radius);
320
- if (style.background !== void 0) d["background"] = style.background;
321
- if (style.color !== void 0) d["color"] = style.color;
322
- if (style.opacity !== void 0) d["opacity"] = String(style.opacity);
323
- if (style.fontFamily !== void 0) d["font-family"] = style.fontFamily;
324
- if (style.fontSize) d["font-size"] = lengthToCss(style.fontSize);
325
- if (style.fontWeight !== void 0) d["font-weight"] = String(style.fontWeight);
326
- if (style.lineHeight !== void 0) d["line-height"] = String(style.lineHeight);
327
- if (style.textAlign !== void 0) d["text-align"] = style.textAlign;
328
- if (style.letterSpacing) d["letter-spacing"] = lengthToCss(style.letterSpacing);
329
- if (style.border !== void 0) d["border"] = style.border;
330
- if (style.boxShadow !== void 0) d["box-shadow"] = style.boxShadow;
331
- if (style.transform !== void 0) d["transform"] = style.transform;
332
- if (style.filter !== void 0) d["filter"] = style.filter;
333
- if (style.gap) d["gap"] = lengthToCss(style.gap);
334
- if (style.align !== void 0) d["align-items"] = ALIGN[style.align] ?? style.align;
335
- if (style.justify !== void 0) d["justify-content"] = JUSTIFY[style.justify] ?? style.justify;
411
+ putLen(d, ["padding"], style.padding);
412
+ putLen(d, ["padding-left", "padding-right"], style.paddingX);
413
+ putLen(d, ["padding-top", "padding-bottom"], style.paddingY);
414
+ putLen(d, ["margin"], style.margin);
415
+ if (style.marginX === "auto") d["margin-left"] = d["margin-right"] = "auto";
416
+ else putLen(d, ["margin-left", "margin-right"], style.marginX);
417
+ putLen(d, ["margin-top", "margin-bottom"], style.marginY);
418
+ putLen(d, ["border-radius"], style.radius);
419
+ putLen(d, ["max-width"], style.maxWidth);
420
+ putLen(d, ["min-height"], style.minHeight);
421
+ put(d, "aspect-ratio", typeof style.aspectRatio === "string" ? style.aspectRatio : void 0);
422
+ if (style.sticky === true) {
423
+ d["position"] = "sticky";
424
+ d["top"] = "0";
425
+ }
426
+ if (typeof style.zIndex === "number" && Number.isFinite(style.zIndex)) d["z-index"] = String(Math.trunc(style.zIndex));
427
+ const overflow = inSet(OVERFLOW, style.overflow);
428
+ if (overflow) d["overflow"] = overflow;
429
+ put(d, "background", typeof style.background === "string" ? style.background : void 0);
430
+ const backgroundSize = inSet(BACKGROUND_SIZE, style.backgroundSize);
431
+ if (backgroundSize) d["background-size"] = backgroundSize;
432
+ put(d, "background-position", typeof style.backgroundPosition === "string" ? style.backgroundPosition : void 0);
433
+ put(d, "color", typeof style.color === "string" ? style.color : void 0);
434
+ const opacity = numberToCss(style.opacity);
435
+ if (opacity !== null) d["opacity"] = opacity;
436
+ put(d, "font-family", typeof style.fontFamily === "string" ? style.fontFamily : void 0);
437
+ putLen(d, ["font-size"], style.fontSize);
438
+ const fontWeight = numberToCss(style.fontWeight);
439
+ if (fontWeight !== null) d["font-weight"] = fontWeight;
440
+ const lineHeight = numberToCss(style.lineHeight);
441
+ if (lineHeight !== null) d["line-height"] = lineHeight;
442
+ const textAlign = inSet(TEXT_ALIGN, style.textAlign);
443
+ if (textAlign) d["text-align"] = textAlign;
444
+ putLen(d, ["letter-spacing"], style.letterSpacing);
445
+ const textTransform = inSet(TEXT_TRANSFORM, style.textTransform);
446
+ if (textTransform) d["text-transform"] = textTransform;
447
+ put(d, "border", typeof style.border === "string" ? style.border : void 0);
448
+ put(d, "box-shadow", typeof style.boxShadow === "string" ? style.boxShadow : void 0);
449
+ put(d, "transform", typeof style.transform === "string" ? style.transform : void 0);
450
+ put(d, "filter", typeof style.filter === "string" ? style.filter : void 0);
451
+ putLen(d, ["gap"], style.gap);
452
+ const align = keyword(ALIGN, style.align);
453
+ if (align) d["align-items"] = align;
454
+ const justify = keyword(JUSTIFY, style.justify);
455
+ if (justify) d["justify-content"] = justify;
336
456
  return d;
337
457
  }
338
- function layoutDecls(node) {
458
+ function shownDisplay(node) {
459
+ if (node.layout === "stack") return "flex";
460
+ if (node.layout === "grid") return "grid";
461
+ return "block";
462
+ }
463
+ function gridColumns(style) {
464
+ const min = lengthToCss(style.columnMin);
465
+ if (min !== null) return `repeat(auto-fit, minmax(min(${min}, 100%), 1fr))`;
466
+ if (typeof style.columns === "number" && Number.isFinite(style.columns)) return `repeat(${Math.max(1, Math.trunc(style.columns))}, 1fr)`;
467
+ return void 0;
468
+ }
469
+ function containerDecls(node, style, isBase) {
339
470
  const d = {};
340
471
  if (node.layout === "stack") {
341
- d["display"] = "flex";
342
- d["flex-direction"] = node.style.base.direction ?? "column";
472
+ if (isBase) d["display"] = "flex";
473
+ const direction = inSet(DIRECTIONS, style.direction);
474
+ if (isBase || direction) d["flex-direction"] = direction ?? "column";
475
+ if (style.wrap !== void 0) d["flex-wrap"] = style.wrap ? "wrap" : "nowrap";
343
476
  } else if (node.layout === "grid") {
344
- d["display"] = "grid";
345
- d["grid-template-columns"] = `repeat(${node.style.base.columns ?? 1}, 1fr)`;
346
- } else if (node.layout === "free") {
477
+ if (isBase) d["display"] = "grid";
478
+ const cols = gridColumns(style);
479
+ if (isBase || cols !== void 0) d["grid-template-columns"] = cols ?? "repeat(1, 1fr)";
480
+ } else if (node.layout === "free" && isBase) {
347
481
  d["position"] = "relative";
348
482
  }
349
483
  return d;
350
484
  }
485
+ function visibilityDecls(node, style, isBase) {
486
+ if (style.hidden === true) return { display: "none" };
487
+ if (style.hidden === false && !isBase) return { display: shownDisplay(node) };
488
+ return {};
489
+ }
490
+ function themeDecls(theme) {
491
+ const d = {};
492
+ const walk = (value, path) => {
493
+ if (typeof value === "string" || typeof value === "number" && Number.isFinite(value)) {
494
+ const key = path.map((p) => p.toLowerCase().replace(/[^a-z0-9-]/g, "")).filter(Boolean).join("-");
495
+ if (key) put(d, `--cms-${key}`, String(value));
496
+ return;
497
+ }
498
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
499
+ for (const [k, v] of Object.entries(value)) walk(v, [...path, k]);
500
+ }
501
+ };
502
+ if (typeof theme === "object" && theme !== null && !Array.isArray(theme)) {
503
+ for (const [k, v] of Object.entries(theme)) {
504
+ if (k !== "name") walk(v, [k]);
505
+ }
506
+ }
507
+ return d;
508
+ }
351
509
  function constraintDecls(c, parentFree) {
352
510
  const d = {};
353
511
  const transforms = [];
354
512
  if (parentFree) {
355
513
  d["position"] = "absolute";
356
- if (c.left) d["left"] = lengthToCss(c.left);
357
- if (c.right) d["right"] = lengthToCss(c.right);
358
- if (c.top) d["top"] = lengthToCss(c.top);
359
- if (c.bottom) d["bottom"] = lengthToCss(c.bottom);
514
+ putLen(d, ["left"], c.left);
515
+ putLen(d, ["right"], c.right);
516
+ putLen(d, ["top"], c.top);
517
+ putLen(d, ["bottom"], c.bottom);
360
518
  if (c.centerX) {
361
519
  d["left"] = "50%";
362
520
  transforms.push("translateX(-50%)");
@@ -367,13 +525,15 @@ function constraintDecls(c, parentFree) {
367
525
  }
368
526
  if (transforms.length) d["transform"] = transforms.join(" ");
369
527
  }
370
- if (c.width) d["width"] = sizeToCss(c.width);
371
- if (c.height) d["height"] = sizeToCss(c.height);
528
+ const width = sizeToCss(c.width);
529
+ if (width !== null) d["width"] = width;
530
+ const height = sizeToCss(c.height);
531
+ if (height !== null) d["height"] = height;
372
532
  return d;
373
533
  }
374
534
  function isParentFree(doc, node) {
375
535
  if (node.parent === null) return false;
376
- const parent = doc.nodes[node.parent];
536
+ const parent = own(doc.nodes, node.parent);
377
537
  return !parent || parent.layout === void 0 || parent.layout === "free";
378
538
  }
379
539
  function serializeRule(selector, decls, indent) {
@@ -388,24 +548,31 @@ function emitDocCss(doc) {
388
548
  if (!hasNodeMap(doc)) return "";
389
549
  const breakpointPx = DEFAULT_BREAKPOINTS;
390
550
  const blocks = [];
551
+ const themeRule = serializeRule(":root", themeDecls(doc.theme), "");
552
+ if (themeRule) blocks.push(themeRule);
391
553
  for (const id of Object.keys(doc.nodes).sort()) {
392
- const node = doc.nodes[id];
554
+ const node = withNodeDefaults(doc.nodes[id]);
393
555
  const selector = selectorFor(id);
394
556
  const parentFree = isParentFree(doc, node);
395
557
  const base = {
396
- ...layoutDecls(node),
558
+ ...containerDecls(node, node.style.base, true),
397
559
  ...node.constraints ? constraintDecls(node.constraints.base, parentFree) : {},
398
- ...styleDecls(node.style.base)
560
+ ...styleDecls(node.style.base),
561
+ ...visibilityDecls(node, node.style.base, true)
399
562
  };
400
563
  const baseRule = serializeRule(selector, base, "");
401
564
  if (baseRule) blocks.push(baseRule);
402
565
  for (const bp of doc.breakpoints) {
403
566
  if (bp === "base") continue;
404
- const px = breakpointPx[bp];
405
- if (px === void 0) continue;
567
+ const px = own(breakpointPx, bp);
568
+ if (typeof px !== "number") continue;
569
+ const bpStyle = own(node.style, bp);
570
+ const bpConstraints = node.constraints ? own(node.constraints, bp) : void 0;
406
571
  const decls = {
407
- ...node.constraints?.[bp] ? constraintDecls(node.constraints[bp], parentFree) : {},
408
- ...node.style[bp] ? styleDecls(node.style[bp]) : {}
572
+ ...bpStyle ? containerDecls(node, bpStyle, false) : {},
573
+ ...bpConstraints ? constraintDecls(bpConstraints, parentFree) : {},
574
+ ...bpStyle ? styleDecls(bpStyle) : {},
575
+ ...bpStyle ? visibilityDecls(node, bpStyle, false) : {}
409
576
  };
410
577
  const inner = serializeRule(selector, decls, " ");
411
578
  if (inner) blocks.push(`@media (min-width: ${px}px) {
@@ -413,7 +580,8 @@ ${inner}
413
580
  }`);
414
581
  }
415
582
  }
416
- return blocks.join("\n\n") + (blocks.length ? "\n" : "");
583
+ if (blocks.length === 0 && Object.keys(doc.nodes).length === 0) return "";
584
+ return [BASE_CSS, ...blocks].join("\n\n") + "\n";
417
585
  }
418
586
  function getPath(data, path) {
419
587
  return path.split(".").reduce((o, k) => o == null ? void 0 : o[k], data);
@@ -423,6 +591,55 @@ function resolveValue(v, data) {
423
591
  if (isBinding(v)) return getPath(data, v.$bind);
424
592
  return v;
425
593
  }
594
+ var LANDMARK_TAGS = ["section", "header", "nav", "main", "footer", "article", "aside", "div"];
595
+ var LANDMARKS = new Set(LANDMARK_TAGS);
596
+ var CONTAINERS = /* @__PURE__ */ new Set(["stack", "grid", "frame", "shape", "card"]);
597
+ var SAFE_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "mailto", "tel"]);
598
+ function safeHref(raw) {
599
+ const href = raw.trim();
600
+ if (!href) return null;
601
+ const probe = href.replace(/[\u0000- \u007f]+/g, "");
602
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(probe);
603
+ if (scheme && !SAFE_SCHEMES.has(scheme[1].toLowerCase())) return null;
604
+ return href;
605
+ }
606
+ var ANCHOR_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
607
+ function anchorFor(node, text) {
608
+ const anchor = text(node.props.anchor);
609
+ return ANCHOR_PATTERN.test(anchor) ? anchor : null;
610
+ }
611
+ function elementFor(node, text) {
612
+ const as = text(node.props.as);
613
+ switch (node.type) {
614
+ case "heading": {
615
+ const level = Number(text(node.props.level) || 2);
616
+ return { tag: Number.isInteger(level) && level >= 1 && level <= 6 ? `h${level}` : "h2" };
617
+ }
618
+ case "text":
619
+ return { tag: "p" };
620
+ case "section":
621
+ return { tag: LANDMARKS.has(as) ? as : "section" };
622
+ case "list":
623
+ return { tag: text(node.props.ordered) === "true" ? "ol" : "ul" };
624
+ case "button":
625
+ case "link": {
626
+ const href = safeHref(text(node.props.href));
627
+ const attrs = {};
628
+ if (href !== null) {
629
+ attrs.href = href;
630
+ if (/^https?:\/\//i.test(href)) attrs.rel = "noopener noreferrer";
631
+ }
632
+ if (node.type === "button") attrs["data-cms-button"] = BUTTON_VARIANTS.has(text(node.props.variant)) ? text(node.props.variant) : "primary";
633
+ return { tag: href !== null ? "a" : node.type === "link" ? "span" : "div", attrs };
634
+ }
635
+ case "callout": {
636
+ const variant = text(node.props.variant);
637
+ return { tag: "div", attrs: { "data-cms-callout": CALLOUT_VARIANTS.has(variant) ? variant : "info" } };
638
+ }
639
+ default:
640
+ return { tag: CONTAINERS.has(node.type) && LANDMARKS.has(as) ? as : "div" };
641
+ }
642
+ }
426
643
  var defaultRegistry = {
427
644
  section: ({ children }) => children,
428
645
  stack: ({ children }) => children,
@@ -435,47 +652,38 @@ var defaultRegistry = {
435
652
  // Rich text — inline-formatted HTML (gradient spans, links, <code>, …) that
436
653
  // plain `text` can't express. The author owns the markup (it's their content).
437
654
  richtext: ({ node, text }) => /* @__PURE__ */ jsxRuntime.jsx("div", { dangerouslySetInnerHTML: { __html: text(node.props.html) } }),
438
- link: ({ node, text }) => /* @__PURE__ */ jsxRuntime.jsx("a", { href: text(node.props.href) || "#", style: { color: "inherit", textDecoration: "underline" }, children: text(node.props.content) || "link" }),
439
- divider: () => /* @__PURE__ */ jsxRuntime.jsx("hr", { style: { border: "none", borderTop: "1px solid #e2e8f0", margin: 0 } }),
440
- callout: ({ node, text }) => {
441
- const tone = { info: "#3b82f6", success: "#10b981", warning: "#f59e0b", danger: "#ef4444" }[text(node.props.variant)] ?? "#3b82f6";
442
- return /* @__PURE__ */ jsxRuntime.jsx("div", { style: { display: "flex", gap: 10, padding: "12px 14px", borderRadius: 10, background: `${tone}14`, borderLeft: `3px solid ${tone}` }, children: /* @__PURE__ */ jsxRuntime.jsx("span", { style: { fontSize: 13 }, children: text(node.props.content) || "Callout" }) });
443
- },
444
- code: ({ node, text }) => /* @__PURE__ */ jsxRuntime.jsx("pre", { style: { margin: 0, padding: "12px 14px", borderRadius: 10, background: "#0b1220", color: "#e2e8f0", fontFamily: "ui-monospace, monospace", fontSize: 13, overflowX: "auto" }, children: /* @__PURE__ */ jsxRuntime.jsx("code", { children: text(node.props.content) }) }),
445
- button: ({ node, text }) => {
446
- const label2 = text(node.props.label) || text(node.props.content) || "Button";
447
- const variant = text(node.props.variant) || "primary";
448
- return /* @__PURE__ */ jsxRuntime.jsx("span", { "data-cms-button": variant, style: buttonStyle(variant), children: label2 });
655
+ // The [data-cms] element is already the <a> (see elementFor), so a link renders its label only.
656
+ link: ({ node, text }) => text(node.props.content) || "link",
657
+ list: ({ node, resolve }) => {
658
+ const items = resolve(node.props.items);
659
+ return Array.isArray(items) ? items.map((item, i) => /* @__PURE__ */ jsxRuntime.jsx("li", { children: item === null || item === void 0 ? "" : String(item) }, i)) : null;
449
660
  },
450
- image: ({ node, text }) => /* @__PURE__ */ jsxRuntime.jsx(
451
- "img",
452
- {
453
- src: text(node.props.src),
454
- alt: text(node.props.alt),
455
- style: { width: "100%", height: "100%", objectFit: "cover", display: "block" }
456
- }
457
- )
661
+ // Looks come from the base stylesheet (render/css.ts BASE_CSS), never from
662
+ // inline style objects, so the PHP renderer emits the same markup.
663
+ divider: () => /* @__PURE__ */ jsxRuntime.jsx("hr", {}),
664
+ callout: ({ node, text }) => text(node.props.content) || "Callout",
665
+ code: ({ node, text }) => /* @__PURE__ */ jsxRuntime.jsx("pre", { children: /* @__PURE__ */ jsxRuntime.jsx("code", { children: text(node.props.content) }) }),
666
+ button: ({ node, text }) => text(node.props.label) || text(node.props.content) || "Button",
667
+ image: ({ node, text }) => {
668
+ const fit = text(node.props.fit);
669
+ return /* @__PURE__ */ jsxRuntime.jsx(
670
+ "img",
671
+ {
672
+ src: text(node.props.src) || void 0,
673
+ alt: text(node.props.alt),
674
+ loading: text(node.props.loading) === "eager" ? "eager" : "lazy",
675
+ "data-cms-fit": IMAGE_FITS.has(fit) && fit !== "cover" ? fit : void 0
676
+ }
677
+ );
678
+ }
458
679
  };
459
- function buttonStyle(variant) {
460
- const base = {
461
- display: "inline-flex",
462
- alignItems: "center",
463
- gap: 6,
464
- padding: "9px 16px",
465
- borderRadius: 8,
466
- fontWeight: 600,
467
- fontSize: 14,
468
- lineHeight: 1,
469
- cursor: "pointer",
470
- userSelect: "none"
471
- };
472
- if (variant === "ghost") return { ...base, background: "transparent", color: "inherit", border: "1px solid currentColor" };
473
- if (variant === "outline") return { ...base, background: "transparent", color: "#7c3aed", border: "1px solid #7c3aed" };
474
- return { ...base, background: "#7c3aed", color: "#fff", border: "1px solid #7c3aed" };
475
- }
680
+ var IMAGE_FITS = /* @__PURE__ */ new Set(["cover", "contain", "fill", "none", "scale-down"]);
681
+ var BUTTON_VARIANTS = /* @__PURE__ */ new Set(["primary", "outline", "ghost"]);
682
+ var CALLOUT_VARIANTS = /* @__PURE__ */ new Set(["info", "success", "warning", "danger"]);
476
683
  function RenderNode({ doc, id, registry = defaultRegistry, data }) {
477
- const node = doc.nodes[id];
478
- if (!node) return null;
684
+ const stored = own(doc.nodes, id);
685
+ if (!stored) return null;
686
+ const node = withNodeDefaults(stored);
479
687
  if (node.type === "repeater") {
480
688
  const arr = resolveValue(node.props.items, data);
481
689
  const items = Array.isArray(arr) ? arr : [];
@@ -490,7 +698,19 @@ function RenderNode({ doc, id, registry = defaultRegistry, data }) {
490
698
  };
491
699
  const renderer = registry[node.type];
492
700
  const inner = renderer ? renderer({ node, doc, children: kids, data, resolve, text }) : node.island ? null : kids;
493
- return /* @__PURE__ */ jsxRuntime.jsx("div", { "data-cms": node.id, "data-cms-island": node.island ? node.type : void 0, className: node.className, children: inner });
701
+ const { tag, attrs } = elementFor(node, text);
702
+ const anchor = anchorFor(node, text);
703
+ return react.createElement(
704
+ tag,
705
+ {
706
+ "data-cms": node.id,
707
+ "data-cms-island": node.island ? node.type : void 0,
708
+ className: node.className,
709
+ ...anchor ? { id: anchor } : {},
710
+ ...attrs
711
+ },
712
+ inner
713
+ );
494
714
  }
495
715
  function CmsPage({
496
716
  doc: input3,
@@ -808,7 +1028,8 @@ var ADD_DEFAULTS = {
808
1028
  divider: { type: "divider", props: {}, style: {} },
809
1029
  code: { type: "code", props: { content: "npm install @particle-academy/react-fancy", lang: "bash" }, style: {} },
810
1030
  richtext: { type: "richtext", props: { html: "<p>Rich <strong>text</strong> with <em>inline</em> formatting.</p>" }, style: {} },
811
- repeater: { type: "repeater", props: { items: "" }, style: { gap: { value: 12, unit: "px" } }, layout: "stack" }
1031
+ repeater: { type: "repeater", props: { items: "" }, style: { gap: { value: 12, unit: "px" } }, layout: "stack" },
1032
+ list: { type: "list", props: { items: ["First point", "Second point"], ordered: false }, style: {} }
812
1033
  };
813
1034
  var ADD_MENU = [
814
1035
  { kind: "heading", label: "Heading" },
@@ -824,6 +1045,7 @@ var ADD_MENU = [
824
1045
  { kind: "divider", label: "Divider" },
825
1046
  { kind: "code", label: "Code" },
826
1047
  { kind: "richtext", label: "Rich text" },
1048
+ { kind: "list", label: "List" },
827
1049
  { kind: "repeater", label: "Repeater" }
828
1050
  ];
829
1051
  function resolveInsertParent(doc, selectedId) {