@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.
- package/README.md +6 -4
- package/dist/cli.mjs +7 -5
- package/dist/guided.mjs +79 -3
- package/dist/native-website-server.mjs +47 -16
- package/dist/native-website.mjs +28 -23
- package/dist/publication.css +5 -0
- package/dist/publication.mjs +10 -4
- package/dist/react-publication.mjs +23 -1
- package/dist/react-website.d.mts +1 -1
- package/dist/react-website.mjs +5 -3
- package/dist/templates/next-theme/app/[[...cmsPath]]/page.tsx +13 -4
- package/dist/templates/next-theme/app/api/cms-source/route.ts +10 -0
- package/dist/templates/next-theme/app/api/universalcms/[endpoint]/route.ts +2 -2
- package/dist/templates/next-theme/app/api/universalcms/form/route.ts +8 -0
- package/dist/templates/next-theme/app/cms-preview/page.tsx +28 -0
- package/dist/templates/next-theme/app/globals.css +94 -10
- package/dist/templates/next-theme/app/layout.tsx +11 -1
- package/dist/templates/next-theme/app/not-found.tsx +6 -3
- package/dist/templates/next-theme/app/robots.txt/route.ts +2 -2
- package/dist/templates/next-theme/app/sitemap.xml/route.ts +2 -2
- package/dist/templates/next-theme/components/site-chrome.tsx +38 -0
- package/dist/templates/next-theme/lib/universalcms.ts +11 -0
- package/dist/templates/next-theme/next.config.mjs +17 -1
- package/dist/theme-client.d.mts +4 -0
- package/dist/theme-client.mjs +70 -0
- package/dist/theme-preview.mjs +149 -0
- package/dist/theme.d.mts +9 -2
- package/dist/theme.mjs +190 -3
- package/dist/vendor/react-document.mjs +12 -3
- package/dist/vendor/react-preview-protocol.mjs +45 -0
- package/dist/website-schema.mjs +19 -4
- 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
|
|
6
|
-
export
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/website-schema.mjs
CHANGED
|
@@ -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(
|
|
749
|
-
const cleanHex =
|
|
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 =
|
|
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
|
|
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.
|
|
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",
|