@universalcms/connect 0.13.3 → 0.18.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.
Files changed (32) hide show
  1. package/README.md +6 -4
  2. package/dist/cli.mjs +7 -5
  3. package/dist/guided.mjs +79 -3
  4. package/dist/native-website-server.mjs +47 -16
  5. package/dist/native-website.mjs +28 -23
  6. package/dist/publication.css +5 -0
  7. package/dist/publication.mjs +10 -4
  8. package/dist/react-publication.mjs +23 -1
  9. package/dist/react-website.d.mts +1 -1
  10. package/dist/react-website.mjs +5 -3
  11. package/dist/templates/next-theme/app/[[...cmsPath]]/page.tsx +13 -4
  12. package/dist/templates/next-theme/app/api/cms-source/route.ts +10 -0
  13. package/dist/templates/next-theme/app/api/universalcms/[endpoint]/route.ts +2 -2
  14. package/dist/templates/next-theme/app/api/universalcms/form/route.ts +8 -0
  15. package/dist/templates/next-theme/app/cms-preview/page.tsx +28 -0
  16. package/dist/templates/next-theme/app/globals.css +94 -10
  17. package/dist/templates/next-theme/app/layout.tsx +11 -1
  18. package/dist/templates/next-theme/app/not-found.tsx +6 -3
  19. package/dist/templates/next-theme/app/robots.txt/route.ts +2 -2
  20. package/dist/templates/next-theme/app/sitemap.xml/route.ts +2 -2
  21. package/dist/templates/next-theme/components/site-chrome.tsx +38 -0
  22. package/dist/templates/next-theme/lib/universalcms.ts +11 -0
  23. package/dist/templates/next-theme/next.config.mjs +17 -1
  24. package/dist/theme-client.d.mts +4 -0
  25. package/dist/theme-client.mjs +70 -0
  26. package/dist/theme-preview.mjs +149 -0
  27. package/dist/theme.d.mts +9 -2
  28. package/dist/theme.mjs +190 -3
  29. package/dist/vendor/react-document.mjs +12 -3
  30. package/dist/vendor/react-preview-protocol.mjs +45 -0
  31. package/dist/website-schema.mjs +19 -4
  32. package/package.json +4 -2
