@universalcms/connect 0.13.1 → 0.13.3
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 +95 -9
- package/dist/settings-only-setup.mjs +7 -1
- package/dist/settings-only.d.mts +1 -1
- package/dist/settings-only.mjs +28 -5
- package/dist/site-discovery.mjs +130 -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/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.3 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.3.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.3 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.3 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.3
|
|
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,11 +379,34 @@ 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;
|
|
383
393
|
if (saved && environment.keyConfigured && !options.code && await readProjectFile(root, SETTINGS_ONLY_FILE) !== null) {
|
|
384
394
|
io.write(`✔ Already connected for site settings (${saved.websiteOrigin})\n`);
|
|
395
|
+
// Bring generated files up to date with this connector version.
|
|
396
|
+
const update = await planSettingsOnly(root, Object.fromEntries(['apiOrigin', 'siteId', 'environmentId', 'locale', 'websiteOrigin', 'adminOrigin'].map(name => [name, saved[name]])));
|
|
397
|
+
const stale = update.files.filter(file => file.state !== 'unchanged');
|
|
398
|
+
if (stale.length) {
|
|
399
|
+
await ensureConnector(root, manifest, io);
|
|
400
|
+
io.write(`\nThis connector version updates ${stale.length} Universal CMS file${stale.length === 1 ? '' : 's'} in your website (your pages and design stay the same):\n`);
|
|
401
|
+
for (const file of stale) io.write(` ${file.state}: ${file.path}\n`);
|
|
402
|
+
if (yes(await io.ask('Update them? [Y/n] '))) {
|
|
403
|
+
const again = await planSettingsOnly(root, update.connection);
|
|
404
|
+
if (JSON.stringify(again.files) !== JSON.stringify(update.files)) fail('Your website files changed while reviewing. Nothing was saved; run the same command again.');
|
|
405
|
+
await applyPlan(update);
|
|
406
|
+
io.write('✔ Universal CMS files updated — restart your website to load them\n');
|
|
407
|
+
return finishSettingsOnly(root, saved, await manifestOf(root), io, true);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
385
410
|
return finishSettingsOnly(root, saved, manifest, io);
|
|
386
411
|
}
|
|
387
412
|
if (saved && installed && environment.keyConfigured && !options.code) {
|
|
@@ -413,7 +438,7 @@ export async function runGuided(directory, options, io) {
|
|
|
413
438
|
mode = await prepareWebsite(root, approval.scope, manifest, framework, io);
|
|
414
439
|
}
|
|
415
440
|
const paired = await redeem(cms, approval.code, approval.verifier, io.fetch);
|
|
416
|
-
io.write(`✔ Connected to ${paired.siteName || 'your website'} (${paired.connection.websiteOrigin})\n`);
|
|
441
|
+
io.write(`✔ Connected to ${paired.siteName || 'your website'} (${paired.connection.websiteOrigin})${paired.environment ? ` · ${paired.environment} environment` : ''}\n`);
|
|
417
442
|
if (mismatch(paired.connection)) fail(different);
|
|
418
443
|
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
444
|
if (!approval.scope && !installed) mode = await prepareWebsite(root, paired.connection, manifest, framework, io);
|
|
@@ -442,6 +467,67 @@ export async function runGuided(directory, options, io) {
|
|
|
442
467
|
return goLive(root, paired.connection, await manifestOf(root), io);
|
|
443
468
|
}
|
|
444
469
|
|
|
470
|
+
export const THEME_FILE = '.universalcms/theme.json';
|
|
471
|
+
export const THEME_WEBSITE_FILE = '.universalcms/website.json';
|
|
472
|
+
const THEME_PORT = 3100;
|
|
473
|
+
|
|
474
|
+
/** Save the public theme connection and the private key after pairing. */
|
|
475
|
+
async function connectTheme(root, paired, io) {
|
|
476
|
+
const { connection } = paired;
|
|
477
|
+
const environment = await planEnvironment(root, { ...Object.fromEntries(['apiOrigin', 'siteId', 'environmentId', 'locale', 'websiteOrigin', 'adminOrigin'].map(name => [name, connection[name]])), apiKey: paired.serverKey });
|
|
478
|
+
await applyEnvironment(environment);
|
|
479
|
+
await mkdir(path.join(root, '.universalcms'), { recursive: true });
|
|
480
|
+
await writeFile(path.join(root, CONNECTION_FILE_PATH), `${JSON.stringify(connection, null, 2)}\n`);
|
|
481
|
+
await writeFile(path.join(root, THEME_WEBSITE_FILE), `${JSON.stringify(themeConfiguration(connection), null, 2)}\n`);
|
|
482
|
+
io.write('✔ Website connected and private key saved\n');
|
|
483
|
+
}
|
|
484
|
+
const CONNECTION_FILE_PATH = '.universalcms/connection.json';
|
|
485
|
+
|
|
486
|
+
function themeDone(root, connection, manager, created, io) {
|
|
487
|
+
const pages = `${connection.adminOrigin}/sites/${connection.siteId}#setup?environmentId=${connection.environmentId}`;
|
|
488
|
+
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`);
|
|
489
|
+
io.write(` Manage everything from the CMS: ${pages}\n`);
|
|
490
|
+
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');
|
|
491
|
+
return { connected: true, mode: 'theme', done: true };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** `create <folder>`: start a new website rendered entirely from Universal CMS. */
|
|
495
|
+
export async function runCreate(parent, name, options, io) {
|
|
496
|
+
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');
|
|
497
|
+
const root = path.resolve(parent, name);
|
|
498
|
+
try { if ((await readdir(root)).length) fail(`The folder ${name} already exists and is not empty. Choose another name.`); }
|
|
499
|
+
catch (error) { if (error instanceof SetupError) throw error; if (error.code !== 'ENOENT') throw error; }
|
|
500
|
+
const cms = cmsOrigin(options.cms ?? io.env.UNIVERSALCMS_URL ?? '') ?? await (async () => {
|
|
501
|
+
while (true) {
|
|
502
|
+
const value = cmsOrigin((await io.ask('Your Universal CMS address (for example https://cms.example.com): ')).trim());
|
|
503
|
+
if (value) return value;
|
|
504
|
+
io.write('Enter the address you use to sign in to the CMS.\n');
|
|
505
|
+
}
|
|
506
|
+
})();
|
|
507
|
+
io.write(`\nUniversal CMS · create a new website in ${name}\n`);
|
|
508
|
+
// Copy into a temporary sibling first so a failed copy never leaves a half-made folder.
|
|
509
|
+
const staging = `${root}.universalcms-${randomBytes(4).toString('hex')}`;
|
|
510
|
+
await cp(fileURLToPath(new URL('./templates/next-theme/', import.meta.url)), staging, { recursive: true, errorOnExist: true, force: false });
|
|
511
|
+
await rename(path.join(staging, 'gitignore.template'), path.join(staging, '.gitignore'));
|
|
512
|
+
const version = await connectorVersion();
|
|
513
|
+
const override = io.env.UNIVERSALCMS_CONNECT_PACKAGE;
|
|
514
|
+
if (override !== undefined && !/^[^\s'"`;&|<>$]{1,2048}$/.test(override)) fail('UNIVERSALCMS_CONNECT_PACKAGE must be a package spec, file path or URL without spaces.');
|
|
515
|
+
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}` },
|
|
516
|
+
dependencies: { '@universalcms/connect': override ?? `^${version}`, next: '^16.4.2', react: '^19.2.3', 'react-dom': '^19.2.3' },
|
|
517
|
+
devDependencies: { '@types/node': '^24.0.0', '@types/react': '^19.2.0', typescript: '^5.9.0' } };
|
|
518
|
+
await writeFile(path.join(staging, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
519
|
+
await rename(staging, root);
|
|
520
|
+
io.write('✔ Website files created\n');
|
|
521
|
+
io.write('Installing packages (this can take a minute)…\n');
|
|
522
|
+
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`);
|
|
523
|
+
io.write('✔ Packages installed\n');
|
|
524
|
+
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' } });
|
|
525
|
+
const paired = await redeem(cms, approval.code, approval.verifier, io.fetch);
|
|
526
|
+
io.write(`✔ Approved: ${paired.siteName || 'your website'} (${paired.connection.websiteOrigin})\n`);
|
|
527
|
+
await connectTheme(root, paired, io);
|
|
528
|
+
return themeDone(root, paired.connection, 'npm', true, io);
|
|
529
|
+
}
|
|
530
|
+
|
|
445
531
|
/** Ask the running website's settings endpoint, as the CMS admin does. */
|
|
446
532
|
async function settingsReady(connection, io) {
|
|
447
533
|
try {
|
|
@@ -449,12 +535,12 @@ async function settingsReady(connection, io) {
|
|
|
449
535
|
url.search = new URLSearchParams({ check: 'connection', siteId: connection.siteId, environmentId: connection.environmentId }).toString();
|
|
450
536
|
const response = await io.fetch(url, { headers: { Origin: connection.adminOrigin, Accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(10000) });
|
|
451
537
|
const body = response.ok ? await response.json() : null;
|
|
452
|
-
return body?.mode === 'settings-only' && body.siteId === connection.siteId && body.environmentId === connection.environmentId;
|
|
538
|
+
return body?.mode === 'settings-only' && body.siteId === connection.siteId && body.environmentId === connection.environmentId && body.discovered?.version === 1;
|
|
453
539
|
} catch { return false; }
|
|
454
540
|
}
|
|
455
541
|
|
|
456
|
-
async function finishSettingsOnly(root, connection, manifest, io) {
|
|
457
|
-
if (!await settingsReady(connection, io)) {
|
|
542
|
+
async function finishSettingsOnly(root, connection, manifest, io, restart = false) {
|
|
543
|
+
if (restart || !await settingsReady(connection, io)) {
|
|
458
544
|
const local = /^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|$)/.test(connection.websiteOrigin), command = await restartHint(root, manifest);
|
|
459
545
|
io.write(local
|
|
460
546
|
? `\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`
|
|
@@ -465,9 +551,9 @@ async function finishSettingsOnly(root, connection, manifest, io) {
|
|
|
465
551
|
while (!ready && io.now() < deadline) { await io.sleep(3000); ready = await settingsReady(connection, io); }
|
|
466
552
|
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
553
|
}
|
|
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`);
|
|
470
|
-
io.write(' • SEO,
|
|
554
|
+
const settings = `${connection.adminOrigin}/sites/${connection.siteId}#setup?environmentId=${connection.environmentId}`;
|
|
555
|
+
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`);
|
|
556
|
+
io.write(' • In Developer setup, import the pages and header/footer links found on your website, so they appear in the CMS.\n • Page text stays in your website\'s code; each page\'s SEO (title, description, social image) and your menus, logo, analytics, scripts and redirects are managed in the CMS.\n');
|
|
471
557
|
io.write(' • To show the CMS menu in your own header or footer, add one line where it should appear:\n');
|
|
472
558
|
io.write(" import { CmsNavigation } from '@universalcms/connect/react-website';\n <CmsNavigation location=\"header\" /> (or location=\"footer\")\n");
|
|
473
559
|
io.write(' Other drop-ins: <CmsBranding />, <CmsSocialLinks />, and useWebsiteSettings() for anything custom.\n');
|
|
@@ -8,6 +8,7 @@ import ts from './vendor/typescript.cjs';
|
|
|
8
8
|
import { projectRoot, readProjectFile, SetupError } from './project-files.mjs';
|
|
9
9
|
import { inspectSourceRoutes } from './source-routes.mjs';
|
|
10
10
|
import { CONNECTION_FILE, validateConnection } from './staged-setup.mjs';
|
|
11
|
+
import { discoverSite, DISCOVERED_FILE } from './site-discovery.mjs';
|
|
11
12
|
|
|
12
13
|
export const SETTINGS_ONLY_FILE = '.universalcms/settings-only.json';
|
|
13
14
|
const fail = message => { throw new SetupError(message); };
|
|
@@ -45,8 +46,9 @@ const settingsRoute = (app, file) => `${MARK} Site settings served to the websit
|
|
|
45
46
|
import { createSettingsOnlyHandler } from '@universalcms/connect/settings-only';
|
|
46
47
|
import configuration from '${relative(file, SETTINGS_ONLY_FILE)}';
|
|
47
48
|
import connection from '${relative(file, CONNECTION_FILE)}';
|
|
49
|
+
import discovered from '${relative(file, DISCOVERED_FILE)}';
|
|
48
50
|
|
|
49
|
-
const handle = createSettingsOnlyHandler({ configuration: configuration as Parameters<typeof createSettingsOnlyHandler>[0]['configuration'], adminOrigin: connection.adminOrigin });
|
|
51
|
+
const handle = createSettingsOnlyHandler({ configuration: configuration as Parameters<typeof createSettingsOnlyHandler>[0]['configuration'], adminOrigin: connection.adminOrigin, discovered });
|
|
50
52
|
export const dynamic = 'force-dynamic';
|
|
51
53
|
export const GET = handle;
|
|
52
54
|
export const HEAD = handle;
|
|
@@ -84,6 +86,10 @@ export async function planSettingsOnly(directory, connectionInput) {
|
|
|
84
86
|
};
|
|
85
87
|
await add(CONNECTION_FILE, json(connection));
|
|
86
88
|
await add(SETTINGS_ONLY_FILE, json({ version: 1, apiOrigin: connection.apiOrigin, siteId: connection.siteId, environmentId: connection.environmentId, websiteOrigin: connection.websiteOrigin, routes: [] }));
|
|
89
|
+
// Public page titles and header/footer links found in the source; the CMS offers to import them.
|
|
90
|
+
const discovered = await discoverSite(root);
|
|
91
|
+
const previousDiscovery = await readProjectFile(root, DISCOVERED_FILE), discoveryContent = json(discovered);
|
|
92
|
+
files.push({ path: DISCOVERED_FILE, content: discoveryContent, state: previousDiscovery === null ? 'create' : previousDiscovery === discoveryContent ? 'unchanged' : 'update', ...(previousDiscovery !== null && previousDiscovery !== discoveryContent ? { previous: previousDiscovery } : {}) });
|
|
87
93
|
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
94
|
const wrapper = `${app}/universalcms.settings.${typed ? 'tsx' : 'jsx'}`;
|
|
89
95
|
await add(wrapper, wrapperTemplate(wrapper, typed));
|
package/dist/settings-only.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export const SETTINGS_ONLY_MODE: 'settings-only';
|
|
2
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>;
|
|
3
|
+
export function createSettingsOnlyHandler(options: { configuration: SettingsOnlyConfiguration; adminOrigin: string; discovered?: unknown; env?: () => Record<string, string | undefined>; fetcher?: typeof fetch }): (request: Request) => Promise<Response>;
|
|
4
4
|
export function createSettingsOnlyRuntime(options: { configuration: SettingsOnlyConfiguration; env?: () => Record<string, string | undefined>; fetcher?: typeof fetch }): { settings(): Promise<unknown> };
|
package/dist/settings-only.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { createWebsiteRuntime } from './website-runtime.mjs';
|
|
8
8
|
import { WEBSITE_PAGE_PATH, WEBSITE_SECTIONS, WEBSITE_SETTINGS_PATH } from './website-http.mjs';
|
|
9
|
+
import { normalizePublicationPath, publicationRevision } from './publication.mjs';
|
|
9
10
|
|
|
10
11
|
export const SETTINGS_ONLY_MODE = 'settings-only';
|
|
11
12
|
const base = { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer' };
|
|
@@ -13,7 +14,17 @@ const base = { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff',
|
|
|
13
14
|
/**
|
|
14
15
|
* @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
|
*/
|
|
16
|
-
|
|
17
|
+
const short = (value, max) => typeof value === 'string' ? value.slice(0, max) : '';
|
|
18
|
+
/** Only bounded public strings from the website source reach the admin. */
|
|
19
|
+
function publicDiscovery(value) {
|
|
20
|
+
if (!value || value.version !== 1) return undefined;
|
|
21
|
+
const link = item => ({ label: short(item?.label, 80), url: short(item?.url, 200) });
|
|
22
|
+
return { version: 1, pages: (Array.isArray(value.pages) ? value.pages : []).slice(0, 100).map(page => ({ path: short(page?.path, 200), title: short(page?.title, 120), description: short(page?.description, 320) })),
|
|
23
|
+
navigation: { header: (value.navigation?.header ?? []).slice(0, 20).map(link), footer: (value.navigation?.footer ?? []).slice(0, 20).map(link) } };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createSettingsOnlyHandler({ configuration, adminOrigin, discovered, env = () => process.env, fetcher = globalThis.fetch }) {
|
|
27
|
+
const discovery = publicDiscovery(discovered);
|
|
17
28
|
const runtime = createWebsiteRuntime({ configuration, settingsOnly: true, env, fetcher });
|
|
18
29
|
const admin = new URL(adminOrigin).origin;
|
|
19
30
|
if (admin !== adminOrigin) throw new Error('Invalid CMS admin origin');
|
|
@@ -21,9 +32,16 @@ export function createSettingsOnlyHandler({ configuration, adminOrigin, env = ()
|
|
|
21
32
|
const method = request.method, url = new URL(request.url), origin = request.headers.get('origin');
|
|
22
33
|
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
34
|
if (url.pathname === WEBSITE_PAGE_PATH) {
|
|
24
|
-
//
|
|
35
|
+
// The website renders every page. A published CMS page at the same path
|
|
36
|
+
// supplies only its SEO metadata (title, description, social tags).
|
|
25
37
|
if (!['GET', 'HEAD'].includes(method)) return reply({ error: 'Method not allowed' }, 405, { Allow: 'GET, HEAD' });
|
|
26
|
-
return reply({
|
|
38
|
+
if (origin && origin !== configuration.websiteOrigin || request.headers.get('sec-fetch-site') === 'cross-site') return reply({ error: 'Request not allowed' }, 403);
|
|
39
|
+
let pathname;
|
|
40
|
+
try { pathname = normalizePublicationPath(url.searchParams.get('path') ?? ''); } catch { return reply({ state: 'missing' }, 404); }
|
|
41
|
+
const result = await runtime.page(pathname === '/' ? '/home' : pathname);
|
|
42
|
+
if (result.state !== 'published') return reply({ state: result.state === 'unavailable' ? 'unavailable' : 'missing' }, result.state === 'unavailable' ? 503 : 404);
|
|
43
|
+
const entry = { ...result.entry, path: pathname };
|
|
44
|
+
return reply({ ...result, path: pathname, entry, revision: publicationRevision(entry) });
|
|
27
45
|
}
|
|
28
46
|
if (url.pathname !== WEBSITE_SETTINGS_PATH) return reply({ error: 'Not found' }, 404);
|
|
29
47
|
const diagnostic = url.searchParams.get('check') === 'connection';
|
|
@@ -32,9 +50,14 @@ export function createSettingsOnlyHandler({ configuration, adminOrigin, env = ()
|
|
|
32
50
|
if (origin !== admin || request.headers.has('authorization') || request.headers.has('cookie')) return reply({ error: 'Request not allowed' }, 403);
|
|
33
51
|
const cors = { 'Access-Control-Allow-Origin': admin, Vary: 'Origin', 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS' };
|
|
34
52
|
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)
|
|
53
|
+
if (url.searchParams.size !== 3 || url.searchParams.get('siteId') !== configuration.siteId || url.searchParams.get('environmentId') !== configuration.environmentId) {
|
|
54
|
+
// Tell the CMS which environment this website is connected to, so the
|
|
55
|
+
// admin can switch to it. Only public identifiers of the same website.
|
|
56
|
+
const sameSite = url.searchParams.get('siteId') === configuration.siteId;
|
|
57
|
+
return reply({ error: 'Website scope mismatch', ...(sameSite ? { siteId: configuration.siteId, environmentId: configuration.environmentId } : {}) }, 409, cors);
|
|
58
|
+
}
|
|
36
59
|
const snapshot = await runtime.settings();
|
|
37
|
-
return reply({ version: 1, mode: SETTINGS_ONLY_MODE, siteId: configuration.siteId, environmentId: configuration.environmentId, renderedVerified: false,
|
|
60
|
+
return reply({ version: 1, mode: SETTINGS_ONLY_MODE, siteId: configuration.siteId, environmentId: configuration.environmentId, renderedVerified: false, ...(discovery ? { discovered: discovery } : {}),
|
|
38
61
|
settings: Object.fromEntries(WEBSITE_SECTIONS.map(section => [section, { installed: true, state: snapshot.sections[section]?.state ?? 'unavailable', version: snapshot.sections[section]?.version ?? 0 }])) }, 200, cors);
|
|
39
62
|
}
|
|
40
63
|
if (!['GET', 'HEAD'].includes(method)) return reply({ error: 'Method not allowed' }, 405, { Allow: 'GET, HEAD' });
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads a Next.js App Router website's public structure without running it:
|
|
3
|
+
* its static pages (path, title, description) and the literal links in its
|
|
4
|
+
* header and footer. The result seeds the CMS for settings-only websites and
|
|
5
|
+
* contains only public text already present in the website source.
|
|
6
|
+
*/
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import ts from './vendor/typescript.cjs';
|
|
9
|
+
import { projectRoot, readProjectFile } from './project-files.mjs';
|
|
10
|
+
import { inspectSourceRoutes } from './source-routes.mjs';
|
|
11
|
+
|
|
12
|
+
export const DISCOVERED_FILE = '.universalcms/discovered.json';
|
|
13
|
+
const MAX_PAGES = 100, MAX_LINKS = 20;
|
|
14
|
+
const entities = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", rsquo: '’', lsquo: '‘', rdquo: '”', ldquo: '“', nbsp: ' ', ndash: '–', mdash: '—', hellip: '…', copy: '©', reg: '®', trade: '™' };
|
|
15
|
+
const decode = value => value.replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, (match, name) => name[0] === '#' ? String.fromCodePoint(Number.parseInt(name.slice(name[1] === 'x' || name[1] === 'X' ? 2 : 1), name[1] === 'x' || name[1] === 'X' ? 16 : 10)) : entities[name.toLowerCase()] ?? match);
|
|
16
|
+
const clean = (value, max) => typeof value === 'string' ? decode(value).replace(/\s+/g, ' ').trim().slice(0, max) : '';
|
|
17
|
+
const parse = (filename, text) => ts.createSourceFile(filename, text, ts.ScriptTarget.Latest, true, /x$/.test(filename) ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
|
18
|
+
// Same rule as CMS navigation: plain site paths (no query or fragment) or HTTPS URLs.
|
|
19
|
+
const publicUrl = value => typeof value === 'string' && value.length <= 200 && (/^\/(?!\/)[a-zA-Z0-9/_\-]*$/.test(value) || /^https:\/\/[^\s<>"'`]+$/.test(value)) && !/^\/(api|_next)(\/|$)/.test(value);
|
|
20
|
+
|
|
21
|
+
/** Literal string of a property in `export const metadata = { ... }` (title may be a string or { default }). */
|
|
22
|
+
function metadataOf(ast) {
|
|
23
|
+
for (const statement of ast.statements) {
|
|
24
|
+
if (!ts.isVariableStatement(statement) || !statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue;
|
|
25
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
26
|
+
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== 'metadata' || !declaration.initializer || !ts.isObjectLiteralExpression(declaration.initializer)) continue;
|
|
27
|
+
const read = name => {
|
|
28
|
+
const property = declaration.initializer.properties.find(item => ts.isPropertyAssignment(item) && item.name.getText(ast) === name);
|
|
29
|
+
if (!property) return '';
|
|
30
|
+
const value = property.initializer;
|
|
31
|
+
if (ts.isStringLiteralLike(value)) return value.text;
|
|
32
|
+
if (ts.isObjectLiteralExpression(value)) { const fallback = value.properties.find(item => ts.isPropertyAssignment(item) && item.name.getText(ast) === 'default'); return fallback && ts.isStringLiteralLike(fallback.initializer) ? fallback.initializer.text : ''; }
|
|
33
|
+
return '';
|
|
34
|
+
};
|
|
35
|
+
return { title: clean(read('title'), 120), description: clean(read('description'), 320) };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { title: '', description: '' };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Text written directly inside a JSX element (ignores expressions and icons). */
|
|
42
|
+
function literalText(node) {
|
|
43
|
+
let text = '';
|
|
44
|
+
(function visit(child) { if (ts.isJsxText(child)) text += ` ${child.text}`; else if (ts.isJsxExpression(child) && child.expression && ts.isStringLiteralLike(child.expression)) text += ` ${child.expression.text}`; else ts.forEachChild(child, visit); })(node);
|
|
45
|
+
return clean(text, 60);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function firstHeading(ast) {
|
|
49
|
+
let found = '';
|
|
50
|
+
(function visit(node) { if (found) return; if (ts.isJsxElement(node) && node.openingElement.tagName.getText(ast) === 'h1') { found = literalText(node); return; } ts.forEachChild(node, visit); })(ast);
|
|
51
|
+
return found;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Literal <Link href> / <a href> elements, labelled by their nearest <header>/<footer> ancestor. */
|
|
55
|
+
function linksOf(ast, fallbackArea) {
|
|
56
|
+
const links = [];
|
|
57
|
+
(function visit(node, area) {
|
|
58
|
+
if (ts.isJsxElement(node)) {
|
|
59
|
+
const tag = node.openingElement.tagName.getText(ast);
|
|
60
|
+
if (tag === 'header' || tag === 'nav' && !area) area = 'header';
|
|
61
|
+
if (tag === 'footer') area = 'footer';
|
|
62
|
+
if (tag === 'Link' || tag === 'a') {
|
|
63
|
+
const href = node.openingElement.attributes.properties.find(attribute => ts.isJsxAttribute(attribute) && attribute.name.getText(ast) === 'href');
|
|
64
|
+
const value = href && ts.isJsxAttribute(href) && href.initializer && (ts.isStringLiteral(href.initializer) ? href.initializer.text : ts.isJsxExpression(href.initializer) && href.initializer.expression && ts.isStringLiteralLike(href.initializer.expression) ? href.initializer.expression.text : null);
|
|
65
|
+
const label = literalText(node);
|
|
66
|
+
if (publicUrl(value) && label && (area ?? fallbackArea)) links.push({ area: area ?? fallbackArea, label, url: value });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
ts.forEachChild(node, child => visit(child, area));
|
|
71
|
+
})(ast, null);
|
|
72
|
+
return links;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Local components imported by the root layout (one level), with their likely area. */
|
|
76
|
+
function layoutComponents(ast, layoutFile, app) {
|
|
77
|
+
const result = [];
|
|
78
|
+
for (const statement of ast.statements) {
|
|
79
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || !statement.importClause?.name && !statement.importClause?.namedBindings) continue;
|
|
80
|
+
const specifier = statement.moduleSpecifier.text, name = statement.importClause.name?.text ?? specifier.split('/').at(-1);
|
|
81
|
+
const area = /footer/i.test(name) ? 'footer' : /header|nav|menu/i.test(name) ? 'header' : null;
|
|
82
|
+
if (!area) continue;
|
|
83
|
+
const base = specifier.startsWith('@/') ? `${app.startsWith('src/') ? 'src/' : ''}${specifier.slice(2)}` : specifier.startsWith('.') ? path.posix.normalize(path.posix.join(path.posix.dirname(layoutFile), specifier)) : null;
|
|
84
|
+
if (base) result.push({ base, area });
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function readFirst(root, base) {
|
|
90
|
+
for (const candidate of [base, ...['.tsx', '.jsx', '.ts', '.js', '/index.tsx', '/index.jsx'].map(suffix => base + suffix)]) {
|
|
91
|
+
try { const text = await readProjectFile(root, candidate); if (text !== null) return { file: candidate, text }; } catch { /* Not this candidate. */ }
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const titleFromPath = route => route === '/' ? 'Home' : clean(route.split('/').filter(Boolean).at(-1).replace(/[-_]+/g, ' ').replace(/\b\w/g, letter => letter.toUpperCase()), 120);
|
|
97
|
+
|
|
98
|
+
export async function discoverSite(directory) {
|
|
99
|
+
const root = await projectRoot(directory), inventory = await inspectSourceRoutes(root);
|
|
100
|
+
const files = Object.entries(inventory.files);
|
|
101
|
+
const app = files[0]?.[1].startsWith('src/app/') ? 'src/app' : 'app';
|
|
102
|
+
const pages = [];
|
|
103
|
+
for (const [route, file] of files.slice(0, MAX_PAGES)) {
|
|
104
|
+
const pathname = route === 'home' || route === '' ? '/' : `/${route}`;
|
|
105
|
+
const text = await readProjectFile(root, file).catch(() => null);
|
|
106
|
+
const ast = text ? parse(file, text) : null;
|
|
107
|
+
const meta = ast ? metadataOf(ast) : { title: '', description: '' };
|
|
108
|
+
pages.push({ path: pathname, title: meta.title || (ast && firstHeading(ast)) || titleFromPath(pathname), description: meta.description });
|
|
109
|
+
}
|
|
110
|
+
pages.sort((a, b) => a.path.localeCompare(b.path));
|
|
111
|
+
const links = [];
|
|
112
|
+
for (const ext of ['tsx', 'jsx', 'js', 'ts']) {
|
|
113
|
+
const layoutFile = `${app}/layout.${ext}`, text = await readProjectFile(root, layoutFile).catch(() => null);
|
|
114
|
+
if (!text) continue;
|
|
115
|
+
const ast = parse(layoutFile, text);
|
|
116
|
+
links.push(...linksOf(ast, null));
|
|
117
|
+
for (const component of layoutComponents(ast, layoutFile, app)) {
|
|
118
|
+
const found = await readFirst(root, component.base);
|
|
119
|
+
if (found) links.push(...linksOf(parse(found.file, found.text), component.area));
|
|
120
|
+
}
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
const navigation = { header: [], footer: [] }, seen = { header: new Set(), footer: new Set() };
|
|
124
|
+
for (const link of links) {
|
|
125
|
+
const key = `${link.label}\n${link.url}`;
|
|
126
|
+
if (seen[link.area].has(key) || navigation[link.area].length >= MAX_LINKS) continue;
|
|
127
|
+
seen[link.area].add(key); navigation[link.area].push({ label: link.label, url: link.url });
|
|
128
|
+
}
|
|
129
|
+
return { version: 1, pages, navigation };
|
|
130
|
+
}
|
|
@@ -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.3",
|
|
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": {
|