@universalcms/connect 0.13.1 → 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 +4 -4
- package/dist/cli.mjs +13 -2
- package/dist/guided.mjs +76 -5
- package/dist/settings-only.mjs +6 -1
- 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/package.json +4 -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
|
|
@@ -262,7 +262,7 @@ custom adapter still requires review, not forced overwriting.
|
|
|
262
262
|
For an operator's downloaded artifact, the installation is:
|
|
263
263
|
|
|
264
264
|
```sh
|
|
265
|
-
npm install --save-prod ./vendor/universalcms-connect-0.13.
|
|
265
|
+
npm install --save-prod ./vendor/universalcms-connect-0.13.2.tgz
|
|
266
266
|
```
|
|
267
267
|
|
|
268
268
|
Use your project's package manager. These paths are examples for downloaded
|
|
@@ -539,7 +539,7 @@ npx --no-install universalcms-connect init \
|
|
|
539
539
|
--environment 22222222-2222-4222-8222-222222222222
|
|
540
540
|
```
|
|
541
541
|
|
|
542
|
-
After a public release, `npx @universalcms/connect@0.13.
|
|
542
|
+
After a public release, `npx @universalcms/connect@0.13.2 init` is the equivalent
|
|
543
543
|
registry entry point. Copy the real IDs/API origin from **Developer setup**.
|
|
544
544
|
Missing values are prompted in a terminal; `--non-interactive` fails instead.
|
|
545
545
|
Init prints a plan. Run the same command with `--apply` after reviewing it.
|
|
@@ -589,7 +589,7 @@ The CMS button reads the website's **`/api/cms-source`** endpoint. Current Next.
|
|
|
589
589
|
prepares delivery scaffolding. React/Vite setup serves its reviewed public manifest through the normal dev command.
|
|
590
590
|
Custom exporters need their explicit adapter; a delivery check alone is insufficient.
|
|
591
591
|
|
|
592
|
-
Use the CMS's reviewed 0.13.
|
|
592
|
+
Use the CMS's reviewed 0.13.2 package for the staged commands and current source
|
|
593
593
|
discovery. Review source diagnostics and complete public
|
|
594
594
|
mappings for unsupported pages. Restart the website using its normal server command, then run:
|
|
595
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.
|
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';
|
|
@@ -22,6 +22,7 @@ import { finalizeSource } from './source-finalize.mjs';
|
|
|
22
22
|
import { preflightSetup } from './preflight.mjs';
|
|
23
23
|
import { planSettingsOnly, SETTINGS_ONLY_FILE } from './settings-only-setup.mjs';
|
|
24
24
|
import { applyEnvironment, planEnvironment } from './setup-environment.mjs';
|
|
25
|
+
import { themeConfiguration } from './theme.mjs';
|
|
25
26
|
|
|
26
27
|
export const PAIRING_WAIT_MS = 10 * 60 * 1000;
|
|
27
28
|
export const WEBSITE_WAIT_MS = 15 * 60 * 1000;
|
|
@@ -284,7 +285,8 @@ export async function redeem(cms, code, verifier, fetcher) {
|
|
|
284
285
|
const data = body.data;
|
|
285
286
|
const connection = validateConnection({ version: 1, apiOrigin: data.apiOrigin, siteId: data.siteId, environmentId: data.environmentId, locale: data.locale, websiteOrigin: data.websiteOrigin, adminOrigin: data.adminOrigin });
|
|
286
287
|
if (typeof data.serverKey !== 'string' || !serverKey.test(data.serverKey)) fail('The CMS returned an unexpected response. No key was saved.');
|
|
287
|
-
|
|
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 };
|
|
288
290
|
}
|
|
289
291
|
|
|
290
292
|
function describeRoute(route) { return route.path === '/' ? '/ (home page)' : route.path; }
|
|
@@ -377,6 +379,14 @@ export async function runGuided(directory, options, io) {
|
|
|
377
379
|
const framework = typeof analysis.framework === 'string' && analysis.framework !== 'Unknown' ? analysis.framework : null;
|
|
378
380
|
io.write(`✔ Found ${framework ? `a ${framework} website` : 'a website'} in ${path.basename(root)}\n`);
|
|
379
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
|
+
}
|
|
380
390
|
const saved = await readConnection(root);
|
|
381
391
|
const environment = await readSetupEnvironment(root);
|
|
382
392
|
const installed = await readProjectFile(root, '.universalcms/website.integration.json') !== null;
|
|
@@ -413,7 +423,7 @@ export async function runGuided(directory, options, io) {
|
|
|
413
423
|
mode = await prepareWebsite(root, approval.scope, manifest, framework, io);
|
|
414
424
|
}
|
|
415
425
|
const paired = await redeem(cms, approval.code, approval.verifier, io.fetch);
|
|
416
|
-
io.write(`✔ Connected to ${paired.siteName || 'your website'} (${paired.connection.websiteOrigin})\n`);
|
|
426
|
+
io.write(`✔ Connected to ${paired.siteName || 'your website'} (${paired.connection.websiteOrigin})${paired.environment ? ` · ${paired.environment} environment` : ''}\n`);
|
|
417
427
|
if (mismatch(paired.connection)) fail(different);
|
|
418
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.');
|
|
419
429
|
if (!approval.scope && !installed) mode = await prepareWebsite(root, paired.connection, manifest, framework, io);
|
|
@@ -442,6 +452,67 @@ export async function runGuided(directory, options, io) {
|
|
|
442
452
|
return goLive(root, paired.connection, await manifestOf(root), io);
|
|
443
453
|
}
|
|
444
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
|
+
|
|
445
516
|
/** Ask the running website's settings endpoint, as the CMS admin does. */
|
|
446
517
|
async function settingsReady(connection, io) {
|
|
447
518
|
try {
|
|
@@ -465,8 +536,8 @@ async function finishSettingsOnly(root, connection, manifest, io) {
|
|
|
465
536
|
while (!ready && io.now() < deadline) { await io.sleep(3000); ready = await settingsReady(connection, io); }
|
|
466
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' }; }
|
|
467
538
|
}
|
|
468
|
-
const settings = `${connection.adminOrigin}/sites/${connection.siteId}`;
|
|
469
|
-
io.write(`✔ Your website is connected for site settings\n\n🎉 All done! Manage it from Universal CMS:\n ${settings}\n`);
|
|
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`);
|
|
470
541
|
io.write(' • SEO, branding, analytics, scripts, cookie consent and redirects apply automatically.\n');
|
|
471
542
|
io.write(' • To show the CMS menu in your own header or footer, add one line where it should appear:\n');
|
|
472
543
|
io.write(" import { CmsNavigation } from '@universalcms/connect/react-website';\n <CmsNavigation location=\"header\" /> (or location=\"footer\")\n");
|
package/dist/settings-only.mjs
CHANGED
|
@@ -32,7 +32,12 @@ export function createSettingsOnlyHandler({ configuration, adminOrigin, env = ()
|
|
|
32
32
|
if (origin !== admin || request.headers.has('authorization') || request.headers.has('cookie')) return reply({ error: 'Request not allowed' }, 403);
|
|
33
33
|
const cors = { 'Access-Control-Allow-Origin': admin, Vary: 'Origin', 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS' };
|
|
34
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)
|
|
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
|
+
}
|
|
36
41
|
const snapshot = await runtime.settings();
|
|
37
42
|
return reply({ version: 1, mode: SETTINGS_ONLY_MODE, siteId: configuration.siteId, environmentId: configuration.environmentId, renderedVerified: false,
|
|
38
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);
|
|
@@ -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
|
+
}
|
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": {
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
"./static-runtime": "./dist/static-runtime.mjs",
|
|
32
32
|
"./website.css": "./dist/website.css",
|
|
33
33
|
"./public-bindings": "./dist/public-bindings.mjs",
|
|
34
|
-
"./settings-only": "./dist/settings-only.mjs"
|
|
34
|
+
"./settings-only": "./dist/settings-only.mjs",
|
|
35
|
+
"./theme": "./dist/theme.mjs"
|
|
35
36
|
},
|
|
36
37
|
"files": [
|
|
37
38
|
"dist",
|
|
@@ -42,7 +43,7 @@
|
|
|
42
43
|
},
|
|
43
44
|
"scripts": {
|
|
44
45
|
"build": "node scripts/build.mjs",
|
|
45
|
-
"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/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",
|
|
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",
|
|
46
47
|
"prepack": "node scripts/build.mjs"
|
|
47
48
|
},
|
|
48
49
|
"publishConfig": {
|