@@ -0,0 +1,149 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
4
+ import { CmsDocument } from './vendor/react-document.mjs';
5
+ import { previewOrigin, readPreviewMessage } from './vendor/react-preview-protocol.mjs';
6
+ /** Mount exclusively on the authenticated, no-store preview route. */
7
+ export function CmsPreview({ document: initial, entryId, adminOrigin, expiresAt, render, ...props }) {
8
+ const origin = previewOrigin(adminOrigin);
9
+ const expiry = Date.parse(expiresAt);
10
+ const [document, setDocument] = useState(initial), [selectedId, setSelectedId] = useState(null);
11
+ const root = useRef(null), editing = useRef(null), pending = useRef(null);
12
+ const [toolbar, setToolbar] = useState(null);
13
+ const [dragging, setDragging] = useState(null), [dropTarget, setDropTarget] = useState(null);
14
+ const [expired, setExpired] = useState(!Number.isFinite(expiry) || expiry <= Date.now());
15
+ useEffect(() => { setDocument(initial); setSelectedId(null); }, [initial, entryId]);
16
+ useEffect(() => {
17
+ const remaining = expiry - Date.now();
18
+ setExpired(!Number.isFinite(expiry) || remaining <= 0);
19
+ if (!Number.isFinite(expiry) || remaining <= 0)
20
+ return;
21
+ const timeout = setTimeout(() => setExpired(true), Math.min(remaining, 2147483647));
22
+ return () => clearTimeout(timeout);
23
+ }, [expiry]);
24
+ useEffect(() => {
25
+ if (window.parent === window || expired || !Number.isFinite(expiry) || expiry <= Date.now())
26
+ return;
27
+ const parent = window.parent;
28
+ const ready = () => { if (Date.now() < expiry)
29
+ parent.postMessage({ source: 'universal-cms-site', type: 'ready', entryId }, origin); };
30
+ const receive = (event) => {
31
+ if (Date.now() >= expiry) {
32
+ setExpired(true);
33
+ return;
34
+ }
35
+ const message = readPreviewMessage(event, { origin, parent, entryId });
36
+ if (!message)
37
+ return;
38
+ // Never replace text under the caret; the edit is already in the admin's copy.
39
+ if (message.type === 'document') {
40
+ if (!editing.current)
41
+ setDocument(message.document);
42
+ else
43
+ pending.current = message.document;
44
+ }
45
+ else if (message.type === 'select')
46
+ setSelectedId(message.id);
47
+ else
48
+ ready();
49
+ };
50
+ window.addEventListener('message', receive);
51
+ ready();
52
+ return () => window.removeEventListener('message', receive);
53
+ }, [entryId, origin, expiry, expired]);
54
+ function select(event) {
55
+ if (expired || !Number.isFinite(expiry) || Date.now() >= expiry)
56
+ return;
57
+ if (!(event.target instanceof Element))
58
+ return;
59
+ const block = event.target.closest('[data-cms-block]');
60
+ if (!block || !event.currentTarget.contains(block))
61
+ return;
62
+ // A preview click selects the block without navigating away or submitting forms.
63
+ if (event.target.closest('.cms-inline-editing'))
64
+ return;
65
+ if (event.target.closest('a,button,input,select,textarea'))
66
+ event.preventDefault();
67
+ const id = block.dataset.cmsBlock;
68
+ if (!id || window.parent === window)
69
+ return;
70
+ setSelectedId(id);
71
+ window.parent.postMessage({ source: 'universal-cms-site', type: 'select', entryId, id }, origin);
72
+ }
73
+ const send = (message) => { if (window.parent !== window && Date.now() < expiry)
74
+ window.parent.postMessage({ source: 'universal-cms-site', entryId, ...message }, origin); };
75
+ // Inline text editing: the selected text block becomes editable on the page itself.
76
+ useLayoutEffect(() => {
77
+ const container = root.current;
78
+ const block = selectedId && container ? container.querySelector(`[data-cms-block="${CSS.escape(selectedId)}"]`) : null;
79
+ if (!container || !block || expired) {
80
+ setToolbar(null);
81
+ return;
82
+ }
83
+ const box = block.getBoundingClientRect(), frame = container.getBoundingClientRect();
84
+ setToolbar({ top: box.top - frame.top, left: box.right - frame.left });
85
+ const target = block.querySelector('h1,h2,h3,h4,p,blockquote,pre,ul,ol,a.cms-button') ?? null;
86
+ const editable = target && !target.querySelector('[data-cms-block]') ? target : null;
87
+ if (!editable)
88
+ return;
89
+ try {
90
+ editable.contentEditable = 'plaintext-only';
91
+ }
92
+ catch {
93
+ editable.contentEditable = 'true';
94
+ }
95
+ editable.spellcheck = true;
96
+ editable.classList.add('cms-inline-editing');
97
+ const text = () => editable.matches('ul,ol') ? [...editable.querySelectorAll('li')].map(item => item.innerText).join('\n') : editable.innerText;
98
+ const onInput = () => send({ type: 'text', id: selectedId, text: text().slice(0, 30000) });
99
+ const onFocus = () => { editing.current = editable; };
100
+ const onBlur = () => { editing.current = null; if (pending.current) {
101
+ setDocument(pending.current);
102
+ pending.current = null;
103
+ } };
104
+ const onKey = (event) => { if (event.key === 'Escape')
105
+ editable.blur(); if (event.key === 'Enter' && !editable.matches('ul,ol,pre,blockquote,p'))
106
+ event.preventDefault(); };
107
+ editable.addEventListener('input', onInput);
108
+ editable.addEventListener('focus', onFocus);
109
+ editable.addEventListener('blur', onBlur);
110
+ editable.addEventListener('keydown', onKey);
111
+ return () => {
112
+ editable.removeEventListener('input', onInput);
113
+ editable.removeEventListener('focus', onFocus);
114
+ editable.removeEventListener('blur', onBlur);
115
+ editable.removeEventListener('keydown', onKey);
116
+ editable.removeAttribute('contenteditable');
117
+ editable.classList.remove('cms-inline-editing');
118
+ };
119
+ }, [selectedId, document, expired]);
120
+ if (expired || !Number.isFinite(expiry) || expiry <= Date.now())
121
+ return _jsx("p", { role: "status", children: "This preview has expired. Reopen it from the CMS." });
122
+ const action = (name) => { if (selectedId)
123
+ send({ type: 'action', id: selectedId, action: name }); };
124
+ // Drop on another block: the admin moves the dragged block before or after it.
125
+ const over = (event) => {
126
+ if (!dragging || !(event.target instanceof Element) || !root.current)
127
+ return;
128
+ const target = event.target.closest('[data-cms-block]');
129
+ if (!target || target.dataset.cmsBlock === dragging || target.closest(`[data-cms-block="${CSS.escape(dragging)}"]`)) {
130
+ setDropTarget(null);
131
+ return;
132
+ }
133
+ event.preventDefault();
134
+ event.dataTransfer.dropEffect = 'move';
135
+ const box = target.getBoundingClientRect(), frame = root.current.getBoundingClientRect(), position = event.clientY < box.top + box.height / 2 ? 'before' : 'after';
136
+ setDropTarget({ id: target.dataset.cmsBlock, position, top: (position === 'before' ? box.top : box.bottom) - frame.top, left: box.left - frame.left, width: box.width });
137
+ };
138
+ const drop = (event) => {
139
+ if (!dragging || !dropTarget)
140
+ return;
141
+ event.preventDefault();
142
+ send({ type: 'action', id: dragging, action: 'move', targetId: dropTarget.id, position: dropTarget.position });
143
+ setDragging(null);
144
+ setDropTarget(null);
145
+ };
146
+ return _jsxs("div", { ref: root, className: "cms-preview", style: { position: 'relative' }, onClickCapture: select, onDragOver: over, onDrop: drop, onDragLeave: event => { if (event.currentTarget === event.target)
147
+ setDropTarget(null); }, children: [dropTarget && _jsx("div", { className: "cms-preview-drop", "aria-hidden": "true", style: { position: 'absolute', top: dropTarget.top - 2, left: dropTarget.left, width: dropTarget.width, height: 4, borderRadius: 2, background: '#5b3fd6', zIndex: 30, pointerEvents: 'none' } }), toolbar && selectedId && _jsxs("div", { className: "cms-preview-toolbar", role: "toolbar", "aria-label": "Block actions", style: { position: 'absolute', top: Math.max(0, toolbar.top - 36), left: toolbar.left, transform: 'translateX(-100%)', zIndex: 20 }, onClick: event => event.stopPropagation(), children: [_jsx("span", { className: "cms-preview-drag", draggable: true, title: "Drag to move", "aria-label": "Drag to move", role: "button", onDragStart: event => { if (!selectedId)
148
+ return; event.dataTransfer.setData('text/x-cms-block', selectedId); event.dataTransfer.effectAllowed = 'move'; setDragging(selectedId); }, onDragEnd: () => { setDragging(null); setDropTarget(null); }, children: "\u283F" }), _jsx("button", { type: "button", title: "Move up", "aria-label": "Move up", onClick: () => action('up'), children: "\u2191" }), _jsx("button", { type: "button", title: "Move down", "aria-label": "Move down", onClick: () => action('down'), children: "\u2193" }), _jsx("button", { type: "button", title: "Duplicate", "aria-label": "Duplicate", onClick: () => action('duplicate'), children: "\u29C9" }), _jsx("button", { type: "button", title: "Delete", "aria-label": "Delete", onClick: () => action('delete'), children: "\u2715" })] }), render ? render(document, selectedId) : _jsx(CmsDocument, { ...props, document: document, entryId: entryId, preview: true, selectedId: selectedId })] });
149
+ }
package/dist/theme.d.mts CHANGED
@@ -2,5 +2,12 @@ import type { createWebsiteRuntime } from './website-runtime.mjs';
2
2
  export const THEME_MODE: 'theme';
3
3
  export type ThemeConfiguration = { version: 1; apiOrigin: string; siteId: string; environmentId: string; websiteOrigin: string; routes: { path: string; kind: string; slug: string; locale: string }[] };
4
4
  export function themeConfiguration(connection: { apiOrigin: string; siteId: string; environmentId: string; websiteOrigin: string; locale?: string }): ThemeConfiguration;
