@universalcms/connect 0.13.0 → 0.13.2
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 +9 -4
- package/dist/cli.mjs +13 -2
- package/dist/content-next-setup.mjs +10 -3
- package/dist/guided.mjs +262 -12
- package/dist/preflight-recovery.mjs +8 -0
- package/dist/settings-only-setup.mjs +100 -0
- package/dist/settings-only.d.mts +4 -0
- package/dist/settings-only.mjs +54 -0
- package/dist/templates/next-theme/.universalcms/theme.json +4 -0
- package/dist/templates/next-theme/README.md +14 -0
- package/dist/templates/next-theme/app/[[...cmsPath]]/page.tsx +43 -0
- package/dist/templates/next-theme/app/api/universalcms/[endpoint]/route.ts +10 -0
- package/dist/templates/next-theme/app/globals.css +12 -0
- package/dist/templates/next-theme/app/layout.tsx +11 -0
- package/dist/templates/next-theme/app/not-found.tsx +9 -0
- package/dist/templates/next-theme/app/robots.txt/route.ts +10 -0
- package/dist/templates/next-theme/app/sitemap.xml/route.ts +10 -0
- package/dist/templates/next-theme/gitignore.template +4 -0
- package/dist/templates/next-theme/lib/universalcms.ts +10 -0
- package/dist/templates/next-theme/next-env.d.ts +6 -0
- package/dist/templates/next-theme/next.config.mjs +1 -0
- package/dist/templates/next-theme/tsconfig.json +35 -0
- package/dist/theme.d.mts +6 -0
- package/dist/theme.mjs +50 -0
- package/dist/vendor/project-analysis.mjs +7 -5
- package/dist/website-runtime.mjs +11 -3
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @universalcms/connect
|
|
2
2
|
|
|
3
3
|
Local, deterministic website setup without AI. Universal CMS stays hosted; this
|
|
4
|
-
package creates a website integration, not a CMS installation. Version 0.13.
|
|
4
|
+
package creates a website integration, not a CMS installation. Version 0.13.2 is
|
|
5
5
|
prepared locally; this repository does not establish its public npm availability.
|
|
6
6
|
|
|
7
7
|
## Quick start
|
|
@@ -15,6 +15,11 @@ this package, connects pages, header/footer and settings, saves the website key
|
|
|
15
15
|
privately in `.env.local`, then guides the restart, content import and final check.
|
|
16
16
|
Run it again to continue where it stopped. Without a browser, create a one-time
|
|
17
17
|
`--code` command in Developer setup. See [connector setup](../../docs/connector-setup.md#one-command-connection-recommended).
|
|
18
|
+
When no page can be edited automatically, the command offers a settings-only
|
|
19
|
+
connection (Next.js): the CMS manages menus, branding, SEO, analytics, scripts,
|
|
20
|
+
consent and redirects while the site renders every page. Add
|
|
21
|
+
`<CmsNavigation location="header" />` from `@universalcms/connect/react-website`
|
|
22
|
+
where CMS menus should appear.
|
|
18
23
|
The individual commands below remain for developers and reviewed integrations.
|
|
19
24
|
|
|
20
25
|
Supported templates: **Next.js 16 App Router on Node**, **React 19.2+ with
|
|
@@ -257,7 +262,7 @@ custom adapter still requires review, not forced overwriting.
|
|
|
257
262
|
For an operator's downloaded artifact, the installation is:
|
|
258
263
|
|
|
259
264
|
```sh
|
|
260
|
-
npm install --save-prod ./vendor/universalcms-connect-0.13.
|
|
265
|
+
npm install --save-prod ./vendor/universalcms-connect-0.13.2.tgz
|
|
261
266
|
```
|
|
262
267
|
|
|
263
268
|
Use your project's package manager. These paths are examples for downloaded
|
|
@@ -534,7 +539,7 @@ npx --no-install universalcms-connect init \
|
|
|
534
539
|
--environment 22222222-2222-4222-8222-222222222222
|
|
535
540
|
```
|
|
536
541
|
|
|
537
|
-
After a public release, `npx @universalcms/connect@0.13.
|
|
542
|
+
After a public release, `npx @universalcms/connect@0.13.2 init` is the equivalent
|
|
538
543
|
registry entry point. Copy the real IDs/API origin from **Developer setup**.
|
|
539
544
|
Missing values are prompted in a terminal; `--non-interactive` fails instead.
|
|
540
545
|
Init prints a plan. Run the same command with `--apply` after reviewing it.
|
|
@@ -584,7 +589,7 @@ The CMS button reads the website's **`/api/cms-source`** endpoint. Current Next.
|
|
|
584
589
|
prepares delivery scaffolding. React/Vite setup serves its reviewed public manifest through the normal dev command.
|
|
585
590
|
Custom exporters need their explicit adapter; a delivery check alone is insufficient.
|
|
586
591
|
|
|
587
|
-
Use the CMS's reviewed 0.13.
|
|
592
|
+
Use the CMS's reviewed 0.13.2 package for the staged commands and current source
|
|
588
593
|
discovery. Review source diagnostics and complete public
|
|
589
594
|
mappings for unsupported pages. Restart the website using its normal server command, then run:
|
|
590
595
|
|
package/dist/cli.mjs
CHANGED
|
@@ -18,9 +18,9 @@ import { setupWebsiteSettings } from './website-setup.mjs';
|
|
|
18
18
|
import { checkWebsiteSettings } from './website-check.mjs';
|
|
19
19
|
import { WEBSITE_SECTIONS } from './website-http.mjs';
|
|
20
20
|
import { setupSource, applySourcePlan, syncSource, readSourceSettings } from './source-setup.mjs';
|
|
21
|
-
import { processIo, runGuided } from './guided.mjs';
|
|
21
|
+
import { processIo, runCreate, runGuided } from './guided.mjs';
|
|
22
22
|
|
|
23
|
-
const help = `Universal CMS connector 0.13.
|
|
23
|
+
const help = `Universal CMS connector 0.13.2
|
|
24
24
|
|
|
25
25
|
Quick start (recommended) — run in your website folder:
|
|
26
26
|
npx @universalcms/connect@latest --cms https://YOUR_CMS_ADDRESS
|
|
@@ -34,6 +34,9 @@ Quick start (recommended) — run in your website folder:
|
|
|
34
34
|
--code <code> One-time code from Developer setup → Connect without a browser
|
|
35
35
|
--details Show every file and technical message
|
|
36
36
|
|
|
37
|
+
New website managed entirely from the CMS (pages, posts, menus, design):
|
|
38
|
+
npx @universalcms/connect@latest create my-website --cms https://YOUR_CMS_ADDRESS
|
|
39
|
+
|
|
37
40
|
Individual steps (advanced):
|
|
38
41
|
Usage: universalcms-connect <start|inspect-pages|review-pages|preflight|review-routes|review-layout|connect|setup|setup-settings|check-settings|server-key|setup-source|sync-source|finalize-source|analyze|init|check|check-source|check-publication> [options]
|
|
39
42
|
|
|
@@ -347,6 +350,14 @@ function guidedArguments(args) {
|
|
|
347
350
|
async function main() {
|
|
348
351
|
if (process.argv.slice(2).some(value => value === '--help' || value === '-h')) { process.stdout.write(help); return; }
|
|
349
352
|
const first = process.argv[2];
|
|
353
|
+
if (first === 'create') {
|
|
354
|
+
const rest = process.argv.slice(3), name = rest[0] && !rest[0].startsWith('--') ? rest.shift() : undefined;
|
|
355
|
+
const options = guidedArguments(rest);
|
|
356
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY) throw new SetupError('This setup needs an interactive terminal.');
|
|
357
|
+
const io = processIo({ ask, write: value => process.stdout.write(value) });
|
|
358
|
+
await runCreate(process.cwd(), name ?? (await ask('Folder name for the new website: ')).trim(), { cms: options['--cms'], code: options['--code'] }, io);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
350
361
|
if (first === undefined || first === 'start' || first.startsWith('--')) {
|
|
351
362
|
// The one-time pairing code is the only accepted credential-like argument:
|
|
352
363
|
// it expires in ten minutes, works once and never reaches project files.
|
|
@@ -12,7 +12,7 @@ import { CONTENT_BINDINGS_FILE, contentDigest, readContentRegistry, readFrozenCo
|
|
|
12
12
|
import { inspectReviewedNativeOwnership } from './reviewed-native-ownership.mjs';
|
|
13
13
|
import { matchesNativeRoutes } from './native-routes.mjs';
|
|
14
14
|
import { inspectReviewedCommerceOwnership } from './reviewed-commerce-ownership.mjs';
|
|
15
|
-
import { setupReviewError } from './preflight-recovery.mjs';
|
|
15
|
+
import { setupReviewError, withReviewPaths } from './preflight-recovery.mjs';
|
|
16
16
|
const json = value => `${JSON.stringify(value, null, 2)}\n`;
|
|
17
17
|
const fail = message => { throw new SetupError(message); };
|
|
18
18
|
const relative = (from, to) => { const value = path.posix.relative(path.posix.dirname(from), to); return value.startsWith('.') ? value : `./${value}`; };
|
|
@@ -54,7 +54,12 @@ function rewriteNextContentConfiguration(source, filename) {
|
|
|
54
54
|
const file = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true, filename.endsWith('.ts') ? ts.ScriptKind.TS : ts.ScriptKind.JS);
|
|
55
55
|
const unbox = value => { while (value && (ts.isAsExpression(value) || ts.isSatisfiesExpression(value) || ts.isParenthesizedExpression(value))) value = value.expression; return value; };
|
|
56
56
|
const exported = file.statements.find(statement => ts.isExportAssignment(statement));
|
|
57
|
-
|
|
57
|
+
// CommonJS configs (`module.exports = {...}` or an inert const alias) are the
|
|
58
|
+
// same literal object in another export form.
|
|
59
|
+
const commonjs = exported ? undefined : file.statements.filter(ts.isExpressionStatement).map(statement => statement.expression)
|
|
60
|
+
.filter(expression => ts.isBinaryExpression(expression) && expression.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isPropertyAccessExpression(expression.left) && ts.isIdentifier(expression.left.expression) && expression.left.expression.text === 'module' && expression.left.name.text === 'exports');
|
|
61
|
+
if (commonjs && commonjs.length > 1) fail('Multiple Next configuration exports need manual review.');
|
|
62
|
+
let object = unbox(exported?.expression ?? commonjs?.[0]?.right);
|
|
58
63
|
if (object && ts.isIdentifier(object)) {
|
|
59
64
|
const name = object.text;
|
|
60
65
|
const declarations = file.statements.filter(ts.isVariableStatement).flatMap(statement => [...statement.declarationList.declarations]);
|
|
@@ -173,7 +178,9 @@ export async function planNextContent(directory, configuration) {
|
|
|
173
178
|
const sourceFile = inventory.files[route.path === '/' ? 'home' : route.path.slice(1)];
|
|
174
179
|
if (!sourceFile || !/\.[jt]sx$/.test(sourceFile)) fail('Automatic content bindings require a JSX/TSX source page for each route.');
|
|
175
180
|
const source = await readProjectFile(root, sourceFile);
|
|
176
|
-
|
|
181
|
+
let transformed;
|
|
182
|
+
try { transformed = transformSourcePublication({ source, slug: route.slug, locale: route.locale, filename: sourceFile, publicSource: true }); }
|
|
183
|
+
catch (error) { throw withReviewPaths(error, [sourceFile]); }
|
|
177
184
|
// Projection is part of planning, so unsupported source structure cannot
|
|
178
185
|
// wait until a running page serializes its client provider properties.
|
|
179
186
|
try { publicBindingInventory(transformed.inventory); }
|
package/dist/guided.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
10
10
|
import { createServer } from 'node:http';
|
|
11
11
|
import { spawn } from 'node:child_process';
|
|
12
|
-
import { lstat, readFile } from 'node:fs/promises';
|
|
12
|
+
import { cp, lstat, mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
15
|
import { analyze, applyPlan, projectRoot, readProjectFile, SetupError } from './core.mjs';
|
|
@@ -19,6 +19,10 @@ import { readSetupEnvironment } from './setup-environment.mjs';
|
|
|
19
19
|
import { readSourceSettings } from './source-setup.mjs';
|
|
20
20
|
import { checkPublication } from './publication-check.mjs';
|
|
21
21
|
import { finalizeSource } from './source-finalize.mjs';
|
|
22
|
+
import { preflightSetup } from './preflight.mjs';
|
|
23
|
+
import { planSettingsOnly, SETTINGS_ONLY_FILE } from './settings-only-setup.mjs';
|
|
24
|
+
import { applyEnvironment, planEnvironment } from './setup-environment.mjs';
|
|
25
|
+
import { themeConfiguration } from './theme.mjs';
|
|
22
26
|
|
|
23
27
|
export const PAIRING_WAIT_MS = 10 * 60 * 1000;
|
|
24
28
|
export const WEBSITE_WAIT_MS = 15 * 60 * 1000;
|
|
@@ -110,17 +114,125 @@ async function reviewLayout(root, manifest, framework, io) {
|
|
|
110
114
|
} else if (framework === 'Next.js') {
|
|
111
115
|
const { planSettingsLayoutReview } = await import('./next-settings-layout.mjs');
|
|
112
116
|
replan = () => planSettingsLayoutReview(root);
|
|
113
|
-
} else return;
|
|
117
|
+
} else return false;
|
|
114
118
|
plan = await replan();
|
|
115
|
-
} catch { return; }
|
|
119
|
+
} catch { return false; }
|
|
116
120
|
const changes = plan.files.filter(file => ['create', 'update'].includes(file.state));
|
|
117
|
-
if (!changes.length) return;
|
|
121
|
+
if (!changes.length) return false;
|
|
118
122
|
const sources = [...new Set((plan.slots ?? plan.pages ?? []).map(item => item.path))];
|
|
119
123
|
io.write(`\nYour website already has its own header and footer${sources.length ? ` (${sources.join(', ')})` : ''}.\nI'll keep them exactly as they are and add places where menus and settings from the CMS can appear.\n`);
|
|
120
124
|
if (!yes(await io.ask('Okay? [Y/n] '))) fail('Nothing was changed. Run the same command again when you are ready.');
|
|
121
125
|
if (JSON.stringify(await replan()) !== JSON.stringify(plan)) fail('Your website files changed while reviewing. Nothing was saved; run the same command again.');
|
|
122
126
|
await applyPlan(plan);
|
|
123
127
|
io.write('✔ Header and footer reviewed\n');
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Static paths that are usually interactive application screens rather than
|
|
132
|
+
// editable pages. They are proposed as website-owned; the person can change it.
|
|
133
|
+
export const ALL_APP_SCREENS = 'Every page in this website is an interactive app screen (built with state, tabs or data lists), so none of its page text can be made editable automatically yet.';
|
|
134
|
+
const appScreen = /(^|\/)(cart|basket|bag|checkout|payment|pay|orders?|order-history|track(ing)?|account|my-account|profile|settings|login|log-in|signin|sign-in|signup|sign-up|register|logout|auth|dashboard|admin|portal|wishlist|favou?rites|search|upload[\w-]*|book(ing)?s?|reservations?|messages|inbox|notifications|api)(\/|$)/i;
|
|
135
|
+
|
|
136
|
+
/** Propose which routes stay part of the website app; ask once; save the reviewed manifest. */
|
|
137
|
+
async function reviewRoutes(root, blocker, io, reason) {
|
|
138
|
+
const { inspectNativeRouteCandidates, planNativeRouteReview } = await import('./native-route-review.mjs');
|
|
139
|
+
let inventory;
|
|
140
|
+
try { inventory = await inspectNativeRouteCandidates(root); } catch { return false; }
|
|
141
|
+
const files = new Set(blocker?.paths ?? []);
|
|
142
|
+
const flagged = new Set([...files].map(name => name.replace(/^(?:src\/)?app/, '')));
|
|
143
|
+
for (const row of inventory.candidates) if (files.has(row.file)) flagged.add(row.path);
|
|
144
|
+
const existing = await readProjectFile(root, '.universalcms/native.routes.json').then(text => text ? JSON.parse(text) : null).catch(() => null);
|
|
145
|
+
for (const path of [...existing?.nativePaths ?? [], ...existing?.nativePatterns ?? []]) flagged.add(path);
|
|
146
|
+
// Client components render from state, so their text cannot be bound to CMS content.
|
|
147
|
+
const client = new Set();
|
|
148
|
+
for (const row of inventory.candidates) {
|
|
149
|
+
const text = await readProjectFile(root, row.file).catch(() => null);
|
|
150
|
+
if (text && /^\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*['"]use client['"]/.test(text)) client.add(row.path);
|
|
151
|
+
}
|
|
152
|
+
let owned = inventory.candidates.filter(row => row.kind === 'dynamic' || flagged.has(row.path) || client.has(row.path) || appScreen.test(row.path)).map(row => row.path);
|
|
153
|
+
if (!inventory.candidates.some(row => row.kind === 'static' && !owned.includes(row.path)) && inventory.candidates.filter(row => row.kind === 'static').every(row => client.has(row.path) || flagged.has(row.path) || appScreen.test(row.path)) && inventory.candidates.some(row => client.has(row.path) || flagged.has(row.path))) throw new SetupError(ALL_APP_SCREENS);
|
|
154
|
+
const cmsPages = () => inventory.candidates.filter(row => row.kind === 'static' && !owned.includes(row.path));
|
|
155
|
+
if (!cmsPages().length) {
|
|
156
|
+
if (reason) throw new SetupError(ALL_APP_SCREENS);
|
|
157
|
+
owned = owned.filter(path => path !== '/');
|
|
158
|
+
}
|
|
159
|
+
const show = () => {
|
|
160
|
+
io.write('\nI looked at every page in your website:\n');
|
|
161
|
+
inventory.candidates.forEach((row, index) => io.write(` ${String(index + 1).padStart(2)}. ${row.path === '/' ? '/ (home page)' : row.path}${row.kind === 'dynamic' ? ' (one page per item)' : client.has(row.path) && owned.includes(row.path) ? ' (interactive)' : ''} — ${owned.includes(row.path) ? 'stays as part of your website app' : 'editable in the CMS'}\n`));
|
|
162
|
+
};
|
|
163
|
+
if (reason) io.write(`\n${reason}\n`);
|
|
164
|
+
show();
|
|
165
|
+
io.write('Pages that stay part of your website app keep working exactly as they do now (shopping, accounts, forms, product details).\nAll other pages become editable in the CMS. Header, footer and site settings are managed in the CMS either way.\n');
|
|
166
|
+
while (true) {
|
|
167
|
+
const answer = (await io.ask('Use this? [Y/n, or type page numbers to switch them, e.g. 3,5] ')).trim();
|
|
168
|
+
if (yes(answer)) break;
|
|
169
|
+
if (/^n(o)?$/i.test(answer)) fail('Nothing was changed. Run the same command again when you are ready.');
|
|
170
|
+
if (!/^\d+(\s*,\s*\d+)*$/.test(answer)) { io.write('Type Y, n, or page numbers separated by commas.\n'); continue; }
|
|
171
|
+
for (const index of answer.split(',').map(value => Number(value.trim()) - 1)) {
|
|
172
|
+
const row = inventory.candidates[index];
|
|
173
|
+
if (!row) continue;
|
|
174
|
+
owned = owned.includes(row.path) ? owned.filter(path => path !== row.path) : [...owned, row.path];
|
|
175
|
+
}
|
|
176
|
+
if (!cmsPages().length) { io.write('At least one page must stay editable in the CMS.\n'); owned = owned.filter(path => path !== '/'); }
|
|
177
|
+
show();
|
|
178
|
+
}
|
|
179
|
+
if (!owned.length) return false;
|
|
180
|
+
const plan = await planNativeRouteReview(root, owned);
|
|
181
|
+
if (JSON.stringify(await planNativeRouteReview(root, owned)) !== JSON.stringify(plan)) fail('Your website files changed while reviewing. Nothing was saved; run the same command again.');
|
|
182
|
+
await applyPlan(plan);
|
|
183
|
+
io.write('✔ Website app pages reviewed\n');
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Plain-language guidance for what to change in the website when a blocker cannot be fixed automatically. */
|
|
188
|
+
export function siteChangeAdvice(blocker) {
|
|
189
|
+
const message = blocker?.message ?? '', paths = blocker?.paths ?? [];
|
|
190
|
+
const where = paths.length ? ` (${paths.slice(0, 6).join(', ')}${paths.length > 6 ? ', …' : ''})` : '';
|
|
191
|
+
if (/next\.config|Configuration needs developer inspection|Dynamic Next configuration|configuration uses values/i.test(message)) return [`Your Next.js config is computed when it runs, so it can't be read safely. Make next.config export a plain object, for example:\n module.exports = { reactStrictMode: true };`];
|
|
192
|
+
if (/static export|output:\s*'export'/i.test(message)) return ['Your site is built as static files only. The CMS needs your site to run its Node server: remove `output: "export"` from next.config and deploy with `next start` (or another Node host).'];
|
|
193
|
+
if (/Pages Router|pages\//.test(message)) return ['Your site uses the older Next.js Pages Router (pages/ folder). Move the pages you want editable into the App Router (app/ folder), or ask your developer for a custom integration.'];
|
|
194
|
+
if (/Workspaces|workspace/i.test(message)) return ['Run this command inside the single website folder (the one with its own package.json), not the monorepo root.'];
|
|
195
|
+
if (/version|Node\.js|supported/i.test(message) && /requires?|supports?|unsupported/i.test(message)) return [`A framework or Node.js version is outside the supported range. ${message}`];
|
|
196
|
+
if (message === ALL_APP_SCREENS) return [message, 'To make a page editable from the CMS, write its main text directly in the page file (headings, paragraphs, images as plain JSX in a server component, without \'use client\'), and keep interactive parts like carts, tabs and product lists in separate components. Then run this command again.', 'Ask your developer if you need a custom integration for data-driven pages.'];
|
|
197
|
+
if (blocker?.code === 'content-bindings-review' || /content adapter|bindings|repeated|data-driven|route props/i.test(message)) return [`Some pages${where} build their text from data, props or loops, so the CMS can't tell which words to make editable. Either keep those pages as part of your website app (rerun and switch them), or write the editable text directly in the page (headings, paragraphs and images as plain JSX).`];
|
|
198
|
+
if (/header|footer|layout|chrome/i.test(message)) return [`Your header or footer${where} is built from components or logic the CMS can't safely extend. Put the header and footer markup directly in the root layout (plain <header>/<footer> elements), then rerun.`];
|
|
199
|
+
if (/route|dynamic|parallel/i.test(message)) return [`Some routes${where} use patterns that need a developer: catch-all, parallel or intercepted routes. Keep them in your app and ask your developer for a reviewed adapter.`];
|
|
200
|
+
return ['This website uses a pattern the automatic setup does not support yet.'];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Check the website before anything is installed, fix what can be fixed safely
|
|
205
|
+
* with one plain question each, and explain the rest. Resolving one finding can
|
|
206
|
+
* reveal the next, so it loops a bounded number of times.
|
|
207
|
+
*/
|
|
208
|
+
async function prepareWebsite(root, connection, manifest, framework, io) {
|
|
209
|
+
const scope = Object.fromEntries(['apiOrigin', 'siteId', 'environmentId', 'locale', 'websiteOrigin', 'adminOrigin'].map(name => [name, connection[name]]));
|
|
210
|
+
const attempted = new Set();
|
|
211
|
+
for (let round = 0; round < 6; round++) {
|
|
212
|
+
const result = await preflightSetup(root, scope);
|
|
213
|
+
if (result.status === 'planned') return 'full';
|
|
214
|
+
const blocker = result.blockers?.[0];
|
|
215
|
+
const key = `${blocker?.code}:${blocker?.message}:${(blocker?.paths ?? []).join(',')}`;
|
|
216
|
+
let advice = blocker;
|
|
217
|
+
if (blocker && !attempted.has(key)) try {
|
|
218
|
+
attempted.add(key);
|
|
219
|
+
if (blocker.code === 'next-route-ownership' && await reviewRoutes(root, blocker, io)) continue;
|
|
220
|
+
if (blocker.code === 'content-bindings-review' && framework === 'Next.js' && blocker.paths?.length && await reviewRoutes(root, blocker, io, `${blocker.paths.join(', ')} builds its text from data or loops, so its words can't be made editable automatically. I've switched it to stay part of your website app (it keeps working as it does now).`)) continue;
|
|
221
|
+
if (['native-chrome-layout', 'native-settings-layout'].includes(blocker.code) && await reviewLayout(root, manifest, framework, io)) continue;
|
|
222
|
+
} catch (error) { if (error?.message !== ALL_APP_SCREENS) throw error; advice = { message: ALL_APP_SCREENS }; }
|
|
223
|
+
// Pages that cannot be edited automatically can still have their site
|
|
224
|
+
// settings managed by the CMS while the website keeps rendering every page.
|
|
225
|
+
if (framework === 'Next.js' && (advice?.message === ALL_APP_SCREENS || ['content-bindings-review', 'next-route-ownership'].includes(blocker?.code))) {
|
|
226
|
+
io.write(`\n${advice?.message === ALL_APP_SCREENS ? ALL_APP_SCREENS : 'Some pages in this website can\'t be made editable automatically.'}\n`);
|
|
227
|
+
io.write('You can still connect it for site settings: the CMS manages your menus, logo and branding, SEO (titles, descriptions, social previews), analytics, scripts, cookie consent and redirects. Every page keeps working exactly as it does now.\n');
|
|
228
|
+
if (yes(await io.ask('Connect for site settings? [Y/n] '))) return 'settings-only';
|
|
229
|
+
}
|
|
230
|
+
io.write('\n✋ Your website needs a small change before it can connect:\n');
|
|
231
|
+
for (const line of siteChangeAdvice(advice)) io.write(` • ${line}\n`);
|
|
232
|
+
io.write(`\nDetails for your developer:\n ${result.message ?? blocker?.message ?? 'Preflight did not pass.'}\n`);
|
|
233
|
+
fail('Your website files were not changed (only the Universal CMS package was added). Make the change above, then run the same command again.');
|
|
234
|
+
}
|
|
235
|
+
fail('Setup found more items to review than it can handle automatically. Ask your developer to run the step-by-step setup in Developer setup.');
|
|
124
236
|
}
|
|
125
237
|
|
|
126
238
|
function pkce() {
|
|
@@ -141,7 +253,11 @@ export async function waitForBrowserApproval({ cms, request, io, timeoutMs = PAI
|
|
|
141
253
|
const valid = returned.length === expected.length && timingSafeEqual(returned, expected) && codePattern.test(code);
|
|
142
254
|
res.writeHead(valid ? 200 : 400, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' });
|
|
143
255
|
res.end(valid ? page('Approved — back to your terminal', 'You can close this tab. Setup continues in your terminal.') : page('This link is not valid', 'Start the connection again from your terminal.'));
|
|
144
|
-
if (valid)
|
|
256
|
+
if (valid) {
|
|
257
|
+
let scope = null;
|
|
258
|
+
try { scope = validateConnection({ version: 1, ...Object.fromEntries(['apiOrigin', 'siteId', 'environmentId', 'locale', 'websiteOrigin', 'adminOrigin'].map(name => [name, url.searchParams.get(name) ?? undefined])) }); } catch { /* Older CMS: check after redemption. */ }
|
|
259
|
+
settle.resolve({ code, scope });
|
|
260
|
+
}
|
|
145
261
|
});
|
|
146
262
|
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
|
147
263
|
const timer = setTimeout(() => settle.reject(new SetupError('No approval arrived within 10 minutes. Run the same command again to start over.')), timeoutMs);
|
|
@@ -152,7 +268,8 @@ export async function waitForBrowserApproval({ cms, request, io, timeoutMs = PAI
|
|
|
152
268
|
io.write('\nOpening your browser to approve this website…\n');
|
|
153
269
|
if (!await io.openUrl(link)) io.write('Your browser did not open. Open this link to continue:\n');
|
|
154
270
|
io.write(` ${link}\n\nWaiting for approval… (no browser on this computer? In the CMS open Developer setup → “Connect without a browser”)\n`);
|
|
155
|
-
|
|
271
|
+
const { code, scope } = await result;
|
|
272
|
+
return { code, verifier, scope };
|
|
156
273
|
} finally { clearTimeout(timer); server.close(); }
|
|
157
274
|
}
|
|
158
275
|
|
|
@@ -168,7 +285,8 @@ export async function redeem(cms, code, verifier, fetcher) {
|
|
|
168
285
|
const data = body.data;
|
|
169
286
|
const connection = validateConnection({ version: 1, apiOrigin: data.apiOrigin, siteId: data.siteId, environmentId: data.environmentId, locale: data.locale, websiteOrigin: data.websiteOrigin, adminOrigin: data.adminOrigin });
|
|
170
287
|
if (typeof data.serverKey !== 'string' || !serverKey.test(data.serverKey)) fail('The CMS returned an unexpected response. No key was saved.');
|
|
171
|
-
|
|
288
|
+
const environment = { production: 'Live website', staging: 'Staging', development: 'Development' }[data.environment] ?? '';
|
|
289
|
+
return { connection, siteName: typeof data.siteName === 'string' ? data.siteName.slice(0, 100) : '', environment, serverKey: data.serverKey };
|
|
172
290
|
}
|
|
173
291
|
|
|
174
292
|
function describeRoute(route) { return route.path === '/' ? '/ (home page)' : route.path; }
|
|
@@ -261,9 +379,21 @@ export async function runGuided(directory, options, io) {
|
|
|
261
379
|
const framework = typeof analysis.framework === 'string' && analysis.framework !== 'Unknown' ? analysis.framework : null;
|
|
262
380
|
io.write(`✔ Found ${framework ? `a ${framework} website` : 'a website'} in ${path.basename(root)}\n`);
|
|
263
381
|
|
|
382
|
+
// A Universal CMS theme website only needs pairing and its private key.
|
|
383
|
+
if (await readProjectFile(root, THEME_FILE) !== null) {
|
|
384
|
+
const cms = cmsOrigin(options.cms ?? io.env.UNIVERSALCMS_URL ?? '') ?? fail('Add --cms with your Universal CMS address.');
|
|
385
|
+
const approval = options.code ? { code: options.code } : await waitForBrowserApproval({ cms, io, request: { origin: suggestedOrigin(manifest, null) ?? `http://localhost:${THEME_PORT}`, project: path.basename(root).slice(0, 80), framework: 'Universal CMS theme' } });
|
|
386
|
+
const paired = await redeem(cms, approval.code, approval.verifier, io.fetch);
|
|
387
|
+
await connectTheme(root, paired, io);
|
|
388
|
+
return themeDone(root, paired.connection, await packageManager(root, manifest), false, io);
|
|
389
|
+
}
|
|
264
390
|
const saved = await readConnection(root);
|
|
265
391
|
const environment = await readSetupEnvironment(root);
|
|
266
392
|
const installed = await readProjectFile(root, '.universalcms/website.integration.json') !== null;
|
|
393
|
+
if (saved && environment.keyConfigured && !options.code && await readProjectFile(root, SETTINGS_ONLY_FILE) !== null) {
|
|
394
|
+
io.write(`✔ Already connected for site settings (${saved.websiteOrigin})\n`);
|
|
395
|
+
return finishSettingsOnly(root, saved, manifest, io);
|
|
396
|
+
}
|
|
267
397
|
if (saved && installed && environment.keyConfigured && !options.code) {
|
|
268
398
|
io.write(`✔ Already set up for ${saved.websiteOrigin}\n`);
|
|
269
399
|
return goLive(root, saved, await manifestOf(root), io);
|
|
@@ -279,15 +409,25 @@ export async function runGuided(directory, options, io) {
|
|
|
279
409
|
if (options.code !== undefined && !codePattern.test(options.code)) fail('That connection code is not valid. Copy the whole command from the CMS again.');
|
|
280
410
|
|
|
281
411
|
await ensureConnector(root, manifest, io);
|
|
282
|
-
if (!installed) await reviewLayout(root, manifest, framework, io);
|
|
283
412
|
const approval = options.code
|
|
284
413
|
? { code: options.code, verifier: undefined }
|
|
285
414
|
: await waitForBrowserApproval({ cms, io, request: { origin: saved?.websiteOrigin ?? suggestedOrigin(manifest, framework) ?? '', project: path.basename(root).slice(0, 80), framework: framework ?? '' } });
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
415
|
+
let mode = 'full';
|
|
416
|
+
const mismatch = connection => saved && (saved.siteId !== connection.siteId || saved.environmentId !== connection.environmentId);
|
|
417
|
+
const different = 'This folder is already connected to a different website or environment in the CMS. Choose the same website when approving, or ask your developer to review .universalcms/connection.json. Nothing was changed.';
|
|
418
|
+
// Check (and safely fix) the website before redeeming, so a blocked site
|
|
419
|
+
// never leaves an unused key behind. The approval expires on its own.
|
|
420
|
+
if (approval.scope && !installed) {
|
|
421
|
+
if (mismatch(approval.scope)) fail(different);
|
|
422
|
+
io.write('✔ Approved in the CMS — checking your website…\n');
|
|
423
|
+
mode = await prepareWebsite(root, approval.scope, manifest, framework, io);
|
|
290
424
|
}
|
|
425
|
+
const paired = await redeem(cms, approval.code, approval.verifier, io.fetch);
|
|
426
|
+
io.write(`✔ Connected to ${paired.siteName || 'your website'} (${paired.connection.websiteOrigin})${paired.environment ? ` · ${paired.environment} environment` : ''}\n`);
|
|
427
|
+
if (mismatch(paired.connection)) fail(different);
|
|
428
|
+
if (approval.scope && JSON.stringify(approval.scope) !== JSON.stringify(paired.connection)) fail('The approved website changed before connecting. Nothing was changed; run the same command again.');
|
|
429
|
+
if (!approval.scope && !installed) mode = await prepareWebsite(root, paired.connection, manifest, framework, io);
|
|
430
|
+
if (mode === 'settings-only') return runSettingsOnly(root, paired, manifest, options, io);
|
|
291
431
|
|
|
292
432
|
const adapter = setupIo(io, paired.serverKey, options.details === true);
|
|
293
433
|
try {
|
|
@@ -312,6 +452,116 @@ export async function runGuided(directory, options, io) {
|
|
|
312
452
|
return goLive(root, paired.connection, await manifestOf(root), io);
|
|
313
453
|
}
|
|
314
454
|
|
|
455
|
+
export const THEME_FILE = '.universalcms/theme.json';
|
|
456
|
+
export const THEME_WEBSITE_FILE = '.universalcms/website.json';
|
|
457
|
+
const THEME_PORT = 3100;
|
|
458
|
+
|
|
459
|
+
/** Save the public theme connection and the private key after pairing. */
|
|
460
|
+
async function connectTheme(root, paired, io) {
|
|
461
|
+
const { connection } = paired;
|
|
462
|
+
const environment = await planEnvironment(root, { ...Object.fromEntries(['apiOrigin', 'siteId', 'environmentId', 'locale', 'websiteOrigin', 'adminOrigin'].map(name => [name, connection[name]])), apiKey: paired.serverKey });
|
|
463
|
+
await applyEnvironment(environment);
|
|
464
|
+
await mkdir(path.join(root, '.universalcms'), { recursive: true });
|
|
465
|
+
await writeFile(path.join(root, CONNECTION_FILE_PATH), `${JSON.stringify(connection, null, 2)}\n`);
|
|
466
|
+
await writeFile(path.join(root, THEME_WEBSITE_FILE), `${JSON.stringify(themeConfiguration(connection), null, 2)}\n`);
|
|
467
|
+
io.write('✔ Website connected and private key saved\n');
|
|
468
|
+
}
|
|
469
|
+
const CONNECTION_FILE_PATH = '.universalcms/connection.json';
|
|
470
|
+
|
|
471
|
+
function themeDone(root, connection, manager, created, io) {
|
|
472
|
+
const pages = `${connection.adminOrigin}/sites/${connection.siteId}#setup?environmentId=${connection.environmentId}`;
|
|
473
|
+
io.write(`\n🎉 Your Universal CMS website is ready.\n\n Start it:\n${created ? ` cd ${path.basename(root)}\n` : ''} ${manager} run dev\n Then open ${connection.websiteOrigin}\n\n`);
|
|
474
|
+
io.write(` Manage everything from the CMS: ${pages}\n`);
|
|
475
|
+
io.write(' • Create a page with the slug "home" — it becomes your home page\n • Pages and posts appear on the website when you publish them\n • Menus, logo, colors and SEO: Appearance and SEO in the CMS\n');
|
|
476
|
+
return { connected: true, mode: 'theme', done: true };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** `create <folder>`: start a new website rendered entirely from Universal CMS. */
|
|
480
|
+
export async function runCreate(parent, name, options, io) {
|
|
481
|
+
if (typeof name !== 'string' || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(name)) fail('Choose a simple folder name for the new website, for example: my-website');
|
|
482
|
+
const root = path.resolve(parent, name);
|
|
483
|
+
try { if ((await readdir(root)).length) fail(`The folder ${name} already exists and is not empty. Choose another name.`); }
|
|
484
|
+
catch (error) { if (error instanceof SetupError) throw error; if (error.code !== 'ENOENT') throw error; }
|
|
485
|
+
const cms = cmsOrigin(options.cms ?? io.env.UNIVERSALCMS_URL ?? '') ?? await (async () => {
|
|
486
|
+
while (true) {
|
|
487
|
+
const value = cmsOrigin((await io.ask('Your Universal CMS address (for example https://cms.example.com): ')).trim());
|
|
488
|
+
if (value) return value;
|
|
489
|
+
io.write('Enter the address you use to sign in to the CMS.\n');
|
|
490
|
+
}
|
|
491
|
+
})();
|
|
492
|
+
io.write(`\nUniversal CMS · create a new website in ${name}\n`);
|
|
493
|
+
// Copy into a temporary sibling first so a failed copy never leaves a half-made folder.
|
|
494
|
+
const staging = `${root}.universalcms-${randomBytes(4).toString('hex')}`;
|
|
495
|
+
await cp(fileURLToPath(new URL('./templates/next-theme/', import.meta.url)), staging, { recursive: true, errorOnExist: true, force: false });
|
|
496
|
+
await rename(path.join(staging, 'gitignore.template'), path.join(staging, '.gitignore'));
|
|
497
|
+
const version = await connectorVersion();
|
|
498
|
+
const override = io.env.UNIVERSALCMS_CONNECT_PACKAGE;
|
|
499
|
+
if (override !== undefined && !/^[^\s'"`;&|<>$]{1,2048}$/.test(override)) fail('UNIVERSALCMS_CONNECT_PACKAGE must be a package spec, file path or URL without spaces.');
|
|
500
|
+
const manifest = { name: name.toLowerCase().replace(/[^a-z0-9._-]/g, '-'), private: true, version: '0.1.0', scripts: { dev: `next dev -p ${THEME_PORT}`, build: 'next build', start: `next start -p ${THEME_PORT}` },
|
|
501
|
+
dependencies: { '@universalcms/connect': override ?? `^${version}`, next: '^16.4.2', react: '^19.2.3', 'react-dom': '^19.2.3' },
|
|
502
|
+
devDependencies: { '@types/node': '^24.0.0', '@types/react': '^19.2.0', typescript: '^5.9.0' } };
|
|
503
|
+
await writeFile(path.join(staging, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
504
|
+
await rename(staging, root);
|
|
505
|
+
io.write('✔ Website files created\n');
|
|
506
|
+
io.write('Installing packages (this can take a minute)…\n');
|
|
507
|
+
if (await io.run('npm', ['install', '--no-audit', '--no-fund'], root) !== 0) fail(`Packages could not be installed. Check your internet connection, then run:\n cd ${name} && npm install\nand then: npx @universalcms/connect@latest`);
|
|
508
|
+
io.write('✔ Packages installed\n');
|
|
509
|
+
const approval = options.code ? { code: options.code } : await waitForBrowserApproval({ cms, io, request: { origin: `http://localhost:${THEME_PORT}`, project: name.slice(0, 80), framework: 'Universal CMS theme' } });
|
|
510
|
+
const paired = await redeem(cms, approval.code, approval.verifier, io.fetch);
|
|
511
|
+
io.write(`✔ Approved: ${paired.siteName || 'your website'} (${paired.connection.websiteOrigin})\n`);
|
|
512
|
+
await connectTheme(root, paired, io);
|
|
513
|
+
return themeDone(root, paired.connection, 'npm', true, io);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Ask the running website's settings endpoint, as the CMS admin does. */
|
|
517
|
+
async function settingsReady(connection, io) {
|
|
518
|
+
try {
|
|
519
|
+
const url = new URL('/api/universalcms/settings', connection.websiteOrigin);
|
|
520
|
+
url.search = new URLSearchParams({ check: 'connection', siteId: connection.siteId, environmentId: connection.environmentId }).toString();
|
|
521
|
+
const response = await io.fetch(url, { headers: { Origin: connection.adminOrigin, Accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(10000) });
|
|
522
|
+
const body = response.ok ? await response.json() : null;
|
|
523
|
+
return body?.mode === 'settings-only' && body.siteId === connection.siteId && body.environmentId === connection.environmentId;
|
|
524
|
+
} catch { return false; }
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function finishSettingsOnly(root, connection, manifest, io) {
|
|
528
|
+
if (!await settingsReady(connection, io)) {
|
|
529
|
+
const local = /^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|$)/.test(connection.websiteOrigin), command = await restartHint(root, manifest);
|
|
530
|
+
io.write(local
|
|
531
|
+
? `\n→ Restart your website so the changes take effect:\n 1. In the terminal where your website is running, press Ctrl+C\n 2. Start it again${command ? ` with: ${command}` : ''}\n It must run at ${connection.websiteOrigin}\n`
|
|
532
|
+
: `\n→ Deploy your website the way you normally do. It must be reachable at ${connection.websiteOrigin}\n`);
|
|
533
|
+
io.write(` Waiting for ${connection.websiteOrigin} … (Ctrl+C stops waiting; run this command again later to continue)\n`);
|
|
534
|
+
const deadline = io.now() + WEBSITE_WAIT_MS;
|
|
535
|
+
let ready = false;
|
|
536
|
+
while (!ready && io.now() < deadline) { await io.sleep(3000); ready = await settingsReady(connection, io); }
|
|
537
|
+
if (!ready) { io.write(`\nThe website at ${connection.websiteOrigin} is not serving CMS settings yet. Run this command again once it's running.\n`); return { connected: false, mode: 'settings-only' }; }
|
|
538
|
+
}
|
|
539
|
+
const settings = `${connection.adminOrigin}/sites/${connection.siteId}#setup?environmentId=${connection.environmentId}`;
|
|
540
|
+
io.write(`✔ Your website is connected for site settings\n\n🎉 All done! Manage it from Universal CMS (this link opens the right environment):\n ${settings}\n`);
|
|
541
|
+
io.write(' • SEO, branding, analytics, scripts, cookie consent and redirects apply automatically.\n');
|
|
542
|
+
io.write(' • To show the CMS menu in your own header or footer, add one line where it should appear:\n');
|
|
543
|
+
io.write(" import { CmsNavigation } from '@universalcms/connect/react-website';\n <CmsNavigation location=\"header\" /> (or location=\"footer\")\n");
|
|
544
|
+
io.write(' Other drop-ins: <CmsBranding />, <CmsSocialLinks />, and useWebsiteSettings() for anything custom.\n');
|
|
545
|
+
return { connected: true, mode: 'settings-only', done: true };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Install the settings-only integration and save the website key privately. */
|
|
549
|
+
async function runSettingsOnly(root, paired, manifest, options, io) {
|
|
550
|
+
const plan = await planSettingsOnly(root, Object.fromEntries(['apiOrigin', 'siteId', 'environmentId', 'locale', 'websiteOrigin', 'adminOrigin'].map(name => [name, paired.connection[name]])));
|
|
551
|
+
const environment = await planEnvironment(root, { ...Object.fromEntries(['apiOrigin', 'siteId', 'environmentId', 'locale', 'websiteOrigin', 'adminOrigin'].map(name => [name, paired.connection[name]])), apiKey: paired.serverKey });
|
|
552
|
+
const added = plan.files.filter(file => file.state === 'create').length, changed = plan.files.filter(file => file.state === 'update').length;
|
|
553
|
+
io.write(`\nReady to connect your website for site settings:\n • ${added} file${added === 1 ? '' : 's'} will be added${changed ? ` and ${changed} updated (your root layout gets one wrapper)` : ''} — your pages and design stay the same\n • The website key is saved privately in .env.local (never committed to Git)\n`);
|
|
554
|
+
if (options.details) for (const file of plan.files) io.write(` ${file.state}: ${file.path}\n`);
|
|
555
|
+
else io.write(' (run with --details to list every file)\n');
|
|
556
|
+
if (!yes(await io.ask('\nGo ahead? [Y/n] '))) { io.write('Cancelled. Nothing was changed.\n'); return { connected: false }; }
|
|
557
|
+
const current = await planSettingsOnly(root, plan.connection);
|
|
558
|
+
if (JSON.stringify(current.files) !== JSON.stringify(plan.files)) fail('Your website files changed while reviewing. Nothing was saved; run the same command again.');
|
|
559
|
+
await applyEnvironment(environment);
|
|
560
|
+
await applyPlan(plan);
|
|
561
|
+
io.write('✔ Website files and private key saved\n');
|
|
562
|
+
return finishSettingsOnly(root, paired.connection, await manifestOf(root), io);
|
|
563
|
+
}
|
|
564
|
+
|
|
315
565
|
/** Process-backed effects for the CLI. */
|
|
316
566
|
export function processIo({ ask, write }) {
|
|
317
567
|
return {
|
|
@@ -60,6 +60,14 @@ export function setupReviewError(code, paths = []) {
|
|
|
60
60
|
return error;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/** Attach sanitized source paths to a fixed planner reason, keeping its message and code. */
|
|
64
|
+
export function withReviewPaths(error, paths) {
|
|
65
|
+
const fixed = error instanceof SetupError && fixedReasons.get(error.message);
|
|
66
|
+
if (!fixed || diagnostics.has(error)) return error;
|
|
67
|
+
diagnostics.set(error, { ...fixed, message: error.message, ...sanitizeSetupPaths(paths) });
|
|
68
|
+
return error;
|
|
69
|
+
}
|
|
70
|
+
|
|
63
71
|
export function setupReviewDiagnostic(error) {
|
|
64
72
|
const value = diagnostics.get(error);
|
|
65
73
|
if (value) return { ...value, paths: [...value.paths] };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plans a settings-only connection for a Next.js App Router website: the site
|
|
3
|
+
* keeps every page, and the CMS manages navigation, branding, SEO, analytics,
|
|
4
|
+
* scripts, consent and other site settings. Only additive files plus one
|
|
5
|
+
* wrapper around the root layout's <body> children are written.
|
|
6
|
+
*/
|
|
7
|
+
import ts from './vendor/typescript.cjs';
|
|
8
|
+
import { projectRoot, readProjectFile, SetupError } from './project-files.mjs';
|
|
9
|
+
import { inspectSourceRoutes } from './source-routes.mjs';
|
|
10
|
+
import { CONNECTION_FILE, validateConnection } from './staged-setup.mjs';
|
|
11
|
+
|
|
12
|
+
export const SETTINGS_ONLY_FILE = '.universalcms/settings-only.json';
|
|
13
|
+
const fail = message => { throw new SetupError(message); };
|
|
14
|
+
const json = value => `${JSON.stringify(value, null, 2)}\n`;
|
|
15
|
+
const relative = (from, to) => { const up = from.split('/').length - 1; return `${'../'.repeat(up) || './'}${to}`; };
|
|
16
|
+
const MARK = '// Generated by @universalcms/connect (settings-only).';
|
|
17
|
+
|
|
18
|
+
async function appDirectory(root) {
|
|
19
|
+
const inventory = await inspectSourceRoutes(root);
|
|
20
|
+
const first = Object.values(inventory.files)[0];
|
|
21
|
+
if (!first) fail('No App Router pages were found. Settings-only connection supports Next.js App Router websites.');
|
|
22
|
+
return first.startsWith('src/app/') ? 'src/app' : 'app';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function wrapBody(source, filename) {
|
|
26
|
+
const ast = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true, /x$/.test(filename) ? ts.ScriptKind.TSX : ts.ScriptKind.JSX);
|
|
27
|
+
if (ast.parseDiagnostics.length) fail(`${filename} could not be read. Fix its syntax, then run the command again.`);
|
|
28
|
+
if (ast.statements[0] && ts.isExpressionStatement(ast.statements[0]) && ts.isStringLiteral(ast.statements[0].expression) && ast.statements[0].expression.text === 'use client') fail(`${filename} is a client component. The root layout must be a server component (remove 'use client' from the root layout and move interactive parts into their own components).`);
|
|
29
|
+
const bodies = [];
|
|
30
|
+
(function visit(node) { if (ts.isJsxElement(node) && node.openingElement.tagName.getText(ast) === 'body') bodies.push(node); ts.forEachChild(node, visit); })(ast);
|
|
31
|
+
if (bodies.length !== 1) fail(`${filename} needs exactly one literal <body> element so site settings can wrap the page. Write <body>…</body> directly in the root layout.`);
|
|
32
|
+
const body = bodies[0];
|
|
33
|
+
let insertion = 0;
|
|
34
|
+
for (const statement of ast.statements) { if (ts.isImportDeclaration(statement) || ts.isExpressionStatement(statement) && ts.isStringLiteral(statement.expression)) insertion = statement.end; else break; }
|
|
35
|
+
const edits = [
|
|
36
|
+
{ at: body.closingElement.pos, text: '</UniversalCmsSettings>' },
|
|
37
|
+
{ at: body.openingElement.end, text: '<UniversalCmsSettings>' },
|
|
38
|
+
{ at: insertion, text: `\nimport UniversalCmsSettings from './universalcms.settings';` },
|
|
39
|
+
];
|
|
40
|
+
for (const edit of edits.sort((a, b) => b.at - a.at)) source = source.slice(0, edit.at) + edit.text + source.slice(edit.at);
|
|
41
|
+
return source;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const settingsRoute = (app, file) => `${MARK} Site settings served to the website; no private value is returned.
|
|
45
|
+
import { createSettingsOnlyHandler } from '@universalcms/connect/settings-only';
|
|
46
|
+
import configuration from '${relative(file, SETTINGS_ONLY_FILE)}';
|
|
47
|
+
import connection from '${relative(file, CONNECTION_FILE)}';
|
|
48
|
+
|
|
49
|
+
const handle = createSettingsOnlyHandler({ configuration: configuration as Parameters<typeof createSettingsOnlyHandler>[0]['configuration'], adminOrigin: connection.adminOrigin });
|
|
50
|
+
export const dynamic = 'force-dynamic';
|
|
51
|
+
export const GET = handle;
|
|
52
|
+
export const HEAD = handle;
|
|
53
|
+
export const OPTIONS = handle;
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
const wrapperTemplate = (file, typed) => `${MARK} Supplies CMS site settings to the whole website.
|
|
57
|
+
import { connection } from 'next/server';
|
|
58
|
+
${typed ? "import type { ReactNode } from 'react';\n" : ''}import { WebsiteProvider } from '@universalcms/connect/react-website';
|
|
59
|
+
import { createSettingsOnlyRuntime } from '@universalcms/connect/settings-only';
|
|
60
|
+
import '@universalcms/connect/website.css';
|
|
61
|
+
import configuration from '${relative(file, SETTINGS_ONLY_FILE)}';
|
|
62
|
+
|
|
63
|
+
const runtime = createSettingsOnlyRuntime({ configuration${typed ? ': configuration as Parameters<typeof createSettingsOnlyRuntime>[0][\'configuration\']' : ''} });
|
|
64
|
+
export default async function UniversalCmsSettings({ children }${typed ? ': { children: ReactNode }' : ''}) {
|
|
65
|
+
await connection();
|
|
66
|
+
const settings = await runtime.settings();
|
|
67
|
+
return <WebsiteProvider scope=${'{'}${'`'}\${configuration.siteId}:\${configuration.environmentId}${'`'}${'}'} initialSettings={settings}>{children}</WebsiteProvider>;
|
|
68
|
+
}
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
/** @returns {Promise<{ root: string, files: { path: string, content: string, state: string, previous?: string }[] }>} */
|
|
72
|
+
export async function planSettingsOnly(directory, connectionInput) {
|
|
73
|
+
const root = await projectRoot(directory), connection = validateConnection({ version: 1, ...connectionInput });
|
|
74
|
+
const app = await appDirectory(root);
|
|
75
|
+
const layouts = [];
|
|
76
|
+
for (const ext of ['tsx', 'jsx', 'js', 'ts']) if (await readProjectFile(root, `${app}/layout.${ext}`) !== null) layouts.push(`${app}/layout.${ext}`);
|
|
77
|
+
if (layouts.length !== 1) fail(`Settings-only connection needs one root layout file in ${app}/.`);
|
|
78
|
+
const layout = layouts[0], typed = /\.tsx?$/.test(layout), ext = typed ? 'ts' : 'js';
|
|
79
|
+
const files = [];
|
|
80
|
+
const add = async (path, content) => {
|
|
81
|
+
const current = await readProjectFile(root, path);
|
|
82
|
+
if (current !== null && current !== content && !current.startsWith(MARK)) fail(`${path} already exists. Rename or remove it, then run the command again.`);
|
|
83
|
+
files.push({ path, content, state: current === null ? 'create' : current === content ? 'unchanged' : 'update', ...(current !== null && current !== content ? { previous: current } : {}) });
|
|
84
|
+
};
|
|
85
|
+
await add(CONNECTION_FILE, json(connection));
|
|
86
|
+
await add(SETTINGS_ONLY_FILE, json({ version: 1, apiOrigin: connection.apiOrigin, siteId: connection.siteId, environmentId: connection.environmentId, websiteOrigin: connection.websiteOrigin, routes: [] }));
|
|
87
|
+
for (const name of ['settings', 'page']) { const path = `${app}/api/universalcms/${name}/route.${ext}`; await add(path, settingsRoute(app, path).replace(typed ? '' : / as Parameters<[^>]+>\[0\]\['configuration'\]/, '')); }
|
|
88
|
+
const wrapper = `${app}/universalcms.settings.${typed ? 'tsx' : 'jsx'}`;
|
|
89
|
+
await add(wrapper, wrapperTemplate(wrapper, typed));
|
|
90
|
+
const source = await readProjectFile(root, layout);
|
|
91
|
+
if (!source.includes("import UniversalCmsSettings from './universalcms.settings';")) {
|
|
92
|
+
const content = wrapBody(source, layout);
|
|
93
|
+
files.push({ path: layout, content, state: 'update', previous: source });
|
|
94
|
+
} else files.push({ path: layout, content: source, state: 'unchanged' });
|
|
95
|
+
return { root, connection, app, files };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function isSettingsOnly(directory) {
|
|
99
|
+
return await readProjectFile(await projectRoot(directory), SETTINGS_ONLY_FILE) !== null;
|
|
100
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export const SETTINGS_ONLY_MODE: 'settings-only';
|
|
2
|
+
export type SettingsOnlyConfiguration = { version: 1; apiOrigin: string; siteId: string; environmentId: string; websiteOrigin: string; routes: [] };
|
|
3
|
+
export function createSettingsOnlyHandler(options: { configuration: SettingsOnlyConfiguration; adminOrigin: string; env?: () => Record<string, string | undefined>; fetcher?: typeof fetch }): (request: Request) => Promise<Response>;
|
|
4
|
+
export function createSettingsOnlyRuntime(options: { configuration: SettingsOnlyConfiguration; env?: () => Record<string, string | undefined>; fetcher?: typeof fetch }): { settings(): Promise<unknown> };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings-only website runtime. The website keeps rendering every page itself;
|
|
3
|
+
* the CMS supplies header/footer navigation, branding, SEO, analytics, scripts,
|
|
4
|
+
* consent and other site settings. It serves the same settings endpoint as a
|
|
5
|
+
* full integration and reports every page as website-owned.
|
|
6
|
+
*/
|
|
7
|
+
import { createWebsiteRuntime } from './website-runtime.mjs';
|
|
8
|
+
import { WEBSITE_PAGE_PATH, WEBSITE_SECTIONS, WEBSITE_SETTINGS_PATH } from './website-http.mjs';
|
|
9
|
+
|
|
10
|
+
export const SETTINGS_ONLY_MODE = 'settings-only';
|
|
11
|
+
const base = { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer' };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {{ configuration: { version: 1, apiOrigin: string, siteId: string, environmentId: string, websiteOrigin: string, routes: [] }, adminOrigin: string, env?: () => Record<string, string | undefined>, fetcher?: typeof fetch }} options
|
|
15
|
+
*/
|
|
16
|
+
export function createSettingsOnlyHandler({ configuration, adminOrigin, env = () => process.env, fetcher = globalThis.fetch }) {
|
|
17
|
+
const runtime = createWebsiteRuntime({ configuration, settingsOnly: true, env, fetcher });
|
|
18
|
+
const admin = new URL(adminOrigin).origin;
|
|
19
|
+
if (admin !== adminOrigin) throw new Error('Invalid CMS admin origin');
|
|
20
|
+
return async function handle(request) {
|
|
21
|
+
const method = request.method, url = new URL(request.url), origin = request.headers.get('origin');
|
|
22
|
+
const reply = (body, status = 200, extra = {}) => new Response(method === 'HEAD' || status === 204 ? null : JSON.stringify(body), { status, headers: { ...base, 'Content-Type': 'application/json', ...extra } });
|
|
23
|
+
if (url.pathname === WEBSITE_PAGE_PATH) {
|
|
24
|
+
// Every page belongs to the website in settings-only mode.
|
|
25
|
+
if (!['GET', 'HEAD'].includes(method)) return reply({ error: 'Method not allowed' }, 405, { Allow: 'GET, HEAD' });
|
|
26
|
+
return reply({ state: 'missing' }, 404);
|
|
27
|
+
}
|
|
28
|
+
if (url.pathname !== WEBSITE_SETTINGS_PATH) return reply({ error: 'Not found' }, 404);
|
|
29
|
+
const diagnostic = url.searchParams.get('check') === 'connection';
|
|
30
|
+
if (diagnostic) {
|
|
31
|
+
// The CMS admin reads this without credentials; nothing private is returned.
|
|
32
|
+
if (origin !== admin || request.headers.has('authorization') || request.headers.has('cookie')) return reply({ error: 'Request not allowed' }, 403);
|
|
33
|
+
const cors = { 'Access-Control-Allow-Origin': admin, Vary: 'Origin', 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS' };
|
|
34
|
+
if (method === 'OPTIONS') return request.headers.get('access-control-request-method') === 'GET' && !request.headers.has('access-control-request-headers') ? reply(null, 204, cors) : reply({ error: 'Request not allowed' }, 403);
|
|
35
|
+
if (url.searchParams.size !== 3 || url.searchParams.get('siteId') !== configuration.siteId || url.searchParams.get('environmentId') !== configuration.environmentId) {
|
|
36
|
+
// Tell the CMS which environment this website is connected to, so the
|
|
37
|
+
// admin can switch to it. Only public identifiers of the same website.
|
|
38
|
+
const sameSite = url.searchParams.get('siteId') === configuration.siteId;
|
|
39
|
+
return reply({ error: 'Website scope mismatch', ...(sameSite ? { siteId: configuration.siteId, environmentId: configuration.environmentId } : {}) }, 409, cors);
|
|
40
|
+
}
|
|
41
|
+
const snapshot = await runtime.settings();
|
|
42
|
+
return reply({ version: 1, mode: SETTINGS_ONLY_MODE, siteId: configuration.siteId, environmentId: configuration.environmentId, renderedVerified: false,
|
|
43
|
+
settings: Object.fromEntries(WEBSITE_SECTIONS.map(section => [section, { installed: true, state: snapshot.sections[section]?.state ?? 'unavailable', version: snapshot.sections[section]?.version ?? 0 }])) }, 200, cors);
|
|
44
|
+
}
|
|
45
|
+
if (!['GET', 'HEAD'].includes(method)) return reply({ error: 'Method not allowed' }, 405, { Allow: 'GET, HEAD' });
|
|
46
|
+
if (url.searchParams.size || origin && origin !== configuration.websiteOrigin || request.headers.get('sec-fetch-site') === 'cross-site') return reply({ error: 'Request not allowed' }, 403);
|
|
47
|
+
return reply(await runtime.settings());
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Server-side settings snapshot for the generated root layout wrapper. */
|
|
52
|
+
export function createSettingsOnlyRuntime({ configuration, env = () => process.env, fetcher = globalThis.fetch }) {
|
|
53
|
+
return createWebsiteRuntime({ configuration, settingsOnly: true, env, fetcher });
|
|
54
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Universal CMS website
|
|
2
|
+
|
|
3
|
+
Every page, post, menu, logo and SEO setting of this website is managed in
|
|
4
|
+
Universal CMS. Start it with:
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
npm run dev
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
The home page is the CMS page with the slug `home`. Pages and posts appear at
|
|
11
|
+
the URLs set in the CMS (SEO → URL patterns) once they are published.
|
|
12
|
+
|
|
13
|
+
Connection values live in `.universalcms/`; the private website key lives only in
|
|
14
|
+
`.env.local`. To reconnect, run `npx @universalcms/connect@latest`.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Every URL is rendered from published Universal CMS content.
|
|
2
|
+
import { cache } from 'react';
|
|
3
|
+
import { connection } from 'next/server';
|
|
4
|
+
import { notFound, permanentRedirect, redirect } from 'next/navigation';
|
|
5
|
+
import { websiteMetadata } from '@universalcms/connect/website-runtime';
|
|
6
|
+
import { CmsWebsitePage } from '@universalcms/connect/react-website';
|
|
7
|
+
import { configuration, runtime, scope } from '../../lib/universalcms';
|
|
8
|
+
|
|
9
|
+
const settings = cache(async () => { await connection(); return runtime.settings(); });
|
|
10
|
+
const read = cache(async (pathname: string) => { await connection(); return runtime.page(pathname); });
|
|
11
|
+
const load = cache(async (pathname: string) => {
|
|
12
|
+
const result = await read(pathname);
|
|
13
|
+
if (result.state === 'redirect') { const target = result as { location: string; status?: number }; if (target.status === 301 || target.status === 308) permanentRedirect(target.location); redirect(target.location); }
|
|
14
|
+
if (result.state === 'missing' || result.state === 'unmanaged' || result.state === 'gone') notFound();
|
|
15
|
+
if (result.state !== 'published' && result.state !== 'archive') throw new Error('Website content is temporarily unavailable');
|
|
16
|
+
return result;
|
|
17
|
+
});
|
|
18
|
+
type Props = { params: Promise<{ cmsPath?: string[] }> };
|
|
19
|
+
const pathOf = async (params: Props['params']) => '/' + ((await params).cmsPath ?? []).join('/');
|
|
20
|
+
|
|
21
|
+
export async function generateMetadata({ params }: Props) {
|
|
22
|
+
// Metadata never throws: the page itself decides between content, redirect and 404.
|
|
23
|
+
const pathname = await pathOf(params), result = await read(pathname), snapshot = await settings();
|
|
24
|
+
if (result.state !== 'published' && result.state !== 'archive') return {};
|
|
25
|
+
const entry = result.state === 'published' ? result.entry : null;
|
|
26
|
+
const archiveTitle = result.state === 'archive' ? (result as { title: string }).title : undefined;
|
|
27
|
+
if (snapshot.sections.seo.state !== 'ready') return { title: archiveTitle ?? entry?.document.title, description: entry?.document.excerpt };
|
|
28
|
+
const value = websiteMetadata(entry, snapshot, pathname, configuration.websiteOrigin);
|
|
29
|
+
return {
|
|
30
|
+
title: archiveTitle ?? value.title, description: value.description, robots: value.robots,
|
|
31
|
+
alternates: { canonical: value.canonical, languages: Object.fromEntries((value.alternates ?? []).map(item => [item.locale, item.url])) },
|
|
32
|
+
openGraph: value.openGraph ? { ...value.openGraph, images: value.openGraph.image ? [value.openGraph.image] : undefined } : undefined,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default async function Page({ params }: Props) {
|
|
37
|
+
const pathname = await pathOf(params), result = await load(pathname), snapshot = await settings();
|
|
38
|
+
const structured = websiteMetadata(result.state === 'published' ? result.entry : null, snapshot, pathname, configuration.websiteOrigin).jsonLd;
|
|
39
|
+
return <>
|
|
40
|
+
{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}/>
|
|
42
|
+
</>;
|
|
43
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Universal CMS website endpoints (settings, live page updates, sitemap, robots).
|
|
2
|
+
import { createThemeHandler } from "@universalcms/connect/theme";
|
|
3
|
+
import { adminOrigin, configuration } from "../../../../lib/universalcms";
|
|
4
|
+
|
|
5
|
+
const handle = createThemeHandler({ configuration, adminOrigin });
|
|
6
|
+
export const dynamic = "force-dynamic";
|
|
7
|
+
export const GET = handle;
|
|
8
|
+
export const HEAD = handle;
|
|
9
|
+
export const OPTIONS = handle;
|
|
10
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/* Universal CMS theme base styles. Brand colors come from CMS Branding. */
|
|
2
|
+
:root { --cms-website-accent: #4f46e5; color-scheme: light; }
|
|
3
|
+
* { 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; }
|
|
5
|
+
.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; }
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import '@universalcms/connect/website.css';
|
|
3
|
+
import './globals.css';
|
|
4
|
+
|
|
5
|
+
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
6
|
+
return (
|
|
7
|
+
<html lang="en">
|
|
8
|
+
<body>{children}</body>
|
|
9
|
+
</html>
|
|
10
|
+
);
|
|
11
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { connection } from 'next/server';
|
|
2
|
+
import { CmsExperience, CmsWebsite } from '@universalcms/connect/react-website';
|
|
3
|
+
import { runtime, scope } from '../lib/universalcms';
|
|
4
|
+
|
|
5
|
+
export default async function NotFound() {
|
|
6
|
+
await connection();
|
|
7
|
+
const settings = await runtime.settings();
|
|
8
|
+
return <CmsWebsite scope={scope} initialSettings={settings} applyTemplate={false}><main><CmsExperience notFound /></main></CmsWebsite>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Universal CMS website endpoints (settings, live page updates, sitemap, robots).
|
|
2
|
+
import { createThemeHandler } from "@universalcms/connect/theme";
|
|
3
|
+
import { adminOrigin, configuration } from "../../lib/universalcms";
|
|
4
|
+
|
|
5
|
+
const handle = createThemeHandler({ configuration, adminOrigin });
|
|
6
|
+
export const dynamic = "force-dynamic";
|
|
7
|
+
export const GET = handle;
|
|
8
|
+
export const HEAD = handle;
|
|
9
|
+
export const OPTIONS = handle;
|
|
10
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Universal CMS website endpoints (settings, live page updates, sitemap, robots).
|
|
2
|
+
import { createThemeHandler } from "@universalcms/connect/theme";
|
|
3
|
+
import { adminOrigin, configuration } from "../../lib/universalcms";
|
|
4
|
+
|
|
5
|
+
const handle = createThemeHandler({ configuration, adminOrigin });
|
|
6
|
+
export const dynamic = "force-dynamic";
|
|
7
|
+
export const GET = handle;
|
|
8
|
+
export const HEAD = handle;
|
|
9
|
+
export const OPTIONS = handle;
|
|
10
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Universal CMS theme: shared runtime. Connection values are public; the
|
|
2
|
+
// website key is read from .env.local on the server only.
|
|
3
|
+
import { createThemeRuntime } from '@universalcms/connect/theme';
|
|
4
|
+
import website from '../.universalcms/website.json';
|
|
5
|
+
import connection from '../.universalcms/connection.json';
|
|
6
|
+
|
|
7
|
+
export const configuration = website as Parameters<typeof createThemeRuntime>[0]['configuration'];
|
|
8
|
+
export const adminOrigin = connection.adminOrigin;
|
|
9
|
+
export const scope = `${website.siteId}:${website.environmentId}`;
|
|
10
|
+
export const runtime = createThemeRuntime({ configuration });
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default { htmlLimitedBots: /.*/ };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": [
|
|
5
|
+
"dom",
|
|
6
|
+
"dom.iterable",
|
|
7
|
+
"esnext"
|
|
8
|
+
],
|
|
9
|
+
"jsx": "react-jsx",
|
|
10
|
+
"module": "esnext",
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"strict": true,
|
|
13
|
+
"esModuleInterop": true,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"noEmit": true,
|
|
16
|
+
"incremental": true,
|
|
17
|
+
"resolveJsonModule": true,
|
|
18
|
+
"isolatedModules": true,
|
|
19
|
+
"plugins": [
|
|
20
|
+
{
|
|
21
|
+
"name": "next"
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"allowJs": true
|
|
25
|
+
},
|
|
26
|
+
"include": [
|
|
27
|
+
"**/*.ts",
|
|
28
|
+
"**/*.tsx",
|
|
29
|
+
".next/types/**/*.ts",
|
|
30
|
+
".next/dev/types/**/*.ts"
|
|
31
|
+
],
|
|
32
|
+
"exclude": [
|
|
33
|
+
"node_modules"
|
|
34
|
+
]
|
|
35
|
+
}
|
package/dist/theme.d.mts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { createWebsiteRuntime } from './website-runtime.mjs';
|
|
2
|
+
export const THEME_MODE: 'theme';
|
|
3
|
+
export type ThemeConfiguration = { version: 1; apiOrigin: string; siteId: string; environmentId: string; websiteOrigin: string; routes: { path: string; kind: string; slug: string; locale: string }[] };
|
|
4
|
+
export function themeConfiguration(connection: { apiOrigin: string; siteId: string; environmentId: string; websiteOrigin: string; locale?: string }): ThemeConfiguration;
|
|
5
|
+
export function createThemeRuntime(options: { configuration: ThemeConfiguration; env?: () => Record<string, string | undefined>; fetcher?: typeof fetch }): ReturnType<typeof createWebsiteRuntime>;
|
|
6
|
+
export function createThemeHandler(options: { configuration: ThemeConfiguration; adminOrigin: string; env?: () => Record<string, string | undefined>; fetcher?: typeof fetch }): (request: Request) => Promise<Response>;
|
package/dist/theme.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Universal CMS theme runtime: every URL of the website is rendered from
|
|
3
|
+
* published CMS content. The home page is the page with slug `home`; all other
|
|
4
|
+
* paths follow the CMS URL patterns (pages, posts, categories, tags).
|
|
5
|
+
*/
|
|
6
|
+
import { createWebsiteRuntime } from './website-runtime.mjs';
|
|
7
|
+
import { createWebsiteRequestHandler, WEBSITE_SECTIONS, WEBSITE_SETTINGS_PATH } from './website-http.mjs';
|
|
8
|
+
|
|
9
|
+
export const THEME_MODE = 'theme';
|
|
10
|
+
|
|
11
|
+
/** Publication configuration for a theme website: only the home page needs a fixed route. */
|
|
12
|
+
export function themeConfiguration({ apiOrigin, siteId, environmentId, websiteOrigin, locale = 'en' }) {
|
|
13
|
+
return { version: 1, apiOrigin, siteId, environmentId, websiteOrigin, routes: [{ path: '/', kind: 'page', slug: 'home', locale }] };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createThemeRuntime({ configuration, env = () => process.env, fetcher = globalThis.fetch }) {
|
|
17
|
+
return createWebsiteRuntime({ configuration, env, fetcher });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Serves /api/universalcms/{settings,page,document}, /sitemap.xml and /robots.txt
|
|
22
|
+
* through the shared website handler, and answers the CMS admin connection check.
|
|
23
|
+
*/
|
|
24
|
+
export function createThemeHandler({ configuration, adminOrigin, env = () => process.env, fetcher = globalThis.fetch }) {
|
|
25
|
+
const website = createWebsiteRequestHandler({ configuration, env, fetcher });
|
|
26
|
+
const runtime = createThemeRuntime({ configuration, env, fetcher });
|
|
27
|
+
const admin = new URL(adminOrigin).origin;
|
|
28
|
+
if (admin !== adminOrigin) throw new Error('Invalid CMS admin origin');
|
|
29
|
+
const json = (body, status, extra = {}) => new Response(body === null ? null : JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', ...extra } });
|
|
30
|
+
return async function handle(request) {
|
|
31
|
+
const url = new URL(request.url);
|
|
32
|
+
if (url.pathname === WEBSITE_SETTINGS_PATH && url.searchParams.get('check') === 'connection') {
|
|
33
|
+
const origin = request.headers.get('origin');
|
|
34
|
+
if (origin !== admin || request.headers.has('authorization') || request.headers.has('cookie')) return json({ error: 'Request not allowed' }, 403);
|
|
35
|
+
const cors = { 'Access-Control-Allow-Origin': admin, Vary: 'Origin', 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS' };
|
|
36
|
+
if (request.method === 'OPTIONS') return request.headers.get('access-control-request-method') === 'GET' && !request.headers.has('access-control-request-headers') ? json(null, 204, cors) : json({ error: 'Request not allowed' }, 403);
|
|
37
|
+
if (url.searchParams.size !== 3 || url.searchParams.get('siteId') !== configuration.siteId || url.searchParams.get('environmentId') !== configuration.environmentId) {
|
|
38
|
+
// Tell the CMS which environment this website is connected to, so the
|
|
39
|
+
// admin can switch to it. Only public identifiers of the same website.
|
|
40
|
+
const sameSite = url.searchParams.get('siteId') === configuration.siteId;
|
|
41
|
+
return json({ error: 'Website scope mismatch', ...(sameSite ? { siteId: configuration.siteId, environmentId: configuration.environmentId } : {}) }, 409, cors);
|
|
42
|
+
}
|
|
43
|
+
const snapshot = await runtime.settings();
|
|
44
|
+
return json({ version: 1, mode: THEME_MODE, siteId: configuration.siteId, environmentId: configuration.environmentId, renderedVerified: false,
|
|
45
|
+
settings: Object.fromEntries(WEBSITE_SECTIONS.map(section => [section, { installed: true, state: snapshot.sections[section]?.state ?? 'unavailable', version: snapshot.sections[section]?.version ?? 0 }])) }, 200, cors);
|
|
46
|
+
}
|
|
47
|
+
const result = await website(request);
|
|
48
|
+
return result instanceof Response ? result : json({ error: 'Not found' }, 404);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -197,10 +197,12 @@ function literalConfig(source, wrapper) {
|
|
|
197
197
|
if (begin < 0) return { literals: {}, properties: {}, opaque: true, list };
|
|
198
198
|
if (wrapper && list[begin]?.value === wrapper && list[begin + 1]?.value === '(') begin += 2;
|
|
199
199
|
let aliasEnd = null;
|
|
200
|
-
// create-next-app emits a typed const followed by `export default config
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
|
|
200
|
+
// create-next-app emits a typed const followed by `export default config`
|
|
201
|
+
// (or `module.exports = config` in its JavaScript template). Resolve that one
|
|
202
|
+
// inert alias, never a function call, mutable assignment, imported runtime
|
|
203
|
+
// value, second declaration or conditional configuration.
|
|
204
|
+
const commonjs = list[begin - 1]?.value === '=' && list[begin - 2]?.value === 'exports' && list[begin - 3]?.value === '.' && list[begin - 4]?.value === 'module';
|
|
205
|
+
if (!wrapper && list[begin]?.type === 'name' && (list[begin - 1]?.value === 'default' || commonjs)) {
|
|
204
206
|
const name = list[begin].value, declaration = list.findIndex(token => token.value === 'const');
|
|
205
207
|
const prefix = list.slice(0, declaration);
|
|
206
208
|
const typeImport = prefix.length === 0 || prefix[0]?.value === 'import' && prefix[1]?.value === 'type' && prefix.filter(token => token.value === 'import').length === 1 && prefix.filter(token => token.value === 'from').length === 1 && prefix.at(-1)?.value === ';' && prefix.at(-2)?.type === 'string' && prefix.at(-2)?.value === 'next' && prefix.at(-3)?.value === 'from' && prefix.slice(2,-3).every(token => token.type === 'name' || ['{','}',','].includes(token.value));
|
|
@@ -213,7 +215,7 @@ function literalConfig(source, wrapper) {
|
|
|
213
215
|
}
|
|
214
216
|
const tail = list.slice(begin + 1);
|
|
215
217
|
if (declaration < 0 || !typeImport || list[declaration + 1]?.value !== name || list[initial]?.value !== '=' || list[initial + 1]?.value !== '{' || tail.some(token => token.value !== ';')) return { literals: {}, properties: {}, opaque: true, list };
|
|
216
|
-
aliasEnd = begin - 2;
|
|
218
|
+
aliasEnd = commonjs ? begin - 4 : begin - 2;
|
|
217
219
|
begin = initial + 1;
|
|
218
220
|
}
|
|
219
221
|
if (list[begin]?.value !== '{') return { literals: {}, properties: {}, opaque: true, list };
|
package/dist/website-runtime.mjs
CHANGED
|
@@ -56,9 +56,16 @@ function publicEntry(raw, config) {
|
|
|
56
56
|
}
|
|
57
57
|
const summary = entry => ({ id: entry.id, kind: entry.kind, slug: entry.slug, locale: entry.locale, path: entry.path, title: entry.document.title, excerpt: entry.document.excerpt, publishedAt: entry.publishedAt });
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
function settingsOnlyConfig(value) {
|
|
60
|
+
if (!value || typeof value !== 'object' || !Array.isArray(value.routes) || value.routes.length) throw new Error('Invalid settings-only configuration');
|
|
61
|
+
// Reuse the full publication validator for origins and scope, then drop the probe route.
|
|
62
|
+
return { ...validatePublicationConfig({ ...value, routes: [{ path: '/', kind: 'page', slug: 'home', locale: 'en' }] }), routes: [] };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createWebsiteRuntime({ configuration, settingsOnly = false, env = process.env, fetcher = globalThis.fetch, nativePaths = [], nativePatterns = [], nativePathPrefixes = [], installedSections = websiteSections, timeoutMs = 10_000, maxResponseBytes = 8 * 1024 * 1024, maxConcurrent = 16 } = {}) {
|
|
60
66
|
if (typeof window !== 'undefined') throw new Error('Website configuration requires a private server runtime.');
|
|
61
|
-
|
|
67
|
+
// Settings-only websites own every route; their configuration has no routes.
|
|
68
|
+
const config = settingsOnly ? settingsOnlyConfig(configuration) : validatePublicationConfig(configuration);
|
|
62
69
|
if (typeof fetcher !== 'function' || !Array.isArray(nativePaths) || nativePaths.length > 1000 || !integer(timeoutMs, 1, 10000) || !integer(maxResponseBytes, 1, 8 * 1024 * 1024) || !integer(maxConcurrent, 1, 16)) return fail();
|
|
63
70
|
const native = new Set(nativePaths.map(normalizePublicationPath));
|
|
64
71
|
if (!Array.isArray(nativePatterns)) return fail();
|
|
@@ -209,7 +216,8 @@ export function createWebsiteRuntime({ configuration, env = process.env, fetcher
|
|
|
209
216
|
const url=new URL(value,config.websiteOrigin);
|
|
210
217
|
if(url.origin!==config.websiteOrigin)return {external:true};
|
|
211
218
|
const path=normalizePublicationPath(url.pathname);
|
|
212
|
-
|
|
219
|
+
// In settings-only mode the website renders every path itself.
|
|
220
|
+
return {path,native:settingsOnly||isNative(path)||isExcludedPublicationPath(path)};
|
|
213
221
|
}catch{return null;}
|
|
214
222
|
}
|
|
215
223
|
async function publishedNavigation(sections,inventory) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@universalcms/connect",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.2",
|
|
4
4
|
"description": "Deterministic framework-aware Universal CMS setup for reviewed native websites",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -30,7 +30,9 @@
|
|
|
30
30
|
"./svelte-runtime": "./dist/svelte-runtime.mjs",
|
|
31
31
|
"./static-runtime": "./dist/static-runtime.mjs",
|
|
32
32
|
"./website.css": "./dist/website.css",
|
|
33
|
-
"./public-bindings": "./dist/public-bindings.mjs"
|
|
33
|
+
"./public-bindings": "./dist/public-bindings.mjs",
|
|
34
|
+
"./settings-only": "./dist/settings-only.mjs",
|
|
35
|
+
"./theme": "./dist/theme.mjs"
|
|
34
36
|
},
|
|
35
37
|
"files": [
|
|
36
38
|
"dist",
|
|
@@ -41,7 +43,7 @@
|
|
|
41
43
|
},
|
|
42
44
|
"scripts": {
|
|
43
45
|
"build": "node scripts/build.mjs",
|
|
44
|
-
"typecheck": "node --check src/cli.mjs && node --check src/core.mjs && node --check src/project-files.mjs && node --check src/templates.mjs && node --check src/vite-handler.mjs && node --check src/wizard.mjs && node --check src/guided.mjs && node --check src/framework.mjs && node --check src/framework-ownership.mjs && node --check src/preflight.mjs && node --check src/preflight-recovery.mjs && node --check src/native-route-review.mjs && node --check src/page-discovery.mjs && node --check src/page-mapping-review.mjs && node --check src/native-chrome-model.mjs && node --check src/native-chrome-source.mjs && node --check src/native-chrome-review.mjs && node --check src/next-settings-layout.mjs && node --check src/vite-source-discovery.mjs && node --check src/reviewed-storefront-source.mjs && node --check src/reviewed-commerce-ownership.mjs && node --check src/native-routes.mjs && node --check src/reviewed-native-ownership.mjs && node --check src/runtime-dependencies.mjs && node --check src/connector-runtime.mjs && node --check src/staged-setup.mjs && node --check src/setup-environment.mjs && node --check src/source-check.mjs && node --check src/source-finalize.mjs && node --check src/source-routes.mjs && node --check src/source-config.mjs && node --check src/source-collect.mjs && node --check src/source-extract.mjs && node --check src/source-setup.mjs && node --check src/next-source.mjs && node --check src/vite-source.mjs && node --check src/publication.mjs && node --check src/publication-verification.mjs && node --check src/publication-check.mjs && node --check src/publication-document.mjs && node --check src/publication-browser.mjs && node --check src/publication-setup.mjs && node --check src/publication-next-setup.mjs && node --check src/next-publication.mjs && node --check src/vite-publication.mjs && node --check src/vite-publication-server.mjs && node --check src/source-bindings.mjs && node --check src/content-registry.mjs && node --check src/content-registry-validation.mjs && node --check src/content-next-setup.mjs && node --check src/content-vite-setup.mjs && node --check src/content-storefront-setup.mjs && node --check src/content-setup.mjs && tsc --noEmit -p tsconfig.react.json && node --check src/website-http.mjs && node --check src/website-node.mjs && node --check src/website-next.mjs && node --check src/website-setup.mjs && node --check src/reviewed-commerce-settings-setup.mjs && node --check src/website-runtime.mjs && node --check src/website-check.mjs && node --check src/astro-source.mjs && node --check src/astro-setup.mjs && node --check src/astro-runtime.mjs && node --check src/native-adapters.mjs && node --check src/native-website-model.mjs && node --check src/vue-source.mjs && node --check src/vue-setup.mjs && node --check src/vue-runtime.mjs && node --check src/vue-server.mjs && node --check src/svelte-template.mjs && node --check src/svelte-setup.mjs && node --check src/svelte-runtime.mjs && node --check src/angular-setup.mjs && node --check src/angular-runtime.mjs && node --check src/static-setup.mjs && node --check src/static-runtime.mjs",
|
|
46
|
+
"typecheck": "node --check src/cli.mjs && node --check src/core.mjs && node --check src/project-files.mjs && node --check src/templates.mjs && node --check src/vite-handler.mjs && node --check src/wizard.mjs && node --check src/guided.mjs && node --check src/settings-only.mjs && node --check src/theme.mjs && node --check src/settings-only-setup.mjs && node --check src/framework.mjs && node --check src/framework-ownership.mjs && node --check src/preflight.mjs && node --check src/preflight-recovery.mjs && node --check src/native-route-review.mjs && node --check src/page-discovery.mjs && node --check src/page-mapping-review.mjs && node --check src/native-chrome-model.mjs && node --check src/native-chrome-source.mjs && node --check src/native-chrome-review.mjs && node --check src/next-settings-layout.mjs && node --check src/vite-source-discovery.mjs && node --check src/reviewed-storefront-source.mjs && node --check src/reviewed-commerce-ownership.mjs && node --check src/native-routes.mjs && node --check src/reviewed-native-ownership.mjs && node --check src/runtime-dependencies.mjs && node --check src/connector-runtime.mjs && node --check src/staged-setup.mjs && node --check src/setup-environment.mjs && node --check src/source-check.mjs && node --check src/source-finalize.mjs && node --check src/source-routes.mjs && node --check src/source-config.mjs && node --check src/source-collect.mjs && node --check src/source-extract.mjs && node --check src/source-setup.mjs && node --check src/next-source.mjs && node --check src/vite-source.mjs && node --check src/publication.mjs && node --check src/publication-verification.mjs && node --check src/publication-check.mjs && node --check src/publication-document.mjs && node --check src/publication-browser.mjs && node --check src/publication-setup.mjs && node --check src/publication-next-setup.mjs && node --check src/next-publication.mjs && node --check src/vite-publication.mjs && node --check src/vite-publication-server.mjs && node --check src/source-bindings.mjs && node --check src/content-registry.mjs && node --check src/content-registry-validation.mjs && node --check src/content-next-setup.mjs && node --check src/content-vite-setup.mjs && node --check src/content-storefront-setup.mjs && node --check src/content-setup.mjs && tsc --noEmit -p tsconfig.react.json && node --check src/website-http.mjs && node --check src/website-node.mjs && node --check src/website-next.mjs && node --check src/website-setup.mjs && node --check src/reviewed-commerce-settings-setup.mjs && node --check src/website-runtime.mjs && node --check src/website-check.mjs && node --check src/astro-source.mjs && node --check src/astro-setup.mjs && node --check src/astro-runtime.mjs && node --check src/native-adapters.mjs && node --check src/native-website-model.mjs && node --check src/vue-source.mjs && node --check src/vue-setup.mjs && node --check src/vue-runtime.mjs && node --check src/vue-server.mjs && node --check src/svelte-template.mjs && node --check src/svelte-setup.mjs && node --check src/svelte-runtime.mjs && node --check src/angular-setup.mjs && node --check src/angular-runtime.mjs && node --check src/static-setup.mjs && node --check src/static-runtime.mjs",
|
|
45
47
|
"prepack": "node scripts/build.mjs"
|
|
46
48
|
},
|
|
47
49
|
"publishConfig": {
|