@dreamtree-org/landing-kit 0.5.0 → 0.6.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.
@@ -0,0 +1,728 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
5
+ var __export = (target, all) => {
6
+ for (var name in all)
7
+ __defProp(target, name, { get: all[name], enumerable: true });
8
+ };
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
18
+
19
+ // src/schema/index.js
20
+ var index_exports = {};
21
+ __export(index_exports, {
22
+ DEFAULT_THEME: () => DEFAULT_THEME,
23
+ FONT_PAIRS: () => FONT_PAIRS,
24
+ ICON_NAMES: () => ICON_NAMES,
25
+ MAX_SECTIONS: () => MAX_SECTIONS,
26
+ MODES: () => MODES,
27
+ PAGE_VERSION: () => PAGE_VERSION,
28
+ PALETTE_STEPS: () => PALETTE_STEPS,
29
+ RADII: () => RADII,
30
+ SECTION_TYPES: () => SECTION_TYPES,
31
+ SECTION_TYPE_KEYS: () => SECTION_TYPE_KEYS,
32
+ THEME_PRESETS: () => THEME_PRESETS,
33
+ brandPalette: () => brandPalette,
34
+ contrastRatio: () => contrastRatio,
35
+ defaultPage: () => defaultPage,
36
+ hexToOklch: () => hexToOklch,
37
+ isHex: () => isHex,
38
+ luminance: () => luminance,
39
+ newSection: () => newSection,
40
+ newSectionId: () => newSectionId,
41
+ oklchToHex: () => oklchToHex,
42
+ readableOn: () => readableOn,
43
+ safeHref: () => safeHref,
44
+ sanitizeRichText: () => sanitizeRichText,
45
+ sectionDefaults: () => sectionDefaults,
46
+ themeVars: () => themeVars,
47
+ validatePage: () => validatePage,
48
+ validateTheme: () => validateTheme
49
+ });
50
+ module.exports = __toCommonJS(index_exports);
51
+
52
+ // src/schema/sections.js
53
+ var ICON_NAMES = [
54
+ "shield-check",
55
+ "badge-check",
56
+ "truck",
57
+ "wrench",
58
+ "refresh",
59
+ "battery",
60
+ "cpu",
61
+ "laptop",
62
+ "star",
63
+ "headset",
64
+ "rupee",
65
+ "award",
66
+ "clock",
67
+ "map-pin",
68
+ "phone",
69
+ "check",
70
+ "sparkles",
71
+ "tag",
72
+ "gift",
73
+ "heart"
74
+ ];
75
+ var TONES = [
76
+ { value: "plain", label: "Plain" },
77
+ { value: "tinted", label: "Brand tint" },
78
+ { value: "muted", label: "Muted" },
79
+ { value: "dark", label: "Dark" }
80
+ ];
81
+ var tone = (d = "plain") => ({ key: "tone", kind: "select", label: "Background", options: TONES, default: d });
82
+ var title = (d = "", max = 90) => ({ key: "title", kind: "text", label: "Heading", max, default: d });
83
+ var intro = { key: "intro", kind: "textarea", label: "Intro", max: 240, default: "" };
84
+ var ctaLabel = { key: "cta_label", kind: "text", label: "Button text", max: 30, default: "" };
85
+ var ctaHref = { key: "cta_href", kind: "url", label: "Button link", default: "" };
86
+ var SECTION_TYPES = {
87
+ hero_slider: {
88
+ label: "Hero slider",
89
+ description: "Full-width slides with an image, heading, text and a button.",
90
+ fields: [
91
+ {
92
+ key: "slides",
93
+ kind: "list",
94
+ label: "Slides",
95
+ min: 1,
96
+ max: 6,
97
+ itemLabel: "heading",
98
+ of: [
99
+ { key: "image", kind: "image", label: "Image (wide, 16:7)", default: null },
100
+ { key: "mobile_image", kind: "image", label: "Phone image (4:5, optional)", default: null },
101
+ { key: "eyebrow", kind: "text", label: "Small label above", max: 60, default: "" },
102
+ { key: "heading", kind: "text", label: "Heading", max: 90, required: true, default: "Your headline" },
103
+ { key: "text", kind: "textarea", label: "Text", max: 220, default: "" },
104
+ ctaLabel,
105
+ ctaHref,
106
+ { key: "align", kind: "select", label: "Text position", options: [{ value: "start", label: "Left" }, { value: "center", label: "Centre" }], default: "start" },
107
+ { key: "overlay", kind: "select", label: "Darken image", options: [{ value: "none", label: "None" }, { value: "light", label: "Light" }, { value: "strong", label: "Strong" }], default: "light" }
108
+ ],
109
+ default: [{ heading: "Your headline" }]
110
+ },
111
+ { key: "height", kind: "select", label: "Height", options: [{ value: "compact", label: "Compact" }, { value: "standard", label: "Standard" }, { value: "tall", label: "Tall" }], default: "standard" },
112
+ { key: "autoplay", kind: "bool", label: "Move to the next slide automatically", default: true },
113
+ { key: "interval", kind: "select", label: "Seconds per slide", options: [{ value: "4", label: "4" }, { value: "6", label: "6" }, { value: "8", label: "8" }], default: "6" }
114
+ ]
115
+ },
116
+ featured: {
117
+ label: "Featured products",
118
+ description: "The store's newest products (filled in by the app).",
119
+ slot: true,
120
+ fields: [title("Featured"), intro, { key: "limit", kind: "select", label: "How many", options: [{ value: "4", label: "4" }, { value: "8", label: "8" }, { value: "12", label: "12" }], default: "8" }, tone()]
121
+ },
122
+ categories: {
123
+ label: "Shop by category",
124
+ description: "Category tiles (filled in by the app).",
125
+ slot: true,
126
+ fields: [title("Shop by category"), tone()]
127
+ },
128
+ promo_banner: {
129
+ label: "Promo banner",
130
+ description: "A highlighted offer with an optional image and button.",
131
+ fields: [
132
+ { key: "eyebrow", kind: "text", label: "Small label above", max: 60, default: "" },
133
+ { key: "heading", kind: "text", label: "Heading", max: 90, required: true, default: "Festive offer" },
134
+ { key: "text", kind: "textarea", label: "Text", max: 240, default: "" },
135
+ ctaLabel,
136
+ ctaHref,
137
+ { key: "image", kind: "image", label: "Image (optional)", default: null },
138
+ { key: "style", kind: "select", label: "Style", options: [{ value: "brand", label: "Brand colour" }, { value: "accent", label: "Accent colour" }, { value: "dark", label: "Dark" }], default: "brand" }
139
+ ]
140
+ },
141
+ trust_badges: {
142
+ label: "Trust badges",
143
+ description: "Why buy from you: icon, title and a line each.",
144
+ fields: [
145
+ title("Why buy from us"),
146
+ intro,
147
+ {
148
+ key: "items",
149
+ kind: "list",
150
+ label: "Badges",
151
+ min: 1,
152
+ max: 8,
153
+ itemLabel: "title",
154
+ of: [
155
+ { key: "icon", kind: "icon", label: "Icon", default: "shield-check" },
156
+ { key: "title", kind: "text", label: "Title", max: 50, required: true, default: "Inspected and graded" },
157
+ { key: "text", kind: "textarea", label: "Text", max: 140, default: "" }
158
+ ],
159
+ default: [{ title: "Inspected and graded" }]
160
+ },
161
+ tone()
162
+ ]
163
+ },
164
+ cta_cards: {
165
+ label: "Action cards",
166
+ description: "Two to four cards that each lead somewhere (repairs, sell your laptop\u2026).",
167
+ fields: [
168
+ title("", 90),
169
+ {
170
+ key: "items",
171
+ kind: "list",
172
+ label: "Cards",
173
+ min: 1,
174
+ max: 4,
175
+ itemLabel: "title",
176
+ of: [
177
+ { key: "icon", kind: "icon", label: "Icon", default: "wrench" },
178
+ { key: "title", kind: "text", label: "Title", max: 50, required: true, default: "Book a repair" },
179
+ { key: "text", kind: "textarea", label: "Text", max: 140, default: "" },
180
+ { key: "cta_label", kind: "text", label: "Link text", max: 30, default: "Learn more" },
181
+ { key: "cta_href", kind: "url", label: "Link", required: true, default: "/" }
182
+ ],
183
+ default: [{ title: "Book a repair", cta_href: "/" }]
184
+ },
185
+ tone()
186
+ ]
187
+ },
188
+ testimonials: {
189
+ label: "Testimonials",
190
+ description: "What customers say.",
191
+ fields: [
192
+ title("What customers say"),
193
+ {
194
+ key: "items",
195
+ kind: "list",
196
+ label: "Quotes",
197
+ min: 1,
198
+ max: 12,
199
+ itemLabel: "name",
200
+ of: [
201
+ { key: "quote", kind: "textarea", label: "Quote", max: 400, required: true, default: "Great service." },
202
+ { key: "name", kind: "text", label: "Name", max: 60, required: true, default: "A happy customer" },
203
+ { key: "detail", kind: "text", label: "Detail (city, purchase)", max: 80, default: "" },
204
+ { key: "rating", kind: "select", label: "Stars", options: ["5", "4", "3", "2", "1"].map((v) => ({ value: v, label: v })), default: "5" }
205
+ ],
206
+ default: [{ quote: "Great service.", name: "A happy customer" }]
207
+ },
208
+ tone("muted")
209
+ ]
210
+ },
211
+ faq: {
212
+ label: "FAQ",
213
+ description: "Questions and answers (also used for search snippets).",
214
+ fields: [
215
+ title("Frequently asked questions"),
216
+ intro,
217
+ {
218
+ key: "items",
219
+ kind: "list",
220
+ label: "Questions",
221
+ min: 1,
222
+ max: 20,
223
+ itemLabel: "q",
224
+ of: [
225
+ { key: "q", kind: "text", label: "Question", max: 160, required: true, default: "A question?" },
226
+ { key: "a", kind: "textarea", label: "Answer", max: 800, required: true, default: "The answer." }
227
+ ],
228
+ default: [{ q: "A question?", a: "The answer." }]
229
+ },
230
+ tone("muted")
231
+ ]
232
+ },
233
+ rich_text: {
234
+ label: "Text",
235
+ description: "Free text with headings, lists and links.",
236
+ fields: [title("", 90), { key: "html", kind: "richtext", label: "Text", max: 8e3, default: "<p>Write something about your store.</p>" }, { key: "width", kind: "select", label: "Width", options: [{ value: "narrow", label: "Narrow" }, { value: "wide", label: "Wide" }], default: "narrow" }, tone()]
237
+ },
238
+ usp_strip: {
239
+ label: "Highlights strip",
240
+ description: "A short row of selling points under the hero.",
241
+ fields: [
242
+ {
243
+ key: "items",
244
+ kind: "list",
245
+ label: "Highlights",
246
+ min: 2,
247
+ max: 6,
248
+ itemLabel: "title",
249
+ of: [
250
+ { key: "icon", kind: "icon", label: "Icon", default: "check" },
251
+ { key: "title", kind: "text", label: "Title", max: 40, required: true, default: "Free delivery" },
252
+ { key: "text", kind: "text", label: "Line", max: 80, default: "" }
253
+ ],
254
+ default: [{ title: "Free delivery" }, { title: "Warranty included" }]
255
+ },
256
+ tone("tinted")
257
+ ]
258
+ },
259
+ brand_strip: {
260
+ label: "Brands we sell",
261
+ description: "Brand names or logos.",
262
+ fields: [
263
+ title("Brands we sell"),
264
+ {
265
+ key: "items",
266
+ kind: "list",
267
+ label: "Brands",
268
+ min: 1,
269
+ max: 16,
270
+ itemLabel: "name",
271
+ of: [
272
+ { key: "name", kind: "text", label: "Brand", max: 40, required: true, default: "Brand" },
273
+ { key: "logo", kind: "image", label: "Logo (optional)", default: null }
274
+ ],
275
+ default: [{ name: "Brand" }]
276
+ },
277
+ tone()
278
+ ]
279
+ },
280
+ newsletter: {
281
+ label: "Newsletter sign-up",
282
+ description: "Email sign-up with consent; the app decides where it goes.",
283
+ fields: [
284
+ { key: "title", kind: "text", label: "Heading", max: 90, required: true, default: "Get new arrivals first" },
285
+ { key: "text", kind: "textarea", label: "Text", max: 200, default: "" },
286
+ { key: "button_label", kind: "text", label: "Button text", max: 30, required: true, default: "Subscribe" },
287
+ { key: "consent_text", kind: "text", label: "Consent line", max: 160, required: true, default: "I agree to receive emails from this store. I can unsubscribe any time." },
288
+ { key: "success_text", kind: "text", label: "Thank-you message", max: 120, default: "Thanks! You are on the list." },
289
+ tone("tinted")
290
+ ]
291
+ },
292
+ gallery: {
293
+ label: "Gallery",
294
+ description: "Photos of your store, workshop or products.",
295
+ fields: [
296
+ title("Inside our store"),
297
+ {
298
+ key: "items",
299
+ kind: "list",
300
+ label: "Photos",
301
+ min: 1,
302
+ max: 12,
303
+ itemLabel: "caption",
304
+ of: [
305
+ { key: "image", kind: "image", label: "Photo", required: true, default: null },
306
+ { key: "caption", kind: "text", label: "Caption", max: 100, default: "" }
307
+ ],
308
+ default: []
309
+ },
310
+ { key: "columns", kind: "select", label: "Columns", options: [{ value: "2", label: "2" }, { value: "3", label: "3" }, { value: "4", label: "4" }], default: "3" },
311
+ tone()
312
+ ]
313
+ }
314
+ };
315
+ var SECTION_TYPE_KEYS = Object.keys(SECTION_TYPES);
316
+ var PAGE_VERSION = 1;
317
+ var MAX_SECTIONS = 20;
318
+ function sectionDefaults(type) {
319
+ const def = SECTION_TYPES[type];
320
+ if (!def) throw new Error(`Unknown section type "${type}"`);
321
+ const fill = (fields2) => Object.fromEntries(fields2.map((f) => [f.key, f.kind === "list" ? (f.default || []).map((item) => ({ ...fill(f.of), ...item })) : f.default]));
322
+ return fill(def.fields);
323
+ }
324
+ var seq = 0;
325
+ function newSectionId(type) {
326
+ seq = (seq + 1) % 1e6;
327
+ return `${type.replace(/_/g, "-").slice(0, 12)}-${Date.now().toString(36).slice(-4)}${seq.toString(36)}`;
328
+ }
329
+ function newSection(type) {
330
+ return { id: newSectionId(type), type, enabled: true, props: sectionDefaults(type) };
331
+ }
332
+
333
+ // src/schema/richtext.js
334
+ var ALLOWED = /* @__PURE__ */ new Set(["p", "br", "h2", "h3", "h4", "strong", "b", "em", "i", "u", "ul", "ol", "li", "a", "blockquote"]);
335
+ var VOID = /* @__PURE__ */ new Set(["br"]);
336
+ var DROP_WITH_CONTENT = /<(script|style|iframe|object|embed|template|noscript|svg|math|textarea|select|title|head)\b[\s\S]*?<\/\1\s*>/gi;
337
+ var TAG = /<!--[\s\S]*?-->|<\/?([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)>/g;
338
+ var escapeText = (s) => s.replace(/&(?!(?:[a-z][a-z0-9]{1,31}|#\d{1,7}|#x[0-9a-f]{1,6});)/gi, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
339
+ var escapeAttr = (s) => s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
340
+ var decode = (s) => s.replace(/&(amp|quot|#39|lt|gt|#x[0-9a-f]+|#\d+);/gi, (m, e) => {
341
+ const k = e.toLowerCase();
342
+ if (k === "amp") return "&";
343
+ if (k === "quot") return '"';
344
+ if (k === "#39") return "'";
345
+ if (k === "lt") return "<";
346
+ if (k === "gt") return ">";
347
+ const code = k.startsWith("#x") ? parseInt(k.slice(2), 16) : parseInt(k.slice(1), 10);
348
+ return Number.isFinite(code) && code < 1114112 ? String.fromCodePoint(code) : "";
349
+ });
350
+ function safeHref(raw) {
351
+ const v = decode(String(raw || "")).replace(/[\u0000- \u007f-\u009f]/g, "");
352
+ if (!v) return null;
353
+ if (/^(https?:\/\/|mailto:|tel:)/i.test(v)) return v;
354
+ if (/^\/(?!\/)/.test(v) || v.startsWith("#")) return v;
355
+ return null;
356
+ }
357
+ function hrefOf(attrs) {
358
+ var _a, _b;
359
+ const m = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/i.exec(attrs);
360
+ return m ? safeHref((_b = (_a = m[1]) != null ? _a : m[2]) != null ? _b : m[3]) : null;
361
+ }
362
+ function sanitizeRichText(html, { maxLength = 2e4 } = {}) {
363
+ const src = String(html == null ? "" : html).slice(0, maxLength * 4).replace(/<[a-zA-Z/!][^>]*$/, "").replace(DROP_WITH_CONTENT, "");
364
+ let out = "";
365
+ let last = 0;
366
+ for (const m of src.matchAll(TAG)) {
367
+ out += escapeText(src.slice(last, m.index));
368
+ last = m.index + m[0].length;
369
+ const name = (m[1] || "").toLowerCase();
370
+ if (!name || !ALLOWED.has(name)) continue;
371
+ const closing = m[0][1] === "/";
372
+ if (closing) {
373
+ if (!VOID.has(name)) out += `</${name}>`;
374
+ } else if (name === "a") {
375
+ const href = hrefOf(m[2] || "");
376
+ out += href ? `<a href="${escapeAttr(href)}" rel="nofollow noopener noreferrer">` : "<a>";
377
+ } else {
378
+ out += `<${name}>`;
379
+ }
380
+ }
381
+ out += escapeText(src.slice(last));
382
+ return out.length > maxLength ? out.slice(0, maxLength).replace(/<[^>]*$/, "") : out;
383
+ }
384
+
385
+ // src/schema/validate.js
386
+ var ID_RE = /^[a-z0-9][a-z0-9-]{0,39}$/;
387
+ var MEDIA_RE = /^[A-Za-z0-9_-]{1,64}$/;
388
+ var CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g;
389
+ function field(f, value, path, errors) {
390
+ var _a, _b, _c, _d;
391
+ const absent = value === void 0 || value === null;
392
+ const missing = absent || value === "";
393
+ switch (f.kind) {
394
+ case "text":
395
+ case "textarea": {
396
+ let v = absent ? (_a = f.default) != null ? _a : "" : value;
397
+ if (typeof v !== "string") {
398
+ errors.push({ path, message: "must be text" });
399
+ return (_b = f.default) != null ? _b : "";
400
+ }
401
+ v = v.replace(CONTROL, "");
402
+ v = f.kind === "text" ? v.replace(/\s+/g, " ").trim() : v.trim();
403
+ if (f.max && v.length > f.max) errors.push({ path, message: `must be at most ${f.max} characters` });
404
+ if (f.required && !v) errors.push({ path, message: "is required" });
405
+ return v.slice(0, f.max || v.length);
406
+ }
407
+ case "url": {
408
+ if (missing) {
409
+ if (f.required) errors.push({ path, message: "is required" });
410
+ return "";
411
+ }
412
+ const v = typeof value === "string" ? safeHref(value.trim()) : null;
413
+ if (!v || v.length > 500) {
414
+ errors.push({ path, message: "must be a page on this site (/\u2026), a full https:// address, mailto: or tel:" });
415
+ return "";
416
+ }
417
+ return v;
418
+ }
419
+ case "image": {
420
+ if (missing) {
421
+ if (f.required) errors.push({ path, message: "is required" });
422
+ return null;
423
+ }
424
+ if (typeof value !== "object" || !MEDIA_RE.test(String((_c = value.media_id) != null ? _c : ""))) {
425
+ errors.push({ path, message: "must be an uploaded image" });
426
+ return null;
427
+ }
428
+ return { media_id: typeof value.media_id === "number" ? value.media_id : String(value.media_id), alt: String((_d = value.alt) != null ? _d : "").replace(CONTROL, "").slice(0, 120) };
429
+ }
430
+ case "icon": {
431
+ const v = absent ? f.default : value;
432
+ if (!ICON_NAMES.includes(v)) {
433
+ errors.push({ path, message: `must be one of the icon names (${ICON_NAMES.slice(0, 4).join(", ")}\u2026)` });
434
+ return f.default;
435
+ }
436
+ return v;
437
+ }
438
+ case "select": {
439
+ const v = missing ? f.default : String(value);
440
+ if (!f.options.some((o) => o.value === v)) {
441
+ errors.push({ path, message: `must be one of ${f.options.map((o) => o.value).join(", ")}` });
442
+ return f.default;
443
+ }
444
+ return v;
445
+ }
446
+ case "bool":
447
+ if (missing) return f.default;
448
+ if (typeof value !== "boolean") {
449
+ errors.push({ path, message: "must be true or false" });
450
+ return f.default;
451
+ }
452
+ return value;
453
+ case "richtext": {
454
+ const raw = absent ? f.default : value;
455
+ if (typeof raw !== "string") {
456
+ errors.push({ path, message: "must be text" });
457
+ return f.default;
458
+ }
459
+ if (f.max && raw.length > f.max * 2) errors.push({ path, message: `must be at most ${f.max} characters` });
460
+ return sanitizeRichText(raw, { maxLength: f.max });
461
+ }
462
+ case "list": {
463
+ const arr = absent ? f.default || [] : value;
464
+ if (!Array.isArray(arr)) {
465
+ errors.push({ path, message: "must be a list" });
466
+ return [];
467
+ }
468
+ if (f.min && arr.length < f.min) errors.push({ path, message: `needs at least ${f.min}` });
469
+ if (f.max && arr.length > f.max) errors.push({ path, message: `can have at most ${f.max}` });
470
+ return arr.slice(0, f.max || arr.length).map((item, i) => fields(f.of, item, `${path}[${i}]`, errors));
471
+ }
472
+ default:
473
+ throw new Error(`Unknown field kind "${f.kind}" (${path})`);
474
+ }
475
+ }
476
+ function fields(defs, obj, path, errors) {
477
+ const src = obj && typeof obj === "object" && !Array.isArray(obj) ? obj : {};
478
+ if (obj != null && src !== obj) errors.push({ path, message: "must be an object" });
479
+ return Object.fromEntries(defs.map((f) => [f.key, field(f, Object.prototype.hasOwnProperty.call(src, f.key) ? src[f.key] : void 0, `${path}.${f.key}`, errors)]));
480
+ }
481
+ function validatePage(input) {
482
+ const errors = [];
483
+ const doc = input && typeof input === "object" && !Array.isArray(input) ? input : null;
484
+ if (!doc) return { ok: false, errors: [{ path: "page", message: "must be an object" }], page: { version: PAGE_VERSION, sections: [] } };
485
+ if (doc.version != null && doc.version !== PAGE_VERSION) errors.push({ path: "version", message: `must be ${PAGE_VERSION}` });
486
+ const list = Array.isArray(doc.sections) ? doc.sections : [];
487
+ if (!Array.isArray(doc.sections)) errors.push({ path: "sections", message: "must be a list" });
488
+ if (list.length > MAX_SECTIONS) errors.push({ path: "sections", message: `can have at most ${MAX_SECTIONS}` });
489
+ const seen = /* @__PURE__ */ new Set();
490
+ const sections = [];
491
+ list.slice(0, MAX_SECTIONS).forEach((s, i) => {
492
+ var _a, _b;
493
+ const path = `sections[${i}]`;
494
+ if (!s || typeof s !== "object") {
495
+ errors.push({ path, message: "must be an object" });
496
+ return;
497
+ }
498
+ const def = SECTION_TYPES[s.type];
499
+ if (!def) {
500
+ errors.push({ path: `${path}.type`, message: `unknown section type "${s.type}"` });
501
+ return;
502
+ }
503
+ const id = String((_a = s.id) != null ? _a : "");
504
+ if (!ID_RE.test(id)) errors.push({ path: `${path}.id`, message: "must be 1\u201340 lowercase letters, digits or dashes" });
505
+ else if (seen.has(id)) errors.push({ path: `${path}.id`, message: `duplicate id "${id}"` });
506
+ seen.add(id);
507
+ const enabled = s.enabled === void 0 ? true : s.enabled;
508
+ if (typeof enabled !== "boolean") errors.push({ path: `${path}.enabled`, message: "must be true or false" });
509
+ sections.push({ id, type: s.type, enabled: enabled !== false, props: fields(def.fields, (_b = s.props) != null ? _b : {}, `${path}.props`, errors) });
510
+ });
511
+ return { ok: errors.length === 0, errors, page: { version: PAGE_VERSION, sections } };
512
+ }
513
+ function defaultPage({ name = "Our store", tagline = "" } = {}) {
514
+ const hero = sectionDefaults("hero_slider");
515
+ hero.slides = [{ ...hero.slides[0], heading: String(name).slice(0, 90), text: String(tagline || "").slice(0, 220), cta_label: "Shop now", cta_href: "/" }];
516
+ return validatePage({
517
+ version: PAGE_VERSION,
518
+ sections: [
519
+ { id: "hero", type: "hero_slider", props: { ...hero, autoplay: false } },
520
+ { id: "featured", type: "featured", props: sectionDefaults("featured") },
521
+ { id: "categories", type: "categories", props: sectionDefaults("categories") }
522
+ ]
523
+ }).page;
524
+ }
525
+
526
+ // src/schema/color.js
527
+ var HEX_RE = /^#([0-9a-f]{6})$/i;
528
+ var isHex = (v) => typeof v === "string" && HEX_RE.test(v);
529
+ function hexToRgb(hex) {
530
+ const m = HEX_RE.exec(String(hex || ""));
531
+ if (!m) throw new Error(`Not a #rrggbb colour: ${hex}`);
532
+ const n = parseInt(m[1], 16);
533
+ return [n >> 16 & 255, n >> 8 & 255, n & 255];
534
+ }
535
+ function rgbToHex([r, g, b]) {
536
+ const c = (x) => Math.round(Math.min(255, Math.max(0, x))).toString(16).padStart(2, "0");
537
+ return `#${c(r)}${c(g)}${c(b)}`;
538
+ }
539
+ var toLinear = (c) => {
540
+ const x = c / 255;
541
+ return x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
542
+ };
543
+ var fromLinear = (x) => 255 * (x <= 31308e-7 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - 0.055);
544
+ function hexToOklch(hex) {
545
+ const [r, g, b] = hexToRgb(hex).map(toLinear);
546
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
547
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
548
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
549
+ const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
550
+ const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
551
+ const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
552
+ return { l: L, c: Math.hypot(A, B), h: Math.atan2(B, A) * 180 / Math.PI };
553
+ }
554
+ function oklchToLinear({ l, c, h }) {
555
+ const a = c * Math.cos(h * Math.PI / 180);
556
+ const b = c * Math.sin(h * Math.PI / 180);
557
+ const l_ = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3;
558
+ const m_ = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3;
559
+ const s_ = (l - 0.0894841775 * a - 1.291485548 * b) ** 3;
560
+ return [
561
+ 4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_,
562
+ -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_,
563
+ -0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_
564
+ ];
565
+ }
566
+ var inGamut = (rgb) => rgb.every((x) => x >= -1e-4 && x <= 1 + 1e-4);
567
+ function oklchToHex({ l, c, h }) {
568
+ let lo = 0;
569
+ let hi = c;
570
+ let rgb = oklchToLinear({ l, c, h });
571
+ if (!inGamut(rgb)) {
572
+ for (let i = 0; i < 24; i += 1) {
573
+ const mid = (lo + hi) / 2;
574
+ if (inGamut(oklchToLinear({ l, c: mid, h }))) lo = mid;
575
+ else hi = mid;
576
+ }
577
+ rgb = oklchToLinear({ l, c: lo, h });
578
+ }
579
+ return rgbToHex(rgb.map((x) => fromLinear(Math.min(1, Math.max(0, x)))));
580
+ }
581
+ var PALETTE_STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
582
+ var LIGHTNESS = { 50: 0.975, 100: 0.945, 200: 0.89, 300: 0.81, 400: 0.71, 500: 0.62, 700: 0.46, 800: 0.39, 900: 0.32, 950: 0.24 };
583
+ var CHROMA = { 50: 0.18, 100: 0.32, 200: 0.55, 300: 0.78, 400: 0.92, 500: 1, 700: 0.92, 800: 0.82, 900: 0.7, 950: 0.58 };
584
+ function brandPalette(hex) {
585
+ const base = hexToOklch(hex);
586
+ const out = { 600: rgbToHex(hexToRgb(hex)) };
587
+ for (const step of PALETTE_STEPS) {
588
+ if (step === 600) continue;
589
+ const l = step < 600 ? Math.max(LIGHTNESS[step], Math.min(0.985, base.l + (0.985 - base.l) * ((600 - step) / 600))) : Math.min(LIGHTNESS[step], Math.max(0.12, base.l - base.l * 0.6 * ((step - 600) / 350)));
590
+ out[step] = oklchToHex({ l, c: base.c * CHROMA[step], h: base.h });
591
+ }
592
+ return Object.fromEntries(PALETTE_STEPS.map((s) => [s, out[s]]));
593
+ }
594
+ function luminance(hex) {
595
+ const [r, g, b] = hexToRgb(hex).map(toLinear);
596
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
597
+ }
598
+ function contrastRatio(a, b) {
599
+ const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
600
+ return (x + 0.05) / (y + 0.05);
601
+ }
602
+ function readableOn(hex) {
603
+ return contrastRatio(hex, "#ffffff") >= contrastRatio(hex, "#101828") ? "#ffffff" : "#101828";
604
+ }
605
+
606
+ // src/schema/theme.js
607
+ var FONT_PAIRS = {
608
+ system: { label: "System (fastest)", heading: "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif", body: "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif", href: null },
609
+ inter: { label: "Inter", heading: "Inter, ui-sans-serif, system-ui, sans-serif", body: "Inter, ui-sans-serif, system-ui, sans-serif", href: "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" },
610
+ poppins: { label: "Poppins + Inter", heading: "Poppins, ui-sans-serif, system-ui, sans-serif", body: "Inter, ui-sans-serif, system-ui, sans-serif", href: "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Poppins:wght@600;700&display=swap" },
611
+ dmsans: { label: "DM Sans", heading: "'DM Sans', ui-sans-serif, system-ui, sans-serif", body: "'DM Sans', ui-sans-serif, system-ui, sans-serif", href: "https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&display=swap" },
612
+ nunito: { label: "Nunito (rounded)", heading: "Nunito, ui-rounded, ui-sans-serif, system-ui, sans-serif", body: "Nunito, ui-rounded, ui-sans-serif, system-ui, sans-serif", href: "https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;800&display=swap" },
613
+ serif: { label: "Merriweather + Source Sans", heading: "Merriweather, Georgia, 'Times New Roman', serif", body: "'Source Sans 3', ui-sans-serif, system-ui, sans-serif", href: "https://fonts.googleapis.com/css2?family=Merriweather:wght@700&family=Source+Sans+3:wght@400;600&display=swap" }
614
+ };
615
+ var RADII = { none: ["0", "0"], sm: ["0.375rem", "0.5rem"], md: ["0.75rem", "1rem"], lg: ["1rem", "1.5rem"], full: ["1.5rem", "2rem"] };
616
+ var MODES = ["light", "dark", "auto"];
617
+ var THEME_PRESETS = {
618
+ classic: { label: "Classic blue", primary: "#2563eb", accent: "#f59e0b", font: "inter", radius: "md", mode: "light" },
619
+ forest: { label: "Forest", primary: "#15803d", accent: "#ca8a04", font: "dmsans", radius: "lg", mode: "light" },
620
+ sunset: { label: "Sunset", primary: "#c2410c", accent: "#0369a1", font: "poppins", radius: "md", mode: "light" },
621
+ midnight: { label: "Midnight", primary: "#4338ca", accent: "#0891b2", font: "inter", radius: "lg", mode: "dark" },
622
+ slate: { label: "Minimal slate", primary: "#334155", accent: "#0f766e", font: "system", radius: "sm", mode: "light" },
623
+ berry: { label: "Berry", primary: "#be185d", accent: "#6d28d9", font: "nunito", radius: "full", mode: "light" }
624
+ };
625
+ var { label: _classicLabel, ...classic } = THEME_PRESETS.classic;
626
+ var DEFAULT_THEME = { preset: "classic", ...classic, logo: null, favicon: null };
627
+ var mediaRef = (v, path, errors, { alt = false } = {}) => {
628
+ if (v == null || v === "") return null;
629
+ if (typeof v !== "object" || v.media_id == null || !/^[A-Za-z0-9_-]{1,64}$/.test(String(v.media_id))) {
630
+ errors.push({ path, message: "must be an uploaded image" });
631
+ return null;
632
+ }
633
+ const ref = { media_id: typeof v.media_id === "number" ? v.media_id : String(v.media_id) };
634
+ if (alt) ref.alt = String(v.alt == null ? "" : v.alt).slice(0, 120);
635
+ return ref;
636
+ };
637
+ function validateTheme(input) {
638
+ const errors = [];
639
+ const warnings = [];
640
+ const t = input && typeof input === "object" && !Array.isArray(input) ? input : {};
641
+ if (input != null && t !== input) errors.push({ path: "theme", message: "must be an object" });
642
+ const preset = t.preset == null || t.preset === "" ? null : String(t.preset);
643
+ if (preset && !THEME_PRESETS[preset] && preset !== "custom") errors.push({ path: "preset", message: `must be one of ${Object.keys(THEME_PRESETS).join(", ")} or custom` });
644
+ const base = preset && THEME_PRESETS[preset] || THEME_PRESETS.classic;
645
+ const color = (key) => {
646
+ const v = t[key] == null || t[key] === "" ? base[key] : String(t[key]).toLowerCase();
647
+ if (!isHex(v)) {
648
+ errors.push({ path: key, message: "must be a colour like #2563eb" });
649
+ return base[key];
650
+ }
651
+ return v;
652
+ };
653
+ const pick = (key, allowed, fallback) => {
654
+ const v = t[key] == null || t[key] === "" ? fallback : String(t[key]);
655
+ if (!allowed.includes(v)) {
656
+ errors.push({ path: key, message: `must be one of ${allowed.join(", ")}` });
657
+ return fallback;
658
+ }
659
+ return v;
660
+ };
661
+ const theme = {
662
+ preset: preset || "classic",
663
+ primary: color("primary"),
664
+ accent: color("accent"),
665
+ font: pick("font", Object.keys(FONT_PAIRS), base.font),
666
+ radius: pick("radius", Object.keys(RADII), base.radius),
667
+ mode: pick("mode", MODES, base.mode),
668
+ logo: mediaRef(t.logo, "logo", errors, { alt: true }),
669
+ favicon: mediaRef(t.favicon, "favicon", errors)
670
+ };
671
+ const onBrand = contrastRatio(theme.primary, readableOn(theme.primary));
672
+ if (onBrand < 4.5) warnings.push({ path: "primary", message: `Button text on this colour is hard to read (contrast ${onBrand.toFixed(1)}:1, needs 4.5:1).` });
673
+ const onPage = contrastRatio(theme.primary, "#ffffff");
674
+ if (onPage < 3) warnings.push({ path: "primary", message: `Links in this colour are hard to see on white (contrast ${onPage.toFixed(1)}:1, needs 3:1).` });
675
+ const accentText = contrastRatio(theme.accent, readableOn(theme.accent));
676
+ if (accentText < 4.5) warnings.push({ path: "accent", message: `Text on the accent colour is hard to read (contrast ${accentText.toFixed(1)}:1, needs 4.5:1).` });
677
+ return { ok: errors.length === 0, errors, warnings, theme };
678
+ }
679
+ function themeVars(input) {
680
+ const { theme } = validateTheme(input);
681
+ const font = FONT_PAIRS[theme.font];
682
+ const [radius, radiusLg] = RADII[theme.radius];
683
+ return {
684
+ theme,
685
+ font,
686
+ palette: { primary: brandPalette(theme.primary), accent: brandPalette(theme.accent) },
687
+ style: {
688
+ "--dt-brand": theme.primary,
689
+ "--dt-brand-accent": theme.accent,
690
+ "--dt-brand-contrast": readableOn(theme.primary),
691
+ "--dt-brand-accent-contrast": readableOn(theme.accent),
692
+ "--dt-lp-font": font.body,
693
+ "--dt-lp-font-heading": font.heading,
694
+ "--dt-lp-radius": radius,
695
+ "--dt-lp-radius-lg": radiusLg
696
+ }
697
+ };
698
+ }
699
+ // Annotate the CommonJS export names for ESM import in node:
700
+ 0 && (module.exports = {
701
+ DEFAULT_THEME,
702
+ FONT_PAIRS,
703
+ ICON_NAMES,
704
+ MAX_SECTIONS,
705
+ MODES,
706
+ PAGE_VERSION,
707
+ PALETTE_STEPS,
708
+ RADII,
709
+ SECTION_TYPES,
710
+ SECTION_TYPE_KEYS,
711
+ THEME_PRESETS,
712
+ brandPalette,
713
+ contrastRatio,
714
+ defaultPage,
715
+ hexToOklch,
716
+ isHex,
717
+ luminance,
718
+ newSection,
719
+ newSectionId,
720
+ oklchToHex,
721
+ readableOn,
722
+ safeHref,
723
+ sanitizeRichText,
724
+ sectionDefaults,
725
+ themeVars,
726
+ validatePage,
727
+ validateTheme
728
+ });