5
- export function createThemeRuntime(options: { configuration: ThemeConfiguration; env?: () => Record<string, string | undefined>; fetcher?: typeof fetch }): ReturnType<typeof createWebsiteRuntime>;
6
- export function createThemeHandler(options: { configuration: ThemeConfiguration; adminOrigin: string; env?: () => Record<string, string | undefined>; fetcher?: typeof fetch }): (request: Request) => Promise<Response>;
5
+ export type ThemeDesign = { colors: Record<'primary' | 'primaryText' | 'background' | 'surface' | 'text' | 'mutedText' | 'border', string>; typography: { bodyFont: string; headingFont: string; baseSize: number; headingWeight: number }; layout: { containerWidth: number; radius: number; spacing: 'compact' | 'comfortable' | 'spacious' }; buttons: { style: 'solid' | 'outline' | 'soft'; shape: 'square' | 'rounded' | 'pill'; uppercase: boolean }; header: { layout: 'left' | 'centered' | 'split'; sticky: boolean; ctaLabel: string; ctaUrl: string }; footer: { layout: 'simple' | 'centered' | 'columns'; showSocial: boolean } };
6
+ export const DEFAULT_DESIGN: ThemeDesign;
7
+ export function validDesign(value: unknown): ThemeDesign;
8
+ export function themeCss(design: unknown): string;
9
+ export function themeFontsHref(design: ThemeDesign): string | null;
10
+ export function createThemeRuntime(options: { configuration: ThemeConfiguration; env?: () => Record<string, string | undefined>; fetcher?: typeof fetch }): ReturnType<typeof createWebsiteRuntime> & { patterns(): Promise<ThemePattern[]>; design(): Promise<ThemeDesign>; preview(params: URLSearchParams): Promise<{ entryId: string; document: { title: string; excerpt?: string; blocks: unknown[] }; expiresAt: string; title: string } | null> };
11
+ export function createThemeHandler(options: { configuration: ThemeConfiguration; adminOrigin: string; imported?: () => Promise<unknown>; env?: () => Record<string, string | undefined>; fetcher?: typeof fetch }): (request: Request) => Promise<Response>;
12
+ export type ThemePattern = { id: string; name: string; block: any; slot?: 'header' | 'footer' | 'popup'; popup?: { delaySeconds: number; frequency: 'once' | 'session' | 'always' } };
13
+ export function applyGlobalSections<T>(entry: T, patterns: ThemePattern[]): T;
package/dist/theme.mjs CHANGED
@@ -5,30 +5,205 @@
5
5
  */
6
6
  import { createWebsiteRuntime } from './website-runtime.mjs';
7
7
  import { createWebsiteRequestHandler, WEBSITE_SECTIONS, WEBSITE_SETTINGS_PATH } from './website-http.mjs';
8
+ import { createSourceManifestProvider } from './vendor/source-manifest-provider.mjs';
9
+ import { publicPublishedEntry } from './publication.mjs';
8
10
 
9
11
  export const THEME_MODE = 'theme';
10
12
 
