@universalcms/connect 0.13.4 → 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 +4 -4
  2. package/dist/cli.mjs +4 -3
  3. package/dist/guided.mjs +41 -1
  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
@@ -2,5 +2,10 @@
2
2
 
3
3
  .cms-document .cms-block,.cms-document .cms-column{min-width:0}.cms-block-paragraph>p,.cms-block-heading>h2,.cms-block-heading>h3,.cms-block-heading>h4,.cms-block-quote>blockquote,.cms-document figcaption,.cms-block-faq p{white-space:pre-line}.cms-image-align-left{text-align:left}.cms-image-align-center{text-align:center}.cms-image-align-right{text-align:right}.cms-image-align-full img{width:100%}.cms-document .cms-image-align-left img,.cms-document .cms-image-align-center img,.cms-document .cms-image-align-right img{display:inline-block}.cms-gallery img{width:100%;height:auto}.cms-table-scroll:focus-visible{outline:2px solid var(--cms-accent,#2563eb);outline-offset:3px}
4
4
 
5
+ .cms-preview-toolbar{display:flex;gap:2px;padding:3px;border-radius:8px;background:#1c1940;box-shadow:0 6px 18px rgb(0 0 0/18%)}.cms-preview-toolbar button{width:28px;height:28px;border:0;border-radius:6px;background:transparent;color:#fff;font:600 13px/1 system-ui,sans-serif;cursor:pointer}.cms-preview-toolbar button:hover{background:rgb(255 255 255/14%)}.cms-inline-editing{outline:none;cursor:text;caret-color:currentColor}
6
+ .cms-padding-none{padding:0}.cms-padding-small{padding:.75rem}.cms-padding-medium{padding:1.5rem}.cms-padding-large{padding:3rem}.cms-radius-none{border-radius:0}.cms-radius-small{border-radius:.375rem}.cms-radius-medium{border-radius:.75rem}.cms-radius-large{border-radius:1.25rem}.cms-radius-pill{border-radius:999px}.cms-border-true{border:1px solid color-mix(in srgb,currentColor 18%,transparent)}.cms-weight-normal{font-weight:400}.cms-weight-medium{font-weight:500}.cms-weight-bold{font-weight:700}.cms-maxWidth-narrow{max-width:42rem;margin-inline:auto}.cms-maxWidth-normal{max-width:64rem;margin-inline:auto}.cms-maxWidth-full{max-width:none}
7
+ @media (max-width:760px){.cms-m-align-left{text-align:left!important}.cms-m-align-center{text-align:center!important}.cms-m-align-right{text-align:right!important}.cms-m-size-small{font-size:.875em!important}.cms-m-size-normal{font-size:1em!important}.cms-m-size-large{font-size:1.25em!important}.cms-m-padding-none{padding:0!important}.cms-m-padding-small{padding:.75rem!important}.cms-m-padding-medium{padding:1.25rem!important}.cms-m-padding-large{padding:2.5rem!important}.cms-m-spaceAbove-none{margin-top:0!important}.cms-m-spaceAbove-small{margin-top:.5rem!important}.cms-m-spaceAbove-medium{margin-top:1.25rem!important}.cms-m-spaceAbove-large{margin-top:2.5rem!important}.cms-m-spaceBelow-none{margin-bottom:0!important}.cms-m-spaceBelow-small{margin-bottom:.5rem!important}.cms-m-spaceBelow-medium{margin-bottom:1.25rem!important}.cms-m-spaceBelow-large{margin-bottom:2.5rem!important}}
8
+ .cms-preview-drag{display:grid;place-items:center;width:28px;height:28px;border-radius:6px;color:#fff;cursor:grab;font:600 14px/1 system-ui,sans-serif}.cms-preview-drag:hover{background:rgb(255 255 255/14%)}
9
+
5
10
  @media(max-width:767px){.cms-hide-mobile{display:none!important}}@media(min-width:768px){.cms-hide-desktop{display:none!important}}
6
11
  .cms-additional-content{max-width:80rem;margin:2rem auto;padding:1rem}.cms-inline-bound{display:contents}
@@ -93,7 +93,12 @@ const leafTypes = new Set([...textTypes, 'image', 'gallery', 'button', 'divider'
93
93
  const styleValues = {
94
94
  align: ['left', 'center', 'right'], size: ['small', 'normal', 'large'], tone: ['default', 'muted', 'accent', 'inverse'],
95
95
  spaceAbove: ['none', 'small', 'medium', 'large'], spaceBelow: ['none', 'small', 'medium', 'large'],
96
+ padding: ['none', 'small', 'medium', 'large'], radius: ['none', 'small', 'medium', 'large', 'pill'],
97
+ weight: ['normal', 'medium', 'bold'], maxWidth: ['narrow', 'normal', 'full'],
98
+ animation: ['fade-up', 'fade-in', 'zoom-in', 'slide-left'],
96
99
  };
100
+ const mobileStyle = style => !own(style, 'mobile') || object(style.mobile) && Object.keys(style.mobile).every(key => ['align', 'size', 'padding', 'spaceAbove', 'spaceBelow'].includes(key)) && ['align', 'size', 'padding', 'spaceAbove', 'spaceBelow'].every(key => optionalEnum(style.mobile, key, styleValues[key]));
101
+ const styleHex = (style, key) => !own(style, key) || typeof style[key] === 'string' && /^#[0-9a-fA-F]{6}$/.test(style[key]);
97
102
  const optionalEnum = (value, key, choices) => !own(value, key) || choices.includes(value[key]);
98
103
  const optionalString = (value, key, max) => !own(value, key) || boundedString(value[key], max);
99
104
  const optionalBoolean = (value, key) => !own(value, key) || typeof value[key] === 'boolean';
@@ -143,12 +148,12 @@ function validDocument(document) {
143
148
  if (!object(block) || !uuid.test(block.id) || ids.has(block.id) || ids.size >= 400 || block.version !== 1 || !boundedString(block.text, 30000)) return false;
144
149
  ids.add(block.id);
145
150
  if (block.style !== undefined && (!object(block.style) || Object.entries(styleValues).some(([key, values]) => !optionalEnum(block.style, key, values)) ||
146
- !optionalBoolean(block.style, 'hideOnMobile') || !optionalBoolean(block.style, 'hideOnDesktop') ||
151
+ !optionalBoolean(block.style, 'hideOnMobile') || !optionalBoolean(block.style, 'hideOnDesktop') || !optionalBoolean(block.style, 'border') || !styleHex(block.style, 'textColor') || !styleHex(block.style, 'background') || !mobileStyle(block.style) ||
147
152
  own(block.style, 'anchor') && (!boundedString(block.style.anchor, 80) || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(block.style.anchor)))) return false;
148
153
  if (leafTypes.has(block.type)) return leafData(block);
149
154
  const d = block.data;
150
155
  if (!object(d)) return false;
151
- if (block.type === 'section' && level === 0) return optionalEnum(d, 'background', ['none', 'muted', 'accent', 'dark']) && optionalEnum(d, 'padding', ['none', 'small', 'medium', 'large']) && optionalEnum(d, 'width', ['normal', 'wide', 'full']) && blocks(d.blocks, 1, 100);
156
+ if (block.type === 'section' && level === 0) return optionalEnum(d, 'background', ['none', 'muted', 'accent', 'dark']) && optionalEnum(d, 'padding', ['none', 'small', 'medium', 'large']) && optionalEnum(d, 'width', ['normal', 'wide', 'full']) && (!own(d, 'globalId') || typeof d.globalId === 'string' && uuid.test(d.globalId)) && blocks(d.blocks, 1, 100);
152
157
  if (block.type === 'columns' && level < 2) return optionalEnum(d, 'layout', ['1-1', '1-2', '2-1', '1-1-1', '1-1-1-1']) && optionalEnum(d, 'gap', ['small', 'medium', 'large']) && optionalBoolean(d, 'stackOnMobile') &&
153
158
  Array.isArray(d.columns) && d.columns.length === (d.layout ?? '1-1').split('-').length && d.columns.every(column => blocks(column, 2, 50));
154
159
  return false;
@@ -191,11 +196,12 @@ const pick = (value, keys) => Object.fromEntries(keys.filter(key => own(value, k
191
196
  const dataKeys = {
192
197
  heading: ['level'], image: ['url', 'alt', 'width', 'height', 'srcset', 'align', 'link'], gallery: ['columns'],
193
198
  button: ['url', 'style', 'align', 'newTab'], spacer: ['height'], faq: [], table: ['header', 'rows'], embed: ['url', 'provider', 'aspect'],
194
- form: ['formKey', 'submitLabel', 'successMessage', 'consentText'], section: ['background', 'padding', 'width'], columns: ['layout', 'gap', 'stackOnMobile'],
199
+ form: ['formKey', 'submitLabel', 'successMessage', 'consentText'], section: ['background', 'padding', 'width', 'globalId'], columns: ['layout', 'gap', 'stackOnMobile'],
195
200
  };
196
201
  function publicBlock(block) {
197
202
  const result = pick(block, ['id', 'type', 'version', 'text']);
198
- if (object(block.style)) result.style = pick(block.style, [...Object.keys(styleValues), 'hideOnMobile', 'hideOnDesktop', 'anchor']);
203
+ if (object(block.style)) result.style = pick(block.style, [...Object.keys(styleValues), 'hideOnMobile', 'hideOnDesktop', 'border', 'textColor', 'background', 'anchor']);
204
+ if (result.style && object(block.style.mobile)) result.style.mobile = pick(block.style.mobile, ['align', 'size', 'padding', 'spaceAbove', 'spaceBelow']);
199
205
  if (object(block.data)) {
200
206
  const data = pick(block.data, dataKeys[block.type] ?? []);
201
207
  if (block.type === 'gallery') data.images = block.data.images.map(image => pick(image, ['id', 'url', 'alt', 'width', 'height', 'srcset']));
@@ -176,7 +176,29 @@ function presentation(block) {
176
176
  style.marginTop = spacing[String(token.spaceAbove)];
177
177
  if (spacing[String(token.spaceBelow)])
178
178
  style.marginBottom = spacing[String(token.spaceBelow)];
179
- return { className: [token.hideOnMobile === true ? "cms-hide-mobile" : "", token.hideOnDesktop === true ? "cms-hide-desktop" : ""].filter(Boolean).join(" "), style, ...(typeof token.anchor === "string" && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(token.anchor) ? { id: token.anchor } : {}) };
179
+ const hex = (value) => typeof value === "string" && /^#[0-9a-fA-F]{6}$/.test(value) ? value : undefined;
180
+ if (hex(token.textColor))
181
+ style.color = hex(token.textColor);
182
+ if (hex(token.background))
183
+ style.background = hex(token.background);
184
+ if (spacing[String(token.padding)])
185
+ style.padding = spacing[String(token.padding)];
186
+ const radii = { none: "0", small: ".375rem", medium: ".75rem", large: "1.25rem", pill: "999px" };
187
+ if (radii[String(token.radius)])
188
+ style.borderRadius = radii[String(token.radius)];
189
+ if (token.border === true)
190
+ style.border = "1px solid color-mix(in srgb, currentColor 18%, transparent)";
191
+ const weights = { normal: 400, medium: 500, bold: 700 };
192
+ if (weights[String(token.weight)])
193
+ style.fontWeight = weights[String(token.weight)];
194
+ const widths = { narrow: "42rem", normal: "64rem", full: "none" };
195
+ if (widths[String(token.maxWidth)]) {
196
+ style.maxWidth = widths[String(token.maxWidth)];
197
+ if (token.maxWidth !== "full")
198
+ style.marginInline = "auto";
199
+ }
200
+ const mobile = (token.mobile ?? {}), phone = { align: ["left", "center", "right"], size: ["small", "normal", "large"], padding: ["none", "small", "medium", "large"], spaceAbove: ["none", "small", "medium", "large"], spaceBelow: ["none", "small", "medium", "large"] };
201
+ return { className: [["fade-up", "fade-in", "zoom-in", "slide-left"].includes(String(token.animation)) ? `cms-animation-${String(token.animation)}` : "", token.hideOnMobile === true ? "cms-hide-mobile" : "", token.hideOnDesktop === true ? "cms-hide-desktop" : "", ...Object.entries(phone).flatMap(([key, allowed]) => allowed.includes(String(mobile[key])) ? [`cms-m-${key}-${String(mobile[key])}`] : [])].filter(Boolean).join(" "), style, ...(typeof token.anchor === "string" && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(token.anchor) ? { id: token.anchor } : {}) };
180
202
  }
181
203
  function replaceNativeText(children, text) {
182
204
  // Custom components may render copy from props, context or closures. Traversing
@@ -6,7 +6,7 @@ export type WebsiteArchive = { kind: 'category' | 'tag'; path: string; title: st
6
6
  export type WebsiteProps = { children: ReactNode; initialSettings?: unknown; entry?: WebsiteEntry; archive?: WebsiteArchive; scope?: string; originalPaths?: string[]; applyTemplate?: boolean; chrome?: boolean };
7
7
  export function WebsiteProvider(props: Omit<WebsiteProps, 'originalPaths' | 'applyTemplate' | 'chrome'>): ReactNode;
8
8
  export function CmsWebsite(props: WebsiteProps): ReactNode;
9
- export function CmsWebsitePage(props: { scope?: string; pathname?: string; initialEntry?: WebsiteEntry; initialResult?: unknown; initialSettings?: unknown; chrome?: boolean }): ReactNode;
9
+ export function CmsWebsitePage(props: { scope?: string; pathname?: string; initialEntry?: WebsiteEntry; initialResult?: unknown; initialSettings?: unknown; chrome?: boolean; formEndpoint?: string }): ReactNode;
10
10
  export function useWebsiteSettings(): { settings: WebsiteSettings; entry?: WebsiteEntry; archive?: WebsiteArchive; related: unknown[]; scope: string; consent: Consent | null; saveConsent(choices: { analytics: boolean; marketing: boolean }): void; preferencesOpen: boolean; setPreferencesOpen(value: boolean): void };
11
11
  export function CmsBranding(props?: { homePath?: string; fallback?: ReactNode }): ReactNode;
12
12
  export function CmsNavigation(props?: { location?: 'header' | 'footer'; originalLinks?: Array<{ label: string; url: string }>; excludeUrls?: string[]; onNavigate?: () => void; className?: string }): ReactNode;
@@ -623,9 +623,11 @@ function publicEntry(value) {
623
623
  // independently validates its URLs and only consumes a ready selected section.
624
624
  return { ...entry, ...Object.fromEntries(['path', 'seo', 'author', 'categories', 'tags', 'translations', 'canonicalUrl', 'publishedAt'].map(name => [name, raw[name]])) };
625
625
  }
626
- export function CmsWebsitePage({ scope, pathname, initialEntry, initialResult, initialSettings, chrome = true }) {
626
+ /** Same-origin path that accepts form submissions (theme websites); absent means forms show as unavailable. */
627
+ const FormEndpoint = createContext(undefined);
628
+ export function CmsWebsitePage({ scope, pathname, initialEntry, initialResult, initialSettings, chrome = true, formEndpoint }) {
627
629
  const path = pathname ?? (typeof window === 'undefined' ? '/' : window.location.pathname);
628
- return _jsx(WebsitePageLoader, { scope: scope, pathname: path, initialEntry: initialEntry, initialResult: initialResult, initialSettings: initialSettings, chrome: chrome }, `${scope}:${path}`);
630
+ return _jsx(FormEndpoint.Provider, { value: formEndpoint, children: _jsx(WebsitePageLoader, { scope: scope, pathname: path, initialEntry: initialEntry, initialResult: initialResult, initialSettings: initialSettings, chrome: chrome }, `${scope}:${path}`) });
629
631
  }
630
632
  function WebsitePageLoader({ scope, pathname, initialEntry, initialResult, initialSettings, chrome }) {
631
633
  const [page, setPage] = useState(() => { try {
@@ -710,7 +712,7 @@ function PublishedPage({ entry, pathname, related }) {
710
712
  }, [entry, presentation.showTableOfContents]);
711
713
  const route = useMemo(() => ({ identity: { kind: entry.kind, slug: entry.slug, locale: entry.locale }, baseline: entry.document, bindings: {} }), [entry]);
712
714
  const publishedDate = Number.isFinite(Date.parse(entry.publishedAt)) ? new Date(entry.publishedAt).toLocaleDateString(entry.locale) : '';
713
- return _jsxs("main", { className: `cms-website-page cms-website-layout-${presentation.layout}`, children: [presentation.showBreadcrumbs && _jsxs("nav", { "aria-label": "Breadcrumbs", children: [_jsx(SafeLink, { url: "/", children: "Home" }), _jsx("span", { "aria-hidden": "true", children: " / " }), _jsx("span", { "aria-current": "page", children: entry.document.title })] }), _jsx("h1", { children: entry.document.title }), entry.document.excerpt && _jsx("p", { className: "cms-website-excerpt", children: entry.document.excerpt }), presentation.showDate && publishedDate && _jsx("time", { dateTime: entry.publishedAt, children: publishedDate }), presentation.showFeaturedImage && presentation.featuredImage.url && _jsx("img", { className: "cms-website-featured", src: presentation.featuredImage.url, alt: presentation.featuredImage.alt, referrerPolicy: "no-referrer" }), !!headings.length && _jsxs("nav", { className: "cms-website-toc", "aria-label": "Table of contents", children: [_jsx("h2", { children: "On this page" }), _jsx("ul", { children: headings.map(item => _jsx("li", { children: _jsx(SafeLink, { url: `${pathname}#${encodeURIComponent(item.id)}`, children: item.title }) }, item.id)) })] }), _jsx("div", { ref: content, children: _jsx(PublishedDocumentProvider, { initialEntry: entry, pathname: pathname, route: route, children: _jsx(CmsPublishedDocument, {}) }) }), presentation.author && _jsxs("aside", { className: "cms-website-author", "aria-label": "About the author", children: [presentation.author.avatarUrl && _jsx("img", { src: presentation.author.avatarUrl, alt: "", loading: "lazy", referrerPolicy: "no-referrer" }), _jsxs("div", { children: [_jsx("h2", { children: presentation.author.url ? _jsx(SafeLink, { url: presentation.author.url, children: presentation.author.name }) : presentation.author.name }), _jsx("p", { children: presentation.author.jobTitle }), _jsx("p", { children: presentation.author.bio })] })] }), (presentation.categories.length > 0 || presentation.tags.length > 0) && _jsx("nav", { className: "cms-website-terms", "aria-label": "Categories and tags", children: [...presentation.categories, ...presentation.tags].map((term, index) => _jsx(SafeLink, { url: term.path, children: term.name }, `${term.id}:${index}`)) }), presentation.showShare && _jsx(SharePage, { title: entry.document.title, path: entry.path || pathname }), presentation.showRelated && Array.isArray(related) && related.length > 0 && _jsxs("aside", { className: "cms-website-related", children: [_jsx("h2", { children: "Related content" }), _jsx("div", { className: "cms-website-cards", children: related.slice(0, 6).map((value, index) => { const row = record(value); return publicHref(row.path)?.startsWith('/') ? _jsxs("article", { children: [_jsx("h3", { children: _jsx(SafeLink, { url: row.path, children: text(row.title, 200) }) }), _jsx("p", { children: text(row.excerpt, 500) })] }, index) : null; }) })] })] });
715
+ return _jsxs("main", { className: `cms-website-page cms-website-layout-${presentation.layout}`, children: [presentation.showBreadcrumbs && _jsxs("nav", { "aria-label": "Breadcrumbs", children: [_jsx(SafeLink, { url: "/", children: "Home" }), _jsx("span", { "aria-hidden": "true", children: " / " }), _jsx("span", { "aria-current": "page", children: entry.document.title })] }), _jsx("h1", { children: entry.document.title }), entry.document.excerpt && _jsx("p", { className: "cms-website-excerpt", children: entry.document.excerpt }), presentation.showDate && publishedDate && _jsx("time", { dateTime: entry.publishedAt, children: publishedDate }), presentation.showFeaturedImage && presentation.featuredImage.url && _jsx("img", { className: "cms-website-featured", src: presentation.featuredImage.url, alt: presentation.featuredImage.alt, referrerPolicy: "no-referrer" }), !!headings.length && _jsxs("nav", { className: "cms-website-toc", "aria-label": "Table of contents", children: [_jsx("h2", { children: "On this page" }), _jsx("ul", { children: headings.map(item => _jsx("li", { children: _jsx(SafeLink, { url: `${pathname}#${encodeURIComponent(item.id)}`, children: item.title }) }, item.id)) })] }), _jsx("div", { ref: content, children: _jsx(PublishedDocumentProvider, { initialEntry: entry, pathname: pathname, route: route, formEndpoint: useContext(FormEndpoint), children: _jsx(CmsPublishedDocument, {}) }) }), presentation.author && _jsxs("aside", { className: "cms-website-author", "aria-label": "About the author", children: [presentation.author.avatarUrl && _jsx("img", { src: presentation.author.avatarUrl, alt: "", loading: "lazy", referrerPolicy: "no-referrer" }), _jsxs("div", { children: [_jsx("h2", { children: presentation.author.url ? _jsx(SafeLink, { url: presentation.author.url, children: presentation.author.name }) : presentation.author.name }), _jsx("p", { children: presentation.author.jobTitle }), _jsx("p", { children: presentation.author.bio })] })] }), (presentation.categories.length > 0 || presentation.tags.length > 0) && _jsx("nav", { className: "cms-website-terms", "aria-label": "Categories and tags", children: [...presentation.categories, ...presentation.tags].map((term, index) => _jsx(SafeLink, { url: term.path, children: term.name }, `${term.id}:${index}`)) }), presentation.showShare && _jsx(SharePage, { title: entry.document.title, path: entry.path || pathname }), presentation.showRelated && Array.isArray(related) && related.length > 0 && _jsxs("aside", { className: "cms-website-related", children: [_jsx("h2", { children: "Related content" }), _jsx("div", { className: "cms-website-cards", children: related.slice(0, 6).map((value, index) => { const row = record(value); return publicHref(row.path)?.startsWith('/') ? _jsxs("article", { children: [_jsx("h3", { children: _jsx(SafeLink, { url: row.path, children: text(row.title, 200) }) }), _jsx("p", { children: text(row.excerpt, 500) })] }, index) : null; }) })] })] });
714
716
  }
715
717
  function SharePage({ title, path }) {
716
718
  const [status, setStatus] = useState('');
@@ -3,8 +3,10 @@ import { cache } from 'react';
3
3
  import { connection } from 'next/server';
4
4
  import { notFound, permanentRedirect, redirect } from 'next/navigation';
5
5
  import { websiteMetadata } from '@universalcms/connect/website-runtime';
6
- import { CmsWebsitePage } from '@universalcms/connect/react-website';
7
- import { configuration, runtime, scope } from '../../lib/universalcms';
6
+ import { applyGlobalSections } from '@universalcms/connect/theme';
7
+ import { CmsWebsite, CmsWebsitePage } from '@universalcms/connect/react-website';
8
+ import { SiteFooter, SiteHeader } from '../../components/site-chrome';
9
+ import { configuration, design, patterns, runtime, scope } from '../../lib/universalcms';
8
10
 
9
11
  const settings = cache(async () => { await connection(); return runtime.settings(); });
10
12
  const read = cache(async (pathname: string) => { await connection(); return runtime.page(pathname); });
@@ -34,10 +36,17 @@ export async function generateMetadata({ params }: Props) {
34
36
  }
35
37
 
36
38
  export default async function Page({ params }: Props) {
37
- const pathname = await pathOf(params), result = await load(pathname), snapshot = await settings();
39
+ const pathname = await pathOf(params), loaded = await load(pathname), snapshot = await settings(), tokens = await design(), saved = await patterns();
40
+ const result = loaded.state === 'published' ? { ...loaded, entry: applyGlobalSections(loaded.entry, saved) } : loaded;
38
41
  const structured = websiteMetadata(result.state === 'published' ? result.entry : null, snapshot, pathname, configuration.websiteOrigin).jsonLd;
39
42
  return <>
40
43
  {structured && <script type="application/ld+json" data-universalcms-setting="seo" dangerouslySetInnerHTML={{ __html: JSON.stringify(structured).replace(/</g, '\\u003c') }}/>}
41
- <CmsWebsitePage pathname={pathname} scope={scope} initialResult={result} initialSettings={snapshot}/>
44
+ <CmsWebsite scope={scope} initialSettings={snapshot} applyTemplate={false} chrome={false}>
45
+ <div className="cms-website theme-site">
46
+ <SiteHeader header={tokens.header} patterns={saved} />
47
+ <main className="theme-main"><CmsWebsitePage pathname={pathname} scope={scope} initialResult={result} initialSettings={snapshot} chrome={false} formEndpoint="/api/universalcms/form"/></main>
48
+ <SiteFooter footer={tokens.footer} patterns={saved} />
49
+ </div>
50
+ </CmsWebsite>
42
51
  </>;
43
52
  }
@@ -0,0 +1,10 @@
1
+ // Universal CMS website endpoints (content import from a previous website).
2
+ import { createThemeHandler } from "@universalcms/connect/theme";
3
+ import { adminOrigin, configuration, pendingImport } from "../../../lib/universalcms";
4
+
5
+ const handle = createThemeHandler({ configuration, adminOrigin, imported: pendingImport });
6
+ export const dynamic = "force-dynamic";
7
+ export const GET = handle;
8
+ export const HEAD = handle;
9
+ export const OPTIONS = handle;
10
+
@@ -1,8 +1,8 @@
1
1
  // Universal CMS website endpoints (settings, live page updates, sitemap, robots).
2
2
  import { createThemeHandler } from "@universalcms/connect/theme";
3
- import { adminOrigin, configuration } from "../../../../lib/universalcms";
3
+ import { adminOrigin, configuration, pendingImport } from "../../../../lib/universalcms";
4
4
 
5
- const handle = createThemeHandler({ configuration, adminOrigin });
5
+ const handle = createThemeHandler({ configuration, adminOrigin, imported: pendingImport });
6
6
  export const dynamic = "force-dynamic";
7
7
  export const GET = handle;
8
8
  export const HEAD = handle;
@@ -0,0 +1,8 @@
1
+ // Universal CMS website endpoints (form submissions from published pages).
2
+ import { createThemeHandler } from "@universalcms/connect/theme";
3
+ import { adminOrigin, configuration, pendingImport } from "../../../../lib/universalcms";
4
+
5
+ const handle = createThemeHandler({ configuration, adminOrigin, imported: pendingImport });
6
+ export const dynamic = "force-dynamic";
7
+ export const POST = handle;
8
+
@@ -0,0 +1,28 @@
1
+ // Draft preview for the Universal CMS visual editor. Opened inside the CMS only;
2
+ // headers in next.config.mjs keep it private and frameable by the CMS admin.
3
+ import { connection } from 'next/server';
4
+ import { CmsPreview } from '@universalcms/connect/theme-preview';
5
+ import { CmsWebsite } from '@universalcms/connect/react-website';
6
+ import { SiteFooter, SiteHeader } from '../../components/site-chrome';
7
+ import { adminOrigin, design, patterns, runtime, scope } from '../../lib/universalcms';
8
+
9
+ export const metadata = { robots: { index: false, follow: false } };
10
+ type Props = { searchParams: Promise<Record<string, string | string[] | undefined>> };
11
+
12
+ export default async function Preview({ searchParams }: Props) {
13
+ await connection();
14
+ const params = new URLSearchParams();
15
+ for (const [name, value] of Object.entries(await searchParams)) for (const item of [value].flat()) if (typeof item === 'string') params.append(name, item);
16
+ const [draft, settings, tokens, saved] = await Promise.all([runtime.preview(params), runtime.settings(), design(), patterns()]);
17
+ if (!draft) return <main className="theme-main"><p role="status">This preview link expired or is not valid. Reopen the page in Universal CMS.</p></main>;
18
+ return <CmsWebsite scope={scope} initialSettings={settings} applyTemplate={false} chrome={false}>
19
+ <div className="cms-website theme-site">
20
+ <SiteHeader header={tokens.header} patterns={saved} />
21
+ <main className="theme-main">
22
+ <h1>{draft.title}</h1>
23
+ <CmsPreview document={draft.document as never} entryId={draft.entryId} adminOrigin={adminOrigin} expiresAt={draft.expiresAt} />
24
+ </main>
25
+ <SiteFooter footer={tokens.footer} patterns={saved} />
26
+ </div>
27
+ </CmsWebsite>;
28
+ }
@@ -1,12 +1,96 @@
1
- /* Universal CMS theme base styles. Brand colors come from CMS Branding. */
2
- :root { --cms-website-accent: #4f46e5; color-scheme: light; }
1
+ /* Universal CMS theme base styles. Values come from Appearance → Design. */
3
2
  * { box-sizing: border-box; }
4
- body { margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; line-height: 1.6; color: #0f172a; background: #fff; }
3
+ body { margin: 0; font-family: var(--cms-font-body); font-size: var(--cms-font-size); line-height: 1.65; color: var(--cms-text); background: var(--cms-background); }
4
+ h1, h2, h3, h4 { font-family: var(--cms-font-heading); font-weight: var(--cms-heading-weight); line-height: 1.2; }
5
+ h1 { font-size: clamp(2rem, 4vw, 3rem); margin: 0 0 calc(var(--cms-gap) * 1); }
6
+ h2 { margin: calc(var(--cms-gap) * 2) 0 calc(var(--cms-gap) * .6); }
7
+ p { margin: 0 0 var(--cms-gap); }
8
+ a { color: var(--cms-primary); }
9
+ img { max-width: 100%; height: auto; border-radius: var(--cms-radius); }
5
10
  .cms-website { min-height: 100vh; display: flex; flex-direction: column; }
6
- .cms-website > :not(header):not(footer) { flex: 1; width: 100%; max-width: 1100px; margin: 0 auto; padding: 32px 20px 64px; }
7
- .cms-website-header, .cms-website-footer { display: flex; flex-wrap: wrap; align-items: center; gap: 16px 28px; padding: 16px max(20px, calc((100% - 1100px) / 2)); }
8
- .cms-website-header { border-bottom: 1px solid #e2e8f0; }
9
- .cms-website-footer { border-top: 1px solid #e2e8f0; color: #475569; font-size: 14px; }
10
- a { color: var(--cms-website-accent); }
11
- img { max-width: 100%; height: auto; }
12
- h1 { font-size: clamp(2rem, 4vw, 3rem); line-height: 1.15; margin: 0 0 16px; }
11
+ .cms-website > :not(header):not(footer) { flex: 1; width: 100%; max-width: var(--cms-container); margin: 0 auto; padding: calc(var(--cms-gap) * 2.5) 20px calc(var(--cms-gap) * 4); }
12
+ .cms-website-header, .cms-website-footer { display: flex; flex-wrap: wrap; align-items: center; gap: var(--cms-gap) calc(var(--cms-gap) * 1.75); padding: var(--cms-gap) max(20px, calc((100% - var(--cms-container)) / 2)); }
13
+ .cms-website-header { border-bottom: 1px solid var(--cms-border); background: var(--cms-background); }
14
+ .cms-website-footer { border-top: 1px solid var(--cms-border); background: var(--cms-surface); color: var(--cms-muted); font-size: .9em; }
15
+ .cms-website-header a, .cms-website-footer a { color: inherit; text-decoration: none; }
16
+ .cms-website-header a:hover, .cms-website-footer a:hover { color: var(--cms-primary); }
17
+ blockquote, pre, table, .cms-block-faq, .cms-block-table { background: var(--cms-surface); border: 1px solid var(--cms-border); border-radius: var(--cms-radius); }
18
+ blockquote { margin: var(--cms-gap) 0; padding: var(--cms-gap) calc(var(--cms-gap) * 1.25); border-left: 4px solid var(--cms-primary); }
19
+
20
+ /* Header and footer presets (Appearance → Design → Header & footer). */
21
+ .theme-site { min-height: 100vh; display: flex; flex-direction: column; }
22
+ .theme-main { flex: 1; width: 100%; max-width: var(--cms-container); margin: 0 auto; padding: calc(var(--cms-gap) * 2.5) 20px calc(var(--cms-gap) * 4); }
23
+ .theme-header, .theme-footer { display: flex; flex-wrap: wrap; align-items: center; gap: var(--cms-gap) calc(var(--cms-gap) * 1.75); padding: var(--cms-gap) max(20px, calc((100% - var(--cms-container)) / 2)); }
24
+ .theme-header { border-bottom: 1px solid var(--cms-border); background: var(--cms-background); z-index: 10; }
25
+ .theme-header-sticky { position: sticky; top: 0; }
26
+ .theme-header-split .theme-header-menu { margin-left: auto; }
27
+ .theme-header-centered { flex-direction: column; text-align: center; }
28
+ .theme-header-cta { margin-left: auto; }
29
+ .theme-header-split .theme-header-cta, .theme-header-centered .theme-header-cta { margin-left: 0; }
30
+ .theme-header a:not(.cms-button), .theme-footer a { color: inherit; text-decoration: none; }
31
+ .theme-header a:not(.cms-button):hover, .theme-footer a:hover { color: var(--cms-primary); }
32
+ .theme-footer { border-top: 1px solid var(--cms-border); background: var(--cms-surface); color: var(--cms-muted); font-size: .9em; }
33
+ .theme-footer-centered { flex-direction: column; text-align: center; }
34
+ .theme-footer-columns { align-items: flex-start; }
35
+ @media (max-width: 640px) { .theme-header { flex-direction: column; align-items: flex-start; } .theme-header-split .theme-header-menu, .theme-header-cta { margin-left: 0; } }
36
+
37
+ /* Page sections and columns from the page builder. */
38
+ .cms-section-none, .cms-section-muted, .cms-section-accent, .cms-section-dark { border-radius: var(--cms-radius); margin: calc(var(--cms-gap) * 1.5) 0; }
39
+ .cms-section-muted { background: var(--cms-surface); border: 1px solid var(--cms-border); }
40
+ .cms-section-accent { background: var(--cms-primary); color: var(--cms-primary-text); }
41
+ .cms-section-accent a.cms-button { background: var(--cms-primary-text); color: var(--cms-primary); border-color: var(--cms-primary-text); }
42
+ .cms-section-dark { background: var(--cms-text); color: var(--cms-background); }
43
+ .cms-padding-small { padding: calc(var(--cms-gap) * .75); }
44
+ .cms-padding-medium { padding: calc(var(--cms-gap) * 1.5); }
45
+ .cms-padding-large { padding: calc(var(--cms-gap) * 3) calc(var(--cms-gap) * 2); }
46
+ .cms-width-wide { margin-inline: calc((min(100vw, 90rem) - 100%) / -2); }
47
+ .cms-width-full { margin-inline: calc((100vw - 100%) / -2); border-radius: 0; }
48
+ .cms-columns { display: grid; gap: calc(var(--cms-gap) * 1.5); }
49
+ .cms-gap-small { gap: calc(var(--cms-gap) * .5); }
50
+ .cms-gap-large { gap: calc(var(--cms-gap) * 2.25); }
51
+ .cms-layout-1-1 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
52
+ .cms-layout-1-1-1 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
53
+ .cms-layout-1-1-1-1 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
54
+ .cms-layout-1-2 { grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); }
55
+ .cms-layout-2-1 { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); }
56
+ .cms-column { min-width: 0; }
57
+ .cms-column > :first-child h3, .cms-column h3:first-child { margin-top: 0; }
58
+ @media (max-width: 760px) { .cms-columns.cms-stack { grid-template-columns: 1fr; } }
59
+
60
+ /* Visual editor: the selected block in the CMS is outlined. */
61
+ .cms-preview [data-cms-block] { cursor: pointer; }
62
+ .cms-preview [data-cms-block]:hover { outline: 1px dashed var(--cms-primary); outline-offset: 3px; }
63
+ .cms-preview .cms-selected { outline: 2px solid var(--cms-primary); outline-offset: 3px; }
64
+ .cms-preview-toolbar { display: flex; gap: 2px; padding: 3px; border-radius: 8px; background: #1c1940; box-shadow: 0 6px 18px rgb(0 0 0 / 18%); }
65
+ .cms-preview-toolbar button { width: 28px; height: 28px; border: 0; border-radius: 6px; background: transparent; color: #fff; font: 600 13px/1 system-ui, sans-serif; cursor: pointer; }
66
+ .cms-preview-toolbar button:hover { background: rgb(255 255 255 / 14%); }
67
+ .cms-inline-editing { outline: none; cursor: text; }
68
+
69
+ /* Block styles from the page builder (Style panel). */
70
+ .cms-radius-none { border-radius: 0; } .cms-radius-small { border-radius: .375rem; } .cms-radius-medium { border-radius: .75rem; } .cms-radius-large { border-radius: 1.25rem; } .cms-radius-pill { border-radius: 999px; }
71
+ .cms-border-true { border: 1px solid var(--cms-border); }
72
+ .cms-weight-normal { font-weight: 400; } .cms-weight-medium { font-weight: 500; } .cms-weight-bold { font-weight: 700; }
73
+ .cms-weight-bold :is(p, li, blockquote) { font-weight: 700; }
74
+ .cms-maxWidth-narrow { max-width: 42rem; margin-inline: auto; } .cms-maxWidth-normal { max-width: 64rem; margin-inline: auto; } .cms-maxWidth-full { max-width: none; }
75
+ .cms-block[style*="--cms-block-color"] :is(p, li, h2, h3, h4, blockquote) { color: inherit; }
76
+
77
+ /* Mobile overrides from the Style panel (Mobile tab). */
78
+ @media (max-width:760px){.cms-m-align-left{text-align:left!important}.cms-m-align-center{text-align:center!important}.cms-m-align-right{text-align:right!important}.cms-m-size-small{font-size:.875em!important}.cms-m-size-normal{font-size:1em!important}.cms-m-size-large{font-size:1.25em!important}.cms-m-padding-none{padding:0!important}.cms-m-padding-small{padding:.75rem!important}.cms-m-padding-medium{padding:1.25rem!important}.cms-m-padding-large{padding:2.5rem!important}.cms-m-spaceAbove-none{margin-top:0!important}.cms-m-spaceAbove-small{margin-top:.5rem!important}.cms-m-spaceAbove-medium{margin-top:1.25rem!important}.cms-m-spaceAbove-large{margin-top:2.5rem!important}.cms-m-spaceBelow-none{margin-bottom:0!important}.cms-m-spaceBelow-small{margin-bottom:.5rem!important}.cms-m-spaceBelow-medium{margin-bottom:1.25rem!important}.cms-m-spaceBelow-large{margin-bottom:2.5rem!important}}
79
+
80
+ /* Saved-section header/footer, popups and scroll animations. */
81
+ .theme-header-custom, .theme-footer-custom { width: 100%; }
82
+ .theme-header-custom .cms-document > .cms-block-section, .theme-footer-custom .cms-document > .cms-block-section { margin: 0; border-radius: 0; }
83
+ .cms-popup-backdrop { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: 16px; background: rgb(15 23 42 / 45%); }
84
+ .cms-popup { position: relative; width: min(560px, 100%); max-height: calc(100dvh - 32px); overflow: auto; border-radius: calc(var(--cms-radius) + 6px); background: var(--cms-background); box-shadow: 0 24px 60px rgb(0 0 0 / 25%); }
85
+ .cms-popup .cms-document > .cms-block-section { margin: 0; }
86
+ .cms-popup-close { position: absolute; top: 8px; right: 8px; z-index: 1; width: 34px; height: 34px; border: 0; border-radius: 50%; background: var(--cms-surface); color: var(--cms-text); font-size: 20px; line-height: 1; cursor: pointer; }
87
+ .cms-anim-ready [class*="cms-animation-"] { opacity: 0; transition: opacity .6s ease, transform .6s cubic-bezier(.2,.7,.2,1); }
88
+ .cms-anim-ready .cms-animation-fade-up { transform: translateY(24px); }
89
+ .cms-anim-ready .cms-animation-zoom-in { transform: scale(.94); }
90
+ .cms-anim-ready .cms-animation-slide-left { transform: translateX(32px); }
91
+ .cms-anim-ready [class*="cms-animation-"].cms-anim-in { opacity: 1; transform: none; }
92
+ @media (prefers-reduced-motion: reduce) { .cms-anim-ready [class*="cms-animation-"] { opacity: 1 !important; transform: none !important; transition: none !important; } }
93
+ .cms-preview-drag { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 6px; color: #fff; cursor: grab; font: 600 14px/1 system-ui, sans-serif; }
94
+ .cms-preview-drag:hover { background: rgb(255 255 255 / 14%); }
95
+ .theme-header-custom [class*="cms-section-"], .theme-footer-custom [class*="cms-section-"] { margin: 0; border-radius: 0; }
96
+ .theme-header-custom .cms-block, .theme-footer-custom .cms-block { margin: 0; }
@@ -1,10 +1,20 @@
1
1
  import type { ReactNode } from 'react';
2
+ import { connection } from 'next/server';
3
+ import { themeCss, themeFontsHref } from '@universalcms/connect/theme';
2
4
  import '@universalcms/connect/website.css';
3
5
  import './globals.css';
6
+ import { design as loadDesign } from '../lib/universalcms';
4
7
 
5
- export default function RootLayout({ children }: { children: ReactNode }) {
8
+ // Colors, fonts, spacing and buttons come from Appearance → Design in Universal CMS.
9
+ export default async function RootLayout({ children }: { children: ReactNode }) {
10
+ await connection();
11
+ const design = await loadDesign(), fonts = themeFontsHref(design);
6
12
  return (
7
13
  <html lang="en">
14
+ <head>
15
+ {fonts && <link rel="stylesheet" href={fonts} />}
16
+ <style data-universalcms-design="">{themeCss(design)}</style>
17
+ </head>
8
18
  <body>{children}</body>
9
19
  </html>
10
20
  );
@@ -1,9 +1,12 @@
1
1
  import { connection } from 'next/server';
2
2
  import { CmsExperience, CmsWebsite } from '@universalcms/connect/react-website';
3
- import { runtime, scope } from '../lib/universalcms';
3
+ import { SiteFooter, SiteHeader } from '../components/site-chrome';
4
+ import { design, patterns, runtime, scope } from '../lib/universalcms';
4
5
 
5
6
  export default async function NotFound() {
6
7
  await connection();
7
- const settings = await runtime.settings();
8
- return <CmsWebsite scope={scope} initialSettings={settings} applyTemplate={false}><main><CmsExperience notFound /></main></CmsWebsite>;
8
+ const [settings, tokens, saved] = await Promise.all([runtime.settings(), design(), patterns()]);
9
+ return <CmsWebsite scope={scope} initialSettings={settings} applyTemplate={false} chrome={false}>
10
+ <div className="cms-website theme-site"><SiteHeader header={tokens.header} patterns={saved} /><main className="theme-main"><CmsExperience notFound /></main><SiteFooter footer={tokens.footer} patterns={saved} /></div>
11
+ </CmsWebsite>;
9
12
  }
@@ -1,8 +1,8 @@
1
1
  // Universal CMS website endpoints (settings, live page updates, sitemap, robots).
2
2
  import { createThemeHandler } from "@universalcms/connect/theme";
3
- import { adminOrigin, configuration } from "../../lib/universalcms";
3
+ import { adminOrigin, configuration, pendingImport } from "../../lib/universalcms";
4
4
 
5
- const handle = createThemeHandler({ configuration, adminOrigin });
5
+ const handle = createThemeHandler({ configuration, adminOrigin, imported: pendingImport });
6
6
  export const dynamic = "force-dynamic";
7
7
  export const GET = handle;
8
8
  export const HEAD = handle;
@@ -1,8 +1,8 @@
1
1
  // Universal CMS website endpoints (settings, live page updates, sitemap, robots).
2
2
  import { createThemeHandler } from "@universalcms/connect/theme";
3
- import { adminOrigin, configuration } from "../../lib/universalcms";
3
+ import { adminOrigin, configuration, pendingImport } from "../../lib/universalcms";
4
4
 
5
- const handle = createThemeHandler({ configuration, adminOrigin });
5
+ const handle = createThemeHandler({ configuration, adminOrigin, imported: pendingImport });
6
6
  export const dynamic = "force-dynamic";
7
7
  export const GET = handle;
8
8
  export const HEAD = handle;
@@ -0,0 +1,38 @@
1
+ 'use client';
2
+ // Header and footer of the Universal CMS website. Layout choices come from
3
+ // Appearance → Design; logo, menus and social links from the other Appearance pages.
4
+ import { CmsBranding, CmsExperience, CmsLanguages, CmsNavigation, CmsSocialLinks } from '@universalcms/connect/react-website';
5
+ import { CmsAnimations, CmsBlocks, CmsPopup } from '@universalcms/connect/theme-client';
6
+
7
+ type Pattern = { id: string; block: unknown; slot?: 'header' | 'footer' | 'popup'; popup?: { delaySeconds: number; frequency: 'once' | 'session' | 'always' } };
8
+
9
+ type Header = { layout: 'left' | 'centered' | 'split'; sticky: boolean; ctaLabel: string; ctaUrl: string };
10
+ type Footer = { layout: 'simple' | 'centered' | 'columns'; showSocial: boolean };
11
+
12
+ export function SiteHeader({ header, patterns = [] }: { header: Header; patterns?: Pattern[] }) {
13
+ // A saved section marked as the site header replaces the preset header.
14
+ const custom = patterns.find(pattern => pattern.slot === 'header');
15
+ if (custom) return <header className="theme-header-custom"><CmsBlocks blocks={[custom.block]} /></header>;
16
+ return (
17
+ <header className={`theme-header theme-header-${header.layout}${header.sticky ? ' theme-header-sticky' : ''}`}>
18
+ <CmsBranding />
19
+ <nav className="theme-header-menu" aria-label="Main"><CmsNavigation location="header" /></nav>
20
+ <CmsLanguages />
21
+ {header.ctaLabel && header.ctaUrl && <a className="cms-button theme-header-cta" href={header.ctaUrl}>{header.ctaLabel}</a>}
22
+ </header>
23
+ );
24
+ }
25
+
26
+ export function SiteFooter({ footer, patterns = [] }: { footer: Footer; patterns?: Pattern[] }) {
27
+ const custom = patterns.find(pattern => pattern.slot === 'footer');
28
+ const extras = <>{patterns.filter(pattern => pattern.slot === 'popup').map(pattern => <CmsPopup key={pattern.id} id={pattern.id} blocks={[pattern.block]} delaySeconds={pattern.popup?.delaySeconds ?? 5} frequency={pattern.popup?.frequency ?? 'once'} />)}<CmsAnimations /></>;
29
+ if (custom) return <><footer className="theme-footer-custom"><CmsBlocks blocks={[custom.block]} /><CmsExperience /></footer>{extras}</>;
30
+ return (<>
31
+ <footer className={`theme-footer theme-footer-${footer.layout}`}>
32
+ <CmsNavigation location="footer" />
33
+ {footer.showSocial && <CmsSocialLinks />}
34
+ <CmsExperience />
35
+ </footer>
36
+ {extras}</>
37
+ );
38
+ }
@@ -1,5 +1,8 @@
1
1
  // Universal CMS theme: shared runtime. Connection values are public; the
2
2
  // website key is read from .env.local on the server only.
3
+ import { cache } from 'react';
4
+ import { readFile } from 'node:fs/promises';
5
+ import path from 'node:path';
3
6
  import { createThemeRuntime } from '@universalcms/connect/theme';
4
7
  import website from '../.universalcms/website.json';
5
8
  import connection from '../.universalcms/connection.json';
@@ -8,3 +11,11 @@ export const configuration = website as Parameters<typeof createThemeRuntime>[0]
8
11
  export const adminOrigin = connection.adminOrigin;
9
12
  export const scope = `${website.siteId}:${website.environmentId}`;
10
13
  export const runtime = createThemeRuntime({ configuration });
14
+ /** Design tokens for this request (Appearance → Design), shared by layout and pages. */
15
+ export const design = cache(() => runtime.design());
16
+ /** Pages and menus from a previous website, waiting to be imported into the CMS (removed after import). */
17
+ export async function pendingImport() {
18
+ try { return JSON.parse(await readFile(path.join(process.cwd(), '.universalcms', 'import.json'), 'utf8')); } catch { return null; }
19
+ }
20
+ /** Saved sections for this request: linked sections, header/footer and popups. */
21
+ export const patterns = cache(() => runtime.patterns());
@@ -1 +1,17 @@
1
- export default { htmlLimitedBots: /.*/ };
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ // The CMS visual editor frames /cms-preview from the CMS admin address only.
4
+ let adminOrigin = null;
5
+ try { adminOrigin = new URL(JSON.parse(readFileSync(new URL('./.universalcms/connection.json', import.meta.url), 'utf8')).adminOrigin).origin; } catch { /* Not connected yet. */ }
6
+
7
+ export default {
8
+ htmlLimitedBots: /.*/,
9
+ async headers() {
10
+ return adminOrigin ? [{ source: '/cms-preview', headers: [
11
+ { key: 'Cache-Control', value: 'private, no-store' },
12
+ { key: 'X-Robots-Tag', value: 'noindex, nofollow' },
13
+ { key: 'Referrer-Policy', value: 'no-referrer' },
14
+ { key: 'Content-Security-Policy', value: `frame-ancestors ${adminOrigin}` },
15
+ ] }] : [];
16
+ },
17
+ };
@@ -0,0 +1,4 @@
1
+ import type { ReactNode } from 'react';
2
+ export function CmsBlocks(props: { blocks: unknown[]; className?: string }): ReactNode;
3
+ export function CmsPopup(props: { id: string; blocks: unknown[]; delaySeconds: number; frequency: 'once' | 'session' | 'always' }): ReactNode;
4
+ export function CmsAnimations(): ReactNode;
@@ -0,0 +1,70 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // Client pieces of the Universal CMS theme: saved sections rendered as the
4
+ // header/footer or a popup, and scroll-in animations chosen in the Style panel.
5
+ import React, { useEffect, useState } from "react";
6
+ import { CmsDocument } from "./vendor/react-document.mjs";
7
+ /** Renders saved-section blocks (for example the site header built in the page builder). */
8
+ export function CmsBlocks({ blocks, className }) {
9
+ return _jsx("div", { className: className, children: _jsx(CmsDocument, { document: { blocks: blocks } }) });
10
+ }
11
+ /** A saved section shown as a popup after a delay; frequency is remembered per browser. */
12
+ export function CmsPopup({ id, blocks, delaySeconds, frequency }) {
13
+ const [open, setOpen] = useState(false);
14
+ const key = `universalcms:popup:${id}`;
15
+ useEffect(() => {
16
+ try {
17
+ if (frequency === "once" && localStorage.getItem(key))
18
+ return;
19
+ if (frequency === "session" && sessionStorage.getItem(key))
20
+ return;
21
+ }
22
+ catch { /* Storage can be unavailable; show the popup. */ }
23
+ const timer = window.setTimeout(() => setOpen(true), Math.max(0, Math.min(delaySeconds, 120)) * 1000);
24
+ return () => window.clearTimeout(timer);
25
+ }, [key, delaySeconds, frequency]);
26
+ const close = () => {
27
+ setOpen(false);
28
+ try {
29
+ if (frequency === "once")
30
+ localStorage.setItem(key, "1");
31
+ if (frequency === "session")
32
+ sessionStorage.setItem(key, "1");
33
+ }
34
+ catch { /* Per-browser convenience only. */ }
35
+ };
36
+ useEffect(() => {
37
+ if (!open)
38
+ return;
39
+ const onKey = (event) => { if (event.key === "Escape")
40
+ close(); };
41
+ window.addEventListener("keydown", onKey);
42
+ return () => window.removeEventListener("keydown", onKey);
43
+ });
44
+ if (!open)
45
+ return null;
46
+ return _jsx("div", { className: "cms-popup-backdrop", role: "presentation", onClick: event => { if (event.target === event.currentTarget)
47
+ close(); }, children: _jsxs("div", { className: "cms-popup", role: "dialog", "aria-modal": "true", "aria-label": "Announcement", children: [_jsx("button", { type: "button", className: "cms-popup-close", "aria-label": "Close", onClick: close, children: "\u00D7" }), _jsx(CmsDocument, { document: { blocks: blocks } })] }) });
48
+ }
49
+ /** Reveals blocks with an animation class as they scroll into view; honours reduced motion. */
50
+ export function CmsAnimations() {
51
+ useEffect(() => {
52
+ const root = document.documentElement;
53
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches || !("IntersectionObserver" in window))
54
+ return;
55
+ root.classList.add("cms-anim-ready");
56
+ const observer = new IntersectionObserver(entries => {
57
+ for (const entry of entries)
58
+ if (entry.isIntersecting) {
59
+ entry.target.classList.add("cms-anim-in");
60
+ observer.unobserve(entry.target);
61
+ }
62
+ }, { rootMargin: "0px 0px -8% 0px", threshold: 0.1 });
63
+ const watch = () => document.querySelectorAll('[class*="cms-animation-"]:not(.cms-anim-in)').forEach(node => observer.observe(node));
64
+ watch();
65
+ const mutations = new MutationObserver(watch);
66
+ mutations.observe(document.body, { childList: true, subtree: true });
67
+ return () => { observer.disconnect(); mutations.disconnect(); root.classList.remove("cms-anim-ready"); };
68
+ }, []);
69
+ return null;
70
+ }