@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.
- package/README.md +4 -4
- package/dist/cli.mjs +4 -3
- package/dist/guided.mjs +41 -1
- 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
package/dist/publication.css
CHANGED
|
@@ -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}
|
package/dist/publication.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/react-website.d.mts
CHANGED
|
@@ -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;
|
package/dist/react-website.mjs
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
7
|
-
import {
|
|
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),
|
|
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
|
-
<
|
|
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.
|
|
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:
|
|
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:
|
|
7
|
-
.cms-website-header, .cms-website-footer { display: flex; flex-wrap: wrap; align-items: center; gap:
|
|
8
|
-
.cms-website-header { border-bottom: 1px solid
|
|
9
|
-
.cms-website-footer { border-top: 1px solid
|
|
10
|
-
a { color:
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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 {
|
|
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}
|
|
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
|
-
|
|
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
|
+
}
|