13
+ const FONTS = new Set(['system', 'Inter', 'Roboto', 'Open Sans', 'Lato', 'Poppins', 'Montserrat', 'Nunito', 'Merriweather', 'Playfair Display', 'Lora', 'Source Serif 4']);
14
+ const SERIF = new Set(['Merriweather', 'Playfair Display', 'Lora', 'Source Serif 4']);
15
+ export const DEFAULT_DESIGN = Object.freeze({
16
+ colors: { primary: '#4f46e5', primaryText: '#ffffff', background: '#ffffff', surface: '#f8fafc', text: '#0f172a', mutedText: '#475569', border: '#e2e8f0' },
17
+ typography: { bodyFont: 'system', headingFont: 'system', baseSize: 16, headingWeight: 700 },
18
+ layout: { containerWidth: 1100, radius: 8, spacing: 'comfortable' },
19
+ buttons: { style: 'solid', shape: 'rounded', uppercase: false },
20
+ header: { layout: 'split', sticky: false, ctaLabel: '', ctaUrl: '' },
21
+ footer: { layout: 'simple', showSocial: true },
22
+ });
23
+
24
+ /** Accept only the exact token shapes; anything else falls back to defaults. Values end up in CSS. */
25
+ export function validDesign(value) {
26
+ const hex = item => typeof item === 'string' && /^#[0-9a-fA-F]{6}$/.test(item);
27
+ const int = (item, min, max) => Number.isInteger(item) && item >= min && item <= max;
28
+ if (!value || typeof value !== 'object') return DEFAULT_DESIGN;
29
+ const { colors, typography, layout, buttons } = value;
30
+ const ok = colors && Object.keys(DEFAULT_DESIGN.colors).every(key => hex(colors[key]))
31
+ && typography && FONTS.has(typography.bodyFont) && FONTS.has(typography.headingFont) && int(typography.baseSize, 14, 20) && [500, 600, 700, 800].includes(typography.headingWeight)
32
+ && layout && int(layout.containerWidth, 720, 1440) && int(layout.radius, 0, 24) && ['compact', 'comfortable', 'spacious'].includes(layout.spacing)
33
+ && buttons && ['solid', 'outline', 'soft'].includes(buttons.style) && ['square', 'rounded', 'pill'].includes(buttons.shape) && typeof buttons.uppercase === 'boolean';
34
+ if (!ok) return DEFAULT_DESIGN;
35
+ // Header/footer presets were added later: missing or invalid values use defaults.
36
+ const link = item => item === '' || typeof item === 'string' && item.length <= 2048 && (/^\/(?!\/)[a-zA-Z0-9/_\-]*$/.test(item) || /^https:\/\/[^\s<>"'`]+$/.test(item));
37
+ const h = value.header, f = value.footer;
38
+ const header = h && ['left', 'centered', 'split'].includes(h.layout) && typeof h.sticky === 'boolean' && typeof h.ctaLabel === 'string' && h.ctaLabel.length <= 40 && link(h.ctaUrl) && !h.ctaLabel.trim() === !h.ctaUrl
39
+ ? { layout: h.layout, sticky: h.sticky, ctaLabel: h.ctaLabel.trim(), ctaUrl: h.ctaUrl } : { ...DEFAULT_DESIGN.header };
40
+ const footer = f && ['simple', 'centered', 'columns'].includes(f.layout) && typeof f.showSocial === 'boolean' ? { layout: f.layout, showSocial: f.showSocial } : { ...DEFAULT_DESIGN.footer };
41
+ return { colors: Object.fromEntries(Object.keys(DEFAULT_DESIGN.colors).map(key => [key, colors[key]])), typography: { bodyFont: typography.bodyFont, headingFont: typography.headingFont, baseSize: typography.baseSize, headingWeight: typography.headingWeight },
42
+ layout: { containerWidth: layout.containerWidth, radius: layout.radius, spacing: layout.spacing }, buttons: { style: buttons.style, shape: buttons.shape, uppercase: buttons.uppercase }, header, footer };
43
+ }
44
+
45
+ const stack = font => font === 'system' ? 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif' : `"${font}", ${SERIF.has(font) ? 'Georgia, serif' : 'ui-sans-serif, system-ui, sans-serif'}`;
46
+
47
+ /** Google Fonts stylesheet for the chosen allowlisted fonts, or null for system fonts. */
48
+ export function themeFontsHref(design) {
49
+ const fonts = [...new Set([design.typography.bodyFont, design.typography.headingFont])].filter(font => font !== 'system');
50
+ return fonts.length ? `https://fonts.googleapis.com/css2?${fonts.map(font => `family=${encodeURIComponent(font).replace(/%20/g, '+')}:wght@400;500;600;700;800`).join('&')}&display=swap` : null;
51
+ }
52
+
53
+ /** CSS custom properties and component rules from validated design tokens. */
54
+ export function themeCss(input) {
55
+ const design = validDesign(input), { colors, typography, layout, buttons } = design;
56
+ const gap = { compact: 0.75, comfortable: 1, spacious: 1.4 }[layout.spacing];
57
+ const radius = buttons.shape === 'pill' ? '999px' : buttons.shape === 'square' ? '0' : `${layout.radius}px`;
58
+ const solid = buttons.style === 'solid', soft = buttons.style === 'soft';
59
+ return `:root{--cms-primary:${colors.primary};--cms-primary-text:${colors.primaryText};--cms-background:${colors.background};--cms-surface:${colors.surface};--cms-text:${colors.text};--cms-muted:${colors.mutedText};--cms-border:${colors.border};--cms-website-accent:${colors.primary};--cms-font-body:${stack(typography.bodyFont)};--cms-font-heading:${stack(typography.headingFont)};--cms-font-size:${typography.baseSize}px;--cms-heading-weight:${typography.headingWeight};--cms-container:${layout.containerWidth}px;--cms-radius:${layout.radius}px;--cms-gap:${gap}rem;--cms-button-radius:${radius}}
60
+ .cms-website a.cms-button,a.cms-button{background:${solid ? 'var(--cms-primary)' : soft ? 'color-mix(in srgb,var(--cms-primary) 14%,transparent)' : 'transparent'};color:${solid ? 'var(--cms-primary-text)' : 'var(--cms-primary)'};border:2px solid var(--cms-primary);border-radius:var(--cms-button-radius);padding:.7rem 1.3rem;font-weight:600;text-decoration:none;text-transform:${buttons.uppercase ? 'uppercase' : 'none'};letter-spacing:${buttons.uppercase ? '.04em' : 'normal'}}
61
+ .cms-website a.cms-button-secondary,a.cms-button-secondary{background:transparent;color:var(--cms-text);border-color:var(--cms-border)}
62
+ .cms-website a.cms-button-link,a.cms-button-link{background:none;border:0;padding:0;color:var(--cms-primary);text-decoration:underline}`;
63
+ }
64
+
65
+ function deliveryUrl(configuration, section) {
66
+ const url = new URL(`/api/v1/delivery/sites/${configuration.siteId}/settings/${section}`, configuration.apiOrigin);
67
+ url.searchParams.set('environmentId', configuration.environmentId);
68
+ return url;
69
+ }
70
+
71
+ /** One saved section validated exactly like published content; anything invalid is dropped. */
72
+ function validPattern(entry) {
73
+ try {
74
+ if (!entry || typeof entry.id !== 'string' || !/^[0-9a-f-]{36}$/i.test(entry.id) || entry.block?.type !== 'section') return null;
75
+ const probe = publicPublishedEntry({ id: '00000000-0000-4000-8000-000000000000', kind: 'page', slug: 'probe', locale: 'en', version: 1, publishedAt: '2026-01-01T00:00:00.000Z', document: { schemaVersion: 1, title: 'probe', excerpt: '', blocks: [entry.block] } });
76
+ const slot = ['header', 'footer', 'popup'].includes(entry.slot) ? entry.slot : undefined;
77
+ const popup = slot === 'popup' && entry.popup && Number.isInteger(entry.popup.delaySeconds) && entry.popup.delaySeconds >= 0 && entry.popup.delaySeconds <= 120 && ['once', 'session', 'always'].includes(entry.popup.frequency) ? { delaySeconds: entry.popup.delaySeconds, frequency: entry.popup.frequency } : slot === 'popup' ? { delaySeconds: 5, frequency: 'once' } : undefined;
78
+ return { id: entry.id, name: String(entry.name ?? '').slice(0, 80), block: probe.document.blocks[0], ...(slot ? { slot } : {}), ...(popup ? { popup } : {}) };
79
+ } catch { return null; }
80
+ }
81
+
82
+ /** Linked (global) sections show the saved section's current content. */
83
+ export function applyGlobalSections(entry, patterns) {
84
+ if (!entry?.document?.blocks || !patterns?.length) return entry;
85
+ const byId = new Map(patterns.map(pattern => [pattern.id, pattern]));
86
+ const blocks = entry.document.blocks.map(block => {
87
+ const linked = block?.type === 'section' && byId.get(block.data?.globalId);
88
+ return linked ? { ...linked.block, id: block.id, data: { ...linked.block.data, globalId: linked.id } } : block;
89
+ });
90
+ return { ...entry, document: { ...entry.document, blocks } };
91
+ }
92
+
11
93
  /** Publication configuration for a theme website: only the home page needs a fixed route. */
12
94
  export function themeConfiguration({ apiOrigin, siteId, environmentId, websiteOrigin, locale = 'en' }) {
13
95
  return { version: 1, apiOrigin, siteId, environmentId, websiteOrigin, routes: [{ path: '/', kind: 'page', slug: 'home', locale }] };
14
96
  }
15
97
 
16
98
  export function createThemeRuntime({ configuration, env = () => process.env, fetcher = globalThis.fetch }) {
17
- return createWebsiteRuntime({ configuration, env, fetcher });
99
+ const runtime = createWebsiteRuntime({ configuration, env, fetcher });
100
+ return {
101
+ ...runtime,
102
+ /**
103
+ * Draft for the CMS visual editor. The CMS signs a short-lived preview token
104
+ * per entry; the API rechecks the editor and this website's key on every read.
105
+ */
106
+ async preview(params) {
107
+ try {
108
+ const one = name => { const values = params.getAll(name); return values.length === 1 ? values[0] : null; };
109
+ const entryId = one('entry'), token = one('token');
110
+ if (one('site') !== configuration.siteId || one('environment') !== configuration.environmentId || !/^[0-9a-f-]{36}$/i.test(entryId ?? '') || !token || token.length > 4000) return null;
111
+ const values = typeof env === 'function' ? await env() : env, key = values?.CMS_API_KEY;
112
+ if (typeof key !== 'string') return null;
113
+ const url = new URL(`/api/v1/delivery/sites/${configuration.siteId}/preview`, configuration.apiOrigin);
114
+ url.search = new URLSearchParams({ environmentId: configuration.environmentId, entryId, token }).toString();
115
+ const response = await fetcher(url, { headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' }, redirect: 'error', cache: 'no-store', signal: AbortSignal.timeout(10000) });
116
+ if (!response.ok) return null;
117
+ const body = await response.json(), item = body?.data?.item;
118
+ if (!item || item.id !== entryId || !item.document || !Array.isArray(item.document.blocks) || typeof body.data.expiresAt !== 'string') return null;
119
+ return { entryId, document: item.document, expiresAt: body.data.expiresAt, title: String(item.document.title ?? '') };
120
+ } catch { return null; }
121
+ },
122
+ /** Saved sections (global sections, header/footer, popups); empty when none or not readable. */
123
+ async patterns() {
124
+ try {
125
+ const values = typeof env === 'function' ? await env() : env, key = values?.CMS_API_KEY;
126
+ if (typeof key !== 'string') return [];
127
+ const response = await fetcher(deliveryUrl(configuration, 'patterns'), { headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' }, redirect: 'error', cache: 'no-store', signal: AbortSignal.timeout(5000) });
128
+ if (!response.ok) return [];
129
+ const text = await response.text();
130
+ if (text.length > 2 * 1024 * 1024) return [];
131
+ const body = JSON.parse(text), entries = body?.data?.section === 'patterns' && Array.isArray(body.data.data?.entries) ? body.data.data.entries : [];
132
+ return entries.slice(0, 50).map(validPattern).filter(Boolean);
133
+ } catch { return []; }
134
+ },
135
+ /** Saved design tokens, or defaults when none are saved or the key cannot read them. */
136
+ async design() {
137
+ try {
138
+ const values = typeof env === 'function' ? await env() : env, key = values?.CMS_API_KEY;
139
+ if (typeof key !== 'string' || !/^cms_server\.[0-9a-f-]{36}\.[0-9a-f-]{36}\.[A-Za-z0-9_-]{43}$/.test(key)) return DEFAULT_DESIGN;
140
+ const response = await fetcher(deliveryUrl(configuration, 'design'), { headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' }, redirect: 'error', cache: 'no-store', signal: AbortSignal.timeout(5000) });
141
+ if (!response.ok) return DEFAULT_DESIGN;
142
+ const text = await response.text();
143
+ if (text.length > 16 * 1024) return DEFAULT_DESIGN;
144
+ const body = JSON.parse(text);
145
+ return body?.data?.section === 'design' && body.data.data ? validDesign(body.data.data) : DEFAULT_DESIGN;
146
+ } catch { return DEFAULT_DESIGN; }
147
+ },
148
+ };
18
149
  }
19
150
 
20
151
  /**
21
152
  * Serves /api/universalcms/{settings,page,document}, /sitemap.xml and /robots.txt
22
153
  * through the shared website handler, and answers the CMS admin connection check.
23
154
  */
24
- export function createThemeHandler({ configuration, adminOrigin, env = () => process.env, fetcher = globalThis.fetch }) {
155
+ /** Pages and menus collected from a previous website, offered once for import into the CMS. */
156
+ function validImport(value, configuration) {
157
+ if (!value || value.version !== 1 || value.siteId !== configuration.siteId || value.environmentId !== configuration.environmentId || !Array.isArray(value.entries) || value.entries.length > 1000) return null;
158
+ const link = item => ({ label: String(item?.label ?? '').slice(0, 80), url: String(item?.url ?? '').slice(0, 200) });
159
+ const navigation = { header: (value.navigation?.header ?? []).slice(0, 20).map(link), footer: (value.navigation?.footer ?? []).slice(0, 20).map(link) };
160
+ const pages = (Array.isArray(value.pages) ? value.pages : []).slice(0, 100).map(page => ({ path: String(page?.path ?? '').slice(0, 200), title: String(page?.title ?? '').slice(0, 120), description: String(page?.description ?? '').slice(0, 320) }));
161
+ return { entries: value.entries, discovered: { version: 1, pages, navigation } };
162
+ }
163
+
164
+ function sourceResponse(provider, request) {
165
+ let status = 404, headers = { 'Cache-Control': 'no-store' }, body;
166
+ provider({ method: request.method, url: request.url, headers: Object.fromEntries(request.headers.entries()) }, { writeHead(code, values) { status = code; headers = values; }, end(value) { body = value; } });
167
+ return new Response(request.method === 'HEAD' || status === 204 ? null : body, { status, headers });
168
+ }
169
+
170
+ export function createThemeHandler({ configuration, adminOrigin, imported = async () => null, env = () => process.env, fetcher = globalThis.fetch }) {
25
171
  const website = createWebsiteRequestHandler({ configuration, env, fetcher });
26
172
  const runtime = createThemeRuntime({ configuration, env, fetcher });
27
173
  const admin = new URL(adminOrigin).origin;
28
174
  if (admin !== adminOrigin) throw new Error('Invalid CMS admin origin');
29
175
  const json = (body, status, extra = {}) => new Response(body === null ? null : JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', ...extra } });
176
+ /** Same-origin form posts are forwarded to the CMS with the website key; the visitor's address keeps rate limits per person. */
177
+ async function submitForm(request) {
178
+ if (request.method !== 'POST') return json({ error: 'Method not allowed' }, 405, { Allow: 'POST' });
179
+ const origin = request.headers.get('origin');
180
+ if (origin && origin !== configuration.websiteOrigin || request.headers.get('sec-fetch-site') === 'cross-site' || !(request.headers.get('content-type') ?? '').startsWith('application/json')) return json({ error: 'Request not allowed' }, 403);
181
+ const text = await request.text();
182
+ if (text.length > 64 * 1024) return json({ error: 'Request too large' }, 413);
183
+ let body; try { body = JSON.parse(text); } catch { return json({ error: 'Invalid request' }, 400); }
184
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return json({ error: 'Invalid request' }, 400);
185
+ const values = typeof env === 'function' ? await env() : env, key = values?.CMS_API_KEY;
186
+ if (typeof key !== 'string') return json({ error: 'Form unavailable' }, 503);
187
+ const target = new URL(`/api/v1/delivery/sites/${configuration.siteId}/forms`, configuration.apiOrigin);
188
+ target.searchParams.set('environmentId', configuration.environmentId);
189
+ const forwarded = (request.headers.get('x-forwarded-for') ?? '').split(',')[0].trim();
190
+ const address = /^[0-9a-fA-F:.]{2,45}$/.test(forwarded) ? forwarded : null;
191
+ try {
192
+ const upstream = await fetcher(target, { method: 'POST', headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json', ...(address ? { 'X-CMS-Client-IP': address } : {}) }, body: JSON.stringify(body), redirect: 'error', signal: AbortSignal.timeout(10000) });
193
+ const reply = await upstream.text();
194
+ return new Response(reply.length <= 16 * 1024 ? reply : '{}', { status: upstream.status, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } });
195
+ } catch { return json({ error: 'Form unavailable' }, 503); }
196
+ }
197
+ const migration = async () => { try { return validImport(await imported(), configuration); } catch { return null; } };
30
198
  return async function handle(request) {
31
199
  const url = new URL(request.url);
200
+ if (url.pathname === '/api/cms-source') {
201
+ // Open only while an import from the previous website is waiting; same rules as connected sites.
202
+ const pending = await migration();
203
+ if (!pending) return json({ version: 1, siteId: configuration.siteId, environmentId: configuration.environmentId, sourceExport: 'closed' }, 410, { 'Access-Control-Allow-Origin': admin, Vary: 'Origin' });
204
+ const provider = createSourceManifestProvider({ manifestJson: JSON.stringify({ version: 1, entries: pending.entries }), sourceOrigin: configuration.websiteOrigin, adminOrigin: admin, siteId: configuration.siteId, environmentId: configuration.environmentId, publicContent: true });
205
+ return sourceResponse(provider, request);
206
+ }
32
207
  if (url.pathname === WEBSITE_SETTINGS_PATH && url.searchParams.get('check') === 'connection') {
33
208
  const origin = request.headers.get('origin');
34
209
  if (origin !== admin || request.headers.has('authorization') || request.headers.has('cookie')) return json({ error: 'Request not allowed' }, 403);
@@ -41,10 +216,22 @@ export function createThemeHandler({ configuration, adminOrigin, env = () => pro
41
216
  return json({ error: 'Website scope mismatch', ...(sameSite ? { siteId: configuration.siteId, environmentId: configuration.environmentId } : {}) }, 409, cors);
42
217
  }
43
218
  const snapshot = await runtime.settings();
44
- return json({ version: 1, mode: THEME_MODE, siteId: configuration.siteId, environmentId: configuration.environmentId, renderedVerified: false,
219
+ const pending = await migration();
220
+ return json({ version: 1, mode: THEME_MODE, siteId: configuration.siteId, environmentId: configuration.environmentId, renderedVerified: false, ...(pending ? { discovered: pending.discovered, migration: true } : {}),
45
221
  settings: Object.fromEntries(WEBSITE_SECTIONS.map(section => [section, { installed: true, state: snapshot.sections[section]?.state ?? 'unavailable', version: snapshot.sections[section]?.version ?? 0 }])) }, 200, cors);
46
222
  }
223
+ if (url.pathname === '/api/universalcms/form') return submitForm(request);
47
224
  const result = await website(request);
225
+ if (result instanceof Response && url.pathname === '/api/universalcms/page' && result.status === 200) {
226
+ // Live page refreshes must show linked sections the same way as the first render.
227
+ try {
228
+ const body = await result.clone().json();
229
+ if (body?.state === 'published' && body.entry) {
230
+ const patterns = await runtime.patterns();
231
+ if (patterns.length) return new Response(JSON.stringify({ ...body, entry: applyGlobalSections(body.entry, patterns) }), { status: 200, headers: result.headers });
232
+ }
233
+ } catch { /* Fall back to the original response. */ }
234
+ }
48
235
  return result instanceof Response ? result : json({ error: 'Not found' }, 404);
49
236
  };
50
237
  }
@@ -4,10 +4,19 @@ import { CmsForm } from './react-form.mjs';
4
4
  import { embedUrl, integer, record, safeSrcSet, safeUrl, text } from './react-safety.mjs';
5
5
  export { CmsForm } from './react-form.mjs';
6
6
  const supported = new Set(['paragraph', 'heading', 'quote', 'list', 'code', 'image', 'gallery', 'button', 'divider', 'spacer', 'faq', 'table', 'embed', 'form', 'section', 'columns']);
7
- const styles = { align: ['left', 'center', 'right'], size: ['small', 'normal', 'large'], tone: ['default', 'muted', 'accent', 'inverse'], spaceAbove: ['none', 'small', 'medium', 'large'], spaceBelow: ['none', 'small', 'medium', 'large'], hideOnMobile: [true], hideOnDesktop: [true] };
7
+ const styles = { align: ['left', 'center', 'right'], size: ['small', 'normal', 'large'], tone: ['default', 'muted', 'accent', 'inverse'], spaceAbove: ['none', 'small', 'medium', 'large'], spaceBelow: ['none', 'small', 'medium', 'large'], hideOnMobile: [true], hideOnDesktop: [true], padding: ['none', 'small', 'medium', 'large'], radius: ['none', 'small', 'medium', 'large', 'pill'], border: [true], weight: ['normal', 'medium', 'bold'], maxWidth: ['narrow', 'normal', 'full'], animation: ['fade-up', 'fade-in', 'zoom-in', 'slide-left'] };
8
+ /** Validated colors become CSS variables on the block wrapper; nothing else reaches inline style. */
9
+ function blockColors(block) {
10
+ const style = record(block.style), hex = (value) => typeof value === 'string' && /^#[0-9a-fA-F]{6}$/.test(value) ? value : undefined;
11
+ const color = hex(style.textColor), background = hex(style.background);
12
+ if (!color && !background)
13
+ return undefined;
14
+ return { ...(color ? { '--cms-block-color': color, color } : {}), ...(background ? { '--cms-block-background': background, background } : {}) };
15
+ }
8
16
  function blockClasses(block) {
9
17
  const style = record(block.style);
10
- return ['cms-block', `cms-block-${text(block.type)}`, ...Object.entries(styles).flatMap(([key, allowed]) => allowed.includes(style[key]) ? [`cms-${key}-${String(style[key])}`] : [])].join(' ');
18
+ const mobile = record(style.mobile), phone = ['align', 'size', 'padding', 'spaceAbove', 'spaceBelow'];
19
+ return ['cms-block', `cms-block-${text(block.type)}`, ...Object.entries(styles).flatMap(([key, allowed]) => allowed.includes(style[key]) ? [`cms-${key}-${String(style[key])}`] : []), ...phone.flatMap(key => styles[key].includes(mobile[key]) ? [`cms-m-${key}-${String(mobile[key])}`] : [])].join(' ');
11
20
  }
12
21
  function image(data, key) {
13
22
  const src = safeUrl(data.url, false);
@@ -118,7 +127,7 @@ export function CmsDocument({ document, components = {}, entryId, revisionId, va
118
127
  }
119
128
  }
120
129
  const anchor = text(record(block.style).anchor);
121
- return _jsx("div", { id: /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(anchor) ? anchor : undefined, className: `${blockClasses(block)}${preview && id === selectedId ? ' cms-selected' : ''}`, ...(preview ? { 'data-cms-block': id, 'data-cms-type': type } : {}), children: node }, id || index);
130
+ return _jsx("div", { id: /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(anchor) ? anchor : undefined, className: `${blockClasses(block)}${preview && id === selectedId ? ' cms-selected' : ''}`, style: blockColors(block), ...(preview ? { 'data-cms-block': id, 'data-cms-type': type } : {}), children: node }, id || index);
122
131
  }
123
132
  return _jsx("div", { className: ['cms-document', className].filter(Boolean).join(' '), children: renderMany(document.blocks, 'root') });
124
133
  }
@@ -0,0 +1,45 @@
1
+ import { record, text } from './react-safety.mjs';
2
+ export function previewOrigin(value) {
3
+ const url = new URL(value);
4
+ const local = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
5
+ if (url.username || url.password || url.search || url.hash || url.pathname !== '/' || (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)))
6
+ throw new Error('Invalid CMS admin origin');
7
+ return url.origin;
8
+ }
9
+ /** Bounds messages before rendering; full content validation remains at the CMS write boundary. */
10
+ function isDocument(value) {
11
+ const doc = record(value);
12
+ if (doc.schemaVersion !== 1 || typeof doc.title !== 'string' || doc.title.length > 200 || typeof doc.excerpt !== 'string' || doc.excerpt.length > 1000 || !Array.isArray(doc.blocks) || doc.blocks.length > 200)
13
+ return false;
14
+ const seen = new Set();
15
+ function blocks(values, depth) {
16
+ if (!Array.isArray(values) || values.length > 400 || depth > 2)
17
+ return false;
18
+ for (const value of values) {
19
+ const block = record(value), data = record(block.data);
20
+ if (!text(block.id) || seen.has(String(block.id)) || seen.size >= 400 || typeof block.type !== 'string' || block.version !== 1 || typeof block.text !== 'string' || block.text.length > 30000)
21
+ return false;
22
+ seen.add(String(block.id));
23
+ if (block.type === 'section' && (depth !== 0 || !blocks(data.blocks, depth + 1)))
24
+ return false;
25
+ if (block.type === 'columns' && (depth > 1 || !Array.isArray(data.columns) || data.columns.length > 4 || !data.columns.every(column => blocks(column, depth + 1))))
26
+ return false;
27
+ }
28
+ return true;
29
+ }
30
+ return blocks(doc.blocks, 0);
31
+ }
32
+ export function readPreviewMessage(event, expected) {
33
+ if (event.origin !== expected.origin || event.source !== expected.parent)
34
+ return null;
35
+ const data = record(event.data);
36
+ if (data.source !== 'universal-cms' || data.entryId !== expected.entryId)
37
+ return null;
38
+ if (data.type === 'ping')
39
+ return { type: 'ping', entryId: expected.entryId };
40
+ if (data.type === 'select' && (data.id === null || (typeof data.id === 'string' && data.id.length <= 100)))
41
+ return { type: 'select', entryId: expected.entryId, id: data.id };
42
+ if (data.type === 'document' && isDocument(data.document))
43
+ return { type: 'document', entryId: expected.entryId, document: data.document };
44
+ return null;
45
+ }
@@ -745,8 +745,8 @@ var require_util = __commonJS({
745
745
  function uint8ArrayToBase64url(bytes) {
746
746
  return uint8ArrayToBase64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
747
747
  }
748
- function hexToUint8Array(hex) {
749
- const cleanHex = hex.replace(/^0x/, "");
748
+ function hexToUint8Array(hex2) {
749
+ const cleanHex = hex2.replace(/^0x/, "");
750
750
  if (cleanHex.length % 2 !== 0) {
751
751
  throw new Error("Invalid hex string length");
752
752
  }
@@ -19108,7 +19108,7 @@ var require_schemas2 = __commonJS({
19108
19108
  exports.jwt = jwt;
19109
19109
  exports.stringFormat = stringFormat;
19110
19110
  exports.hostname = hostname;
19111
- exports.hex = hex;
19111
+ exports.hex = hex2;
19112
19112
  exports.hash = hash;
19113
19113
  exports.number = number;
19114
19114
  exports.int = int;
@@ -19694,7 +19694,7 @@ var require_schemas2 = __commonJS({
19694
19694
  function hostname(_params) {
19695
19695
  return core._stringFormat(exports.ZodCustomStringFormat, "hostname", core.regexes.hostname, _params);
19696
19696
  }
19697
- function hex(_params) {
19697
+ function hex2(_params) {
19698
19698
  return core._stringFormat(exports.ZodCustomStringFormat, "hex", core.regexes.hex, _params);
19699
19699
  }
19700
19700
  function hash(alg, params) {
@@ -21929,7 +21929,11 @@ var link = import_zod.z.object({ label: import_zod.z.string().trim().min(1).max(
21929
21929
  var links = import_zod.z.array(link).max(40);
21930
21930
  var headerLink = link.extend({ url: link.shape.url.optional(), children: import_zod.z.array(link).max(20).optional() }).refine((value) => value.url !== void 0 || (value.children?.length ?? 0) > 0, { message: "Header links without dropdown children need a URL", path: ["url"] });
21931
21931
  var footerGroup = import_zod.z.object({ title: import_zod.z.string().trim().min(1).max(80), links: import_zod.z.array(link).max(20) });
21932
+ var designFonts = ["system", "Inter", "Roboto", "Open Sans", "Lato", "Poppins", "Montserrat", "Nunito", "Merriweather", "Playfair Display", "Lora", "Source Serif 4"];
21933
+ var hex = import_zod.z.string().regex(/^#[0-9a-fA-F]{6}$/);
21934
+ var designColors = import_zod.z.object({ primary: hex, primaryText: hex, background: hex, surface: hex, text: hex, mutedText: hex, border: hex });
21932
21935
  var configurationSchemas = {
21936
+ design: import_zod.z.object({ colors: designColors, typography: import_zod.z.object({ bodyFont: import_zod.z.enum(designFonts), headingFont: import_zod.z.enum(designFonts), baseSize: import_zod.z.number().int().min(14).max(20), headingWeight: import_zod.z.union([import_zod.z.literal(500), import_zod.z.literal(600), import_zod.z.literal(700), import_zod.z.literal(800)]) }), layout: import_zod.z.object({ containerWidth: import_zod.z.number().int().min(720).max(1440), radius: import_zod.z.number().int().min(0).max(24), spacing: import_zod.z.enum(["compact", "comfortable", "spacious"]) }), buttons: import_zod.z.object({ style: import_zod.z.enum(["solid", "outline", "soft"]), shape: import_zod.z.enum(["square", "rounded", "pill"]), uppercase: import_zod.z.boolean() }), header: import_zod.z.object({ layout: import_zod.z.enum(["left", "centered", "split"]), sticky: import_zod.z.boolean(), ctaLabel: import_zod.z.string().max(40), ctaUrl: import_zod.z.string().max(2048) }).optional(), footer: import_zod.z.object({ layout: import_zod.z.enum(["simple", "centered", "columns"]), showSocial: import_zod.z.boolean() }).optional() }),
21933
21937
  branding: import_zod.z.object({ description: import_zod.z.string().max(2e3), logoUrl: url, faviconUrl: url, logoAlt: text, accentColor: import_zod.z.string().regex(/^#[0-9a-fA-F]{6}$/) }),
21934
21938
  social: import_zod.z.object({ profiles: import_zod.z.array(socialProfile).max(20), alignment: import_zod.z.enum(["left", "center", "right"]).optional() }),
21935
21939
  seo: import_zod.z.object({
@@ -21992,6 +21996,15 @@ var styleSchema = import_zod2.z.object({
21992
21996
  spaceBelow: import_zod2.z.enum(["none", "small", "medium", "large"]).optional(),
21993
21997
  hideOnMobile: import_zod2.z.boolean().optional(),
21994
21998
  hideOnDesktop: import_zod2.z.boolean().optional(),
21999
+ textColor: import_zod2.z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
22000
+ background: import_zod2.z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
22001
+ padding: import_zod2.z.enum(["none", "small", "medium", "large"]).optional(),
22002
+ radius: import_zod2.z.enum(["none", "small", "medium", "large", "pill"]).optional(),
22003
+ border: import_zod2.z.boolean().optional(),
22004
+ weight: import_zod2.z.enum(["normal", "medium", "bold"]).optional(),
22005
+ maxWidth: import_zod2.z.enum(["narrow", "normal", "full"]).optional(),
22006
+ mobile: import_zod2.z.object({ align: import_zod2.z.enum(["left", "center", "right"]).optional(), size: import_zod2.z.enum(["small", "normal", "large"]).optional(), padding: import_zod2.z.enum(["none", "small", "medium", "large"]).optional(), spaceAbove: import_zod2.z.enum(["none", "small", "medium", "large"]).optional(), spaceBelow: import_zod2.z.enum(["none", "small", "medium", "large"]).optional() }).strict().optional(),
22007
+ animation: import_zod2.z.enum(["fade-up", "fade-in", "zoom-in", "slide-left"]).optional(),
21995
22008
  anchor: import_zod2.z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).max(80).optional()
21996
22009
  });
21997
22010
  var id = import_zod2.z.string().uuid();
@@ -22056,6 +22069,8 @@ var sectionSchema = block("section", import_zod2.z.object({
22056
22069
  background: import_zod2.z.enum(["none", "muted", "accent", "dark"]).default("none"),
22057
22070
  padding: import_zod2.z.enum(["none", "small", "medium", "large"]).default("medium"),
22058
22071
  width: import_zod2.z.enum(["normal", "wide", "full"]).default("normal"),
22072
+ /** Linked to a saved site section: websites show that section's current content. */
22073
+ globalId: import_zod2.z.string().uuid().optional(),
22059
22074
  blocks: import_zod2.z.array(import_zod2.z.union([leafSchema, columnsSchema])).max(100)
22060
22075
  }));
22061
22076
  var blockSchema = import_zod2.z.union([leafSchema, columnsSchema, sectionSchema]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@universalcms/connect",
3
- "version": "0.13.3",
3
+ "version": "0.18.0",
4
4
  "description": "Deterministic framework-aware Universal CMS setup for reviewed native websites",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,7 +32,9 @@
32
32
  "./website.css": "./dist/website.css",
33
33
  "./public-bindings": "./dist/public-bindings.mjs",
34
34
  "./settings-only": "./dist/settings-only.mjs",
35
- "./theme": "./dist/theme.mjs"
35
+ "./theme": "./dist/theme.mjs",
36
+ "./theme-preview": "./dist/theme-preview.mjs",
37
+ "./theme-client": "./dist/theme-client.mjs"
36
38
  },
37
39
  "files": [
38
40
  "dist",