@universalcms/connect 0.13.2 → 0.13.4

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 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.2 is
4
+ package creates a website integration, not a CMS installation. Version 0.13.4 is
5
5
  prepared locally; this repository does not establish its public npm availability.
6
6
 
7
7
  ## Quick start
@@ -20,6 +20,8 @@ connection (Next.js): the CMS manages menus, branding, SEO, analytics, scripts,
20
20
  consent and redirects while the site renders every page. Add
21
21
  `<CmsNavigation location="header" />` from `@universalcms/connect/react-website`
22
22
  where CMS menus should appear.
23
+ Add `--reset` to disconnect the folder first and connect it to another website
24
+ or organization.
23
25
  The individual commands below remain for developers and reviewed integrations.
24
26
 
25
27
  Supported templates: **Next.js 16 App Router on Node**, **React 19.2+ with
@@ -262,7 +264,7 @@ custom adapter still requires review, not forced overwriting.
262
264
  For an operator's downloaded artifact, the installation is:
263
265
 
264
266
  ```sh
265
- npm install --save-prod ./vendor/universalcms-connect-0.13.2.tgz
267
+ npm install --save-prod ./vendor/universalcms-connect-0.13.4.tgz
266
268
  ```
267
269
 
268
270
  Use your project's package manager. These paths are examples for downloaded
@@ -539,7 +541,7 @@ npx --no-install universalcms-connect init \
539
541
  --environment 22222222-2222-4222-8222-222222222222
540
542
  ```
541
543
 
542
- After a public release, `npx @universalcms/connect@0.13.2 init` is the equivalent
544
+ After a public release, `npx @universalcms/connect@0.13.4 init` is the equivalent
543
545
  registry entry point. Copy the real IDs/API origin from **Developer setup**.
544
546
  Missing values are prompted in a terminal; `--non-interactive` fails instead.
545
547
  Init prints a plan. Run the same command with `--apply` after reviewing it.
@@ -589,7 +591,7 @@ The CMS button reads the website's **`/api/cms-source`** endpoint. Current Next.
589
591
  prepares delivery scaffolding. React/Vite setup serves its reviewed public manifest through the normal dev command.
590
592
  Custom exporters need their explicit adapter; a delivery check alone is insufficient.
591
593
 
592
- Use the CMS's reviewed 0.13.2 package for the staged commands and current source
594
+ Use the CMS's reviewed 0.13.4 package for the staged commands and current source
593
595
  discovery. Review source diagnostics and complete public
594
596
  mappings for unsupported pages. Restart the website using its normal server command, then run:
595
597
 
package/dist/cli.mjs CHANGED
@@ -20,7 +20,7 @@ import { WEBSITE_SECTIONS } from './website-http.mjs';
20
20
  import { setupSource, applySourcePlan, syncSource, readSourceSettings } from './source-setup.mjs';
21
21
  import { processIo, runCreate, runGuided } from './guided.mjs';
22
22
 
23
- const help = `Universal CMS connector 0.13.2
23
+ const help = `Universal CMS connector 0.13.4
24
24
 
25
25
  Quick start (recommended) — run in your website folder:
26
26
  npx @universalcms/connect@latest --cms https://YOUR_CMS_ADDRESS
@@ -33,6 +33,7 @@ Quick start (recommended) — run in your website folder:
33
33
  --cms <origin> Your Universal CMS address (or UNIVERSALCMS_URL)
34
34
  --code <code> One-time code from Developer setup → Connect without a browser
35
35
  --details Show every file and technical message
36
+ --reset Disconnect this folder first, then connect it to another website or organization
36
37
 
37
38
  New website managed entirely from the CMS (pages, posts, menus, design):
38
39
  npx @universalcms/connect@latest create my-website --cms https://YOUR_CMS_ADDRESS
@@ -338,7 +339,7 @@ function guidedArguments(args) {
338
339
  for (let index = 0; index < args.length; index++) {
339
340
  const name = args[index];
340
341
  if (Object.hasOwn(options, name)) throw new SetupError('Each option can be given once.');
341
- if (name === '--details') { options[name] = true; continue; }
342
+ if (name === '--details' || name === '--reset') { options[name] = true; continue; }
342
343
  if (!names.has(name)) throw new SetupError(`Unknown option ${JSON.stringify(name.slice(0, 40))}. Use --help for instructions.`);
343
344
  const value = args[++index];
344
345
  if (!value || value.startsWith('--')) throw new SetupError('An option is missing its value.');
@@ -363,7 +364,7 @@ async function main() {
363
364
  // it expires in ten minutes, works once and never reaches project files.
364
365
  const options = guidedArguments(process.argv.slice(2));
365
366
  if (!process.stdin.isTTY || !process.stderr.isTTY) throw new SetupError('This setup needs an interactive terminal. Run it directly in your website folder.');
366
- await runGuided(options['--project'] ?? process.cwd(), { cms: options['--cms'], code: options['--code'], details: options['--details'] === true }, processIo({ ask, write: value => process.stdout.write(value) }));
367
+ await runGuided(options['--project'] ?? process.cwd(), { cms: options['--cms'], code: options['--code'], details: options['--details'] === true, reset: options['--reset'] === true }, processIo({ ask, write: value => process.stdout.write(value) }));
367
368
  return;
368
369
  }
369
370
  const { command, options } = argumentsFor(process.argv.slice(2));
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 { cp, lstat, mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises';
12
+ import { chmod, cp, lstat, mkdir, readdir, readFile, rename, rm, 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';
@@ -378,6 +378,7 @@ export async function runGuided(directory, options, io) {
378
378
  const analysis = await analyze(root).catch(() => ({}));
379
379
  const framework = typeof analysis.framework === 'string' && analysis.framework !== 'Unknown' ? analysis.framework : null;
380
380
  io.write(`✔ Found ${framework ? `a ${framework} website` : 'a website'} in ${path.basename(root)}\n`);
381
+ if (options.reset) await resetConnection(root, io);
381
382
 
382
383
  // A Universal CMS theme website only needs pairing and its private key.
383
384
  if (await readProjectFile(root, THEME_FILE) !== null) {
@@ -392,6 +393,21 @@ export async function runGuided(directory, options, io) {
392
393
  const installed = await readProjectFile(root, '.universalcms/website.integration.json') !== null;
393
394
  if (saved && environment.keyConfigured && !options.code && await readProjectFile(root, SETTINGS_ONLY_FILE) !== null) {
394
395
  io.write(`✔ Already connected for site settings (${saved.websiteOrigin})\n`);
396
+ // Bring generated files up to date with this connector version.
397
+ const update = await planSettingsOnly(root, Object.fromEntries(['apiOrigin', 'siteId', 'environmentId', 'locale', 'websiteOrigin', 'adminOrigin'].map(name => [name, saved[name]])));
398
+ const stale = update.files.filter(file => file.state !== 'unchanged');
399
+ if (stale.length) {
400
+ await ensureConnector(root, manifest, io);
401
+ 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`);
402
+ for (const file of stale) io.write(` ${file.state}: ${file.path}\n`);
403
+ if (yes(await io.ask('Update them? [Y/n] '))) {
404
+ const again = await planSettingsOnly(root, update.connection);
405
+ if (JSON.stringify(again.files) !== JSON.stringify(update.files)) fail('Your website files changed while reviewing. Nothing was saved; run the same command again.');
406
+ await applyPlan(update);
407
+ io.write('✔ Universal CMS files updated — restart your website to load them\n');
408
+ return finishSettingsOnly(root, saved, await manifestOf(root), io, true);
409
+ }
410
+ }
395
411
  return finishSettingsOnly(root, saved, manifest, io);
396
412
  }
397
413
  if (saved && installed && environment.keyConfigured && !options.code) {
@@ -414,7 +430,7 @@ export async function runGuided(directory, options, io) {
414
430
  : await waitForBrowserApproval({ cms, io, request: { origin: saved?.websiteOrigin ?? suggestedOrigin(manifest, framework) ?? '', project: path.basename(root).slice(0, 80), framework: framework ?? '' } });
415
431
  let mode = 'full';
416
432
  const mismatch = connection => saved && (saved.siteId !== connection.siteId || saved.environmentId !== connection.environmentId);
417
- const different = 'This folder is already connected to a different website or environment in the CMS. Choose the same website when approving, or ask your developer to review .universalcms/connection.json. Nothing was changed.';
433
+ const different = 'This folder is already connected to a different website or environment in the CMS. Choose the same website when approving, or run the command again with --reset to connect it to the other website. Nothing was changed.';
418
434
  // Check (and safely fix) the website before redeeming, so a blocked site
419
435
  // never leaves an unused key behind. The approval expires on its own.
420
436
  if (approval.scope && !installed) {
@@ -456,6 +472,41 @@ export const THEME_FILE = '.universalcms/theme.json';
456
472
  export const THEME_WEBSITE_FILE = '.universalcms/website.json';
457
473
  const THEME_PORT = 3100;
458
474
 
475
+ const RESET_FILES = ['.universalcms/connection.json', '.universalcms/settings-only.json', '.universalcms/discovered.json', '.universalcms/website.json'];
476
+ const RESET_ENV = /^\s*(?:export\s+)?(CMS_API_BASE_URL|CMS_SITE_ID|CMS_ENVIRONMENT_ID|CMS_LOCALE|CMS_API_KEY|CMS_WEBSITE_ORIGIN|CMS_SOURCE_ORIGIN|CMS_ADMIN_ORIGIN)\s*=/;
477
+
478
+ /**
479
+ * `--reset`: disconnect this folder from its CMS website so it can be connected
480
+ * to another website or organization. Removes only the saved connection and the
481
+ * private CMS_* values; generated integration code stays and is updated when
482
+ * the folder is connected again in the same run.
483
+ */
484
+ export async function resetConnection(directory, io) {
485
+ const root = await projectRoot(directory);
486
+ const settingsOnly = await readProjectFile(root, SETTINGS_ONLY_FILE) !== null, theme = await readProjectFile(root, THEME_FILE) !== null;
487
+ const full = !settingsOnly && !theme && (await readProjectFile(root, '.universalcms/publication.config.json') !== null || await readProjectFile(root, '.universalcms/website.integration.json') !== null);
488
+ if (full) fail('This folder has a full page integration (pages are bound to CMS content for one website). Moving it to another website needs your developer: the page bindings and imported content belong to the current website. Nothing was changed.');
489
+ const present = [];
490
+ for (const file of RESET_FILES) if (await readProjectFile(root, file) !== null) present.push(file);
491
+ const envText = await readProjectFile(root, '.env.local');
492
+ const envLines = envText === null ? [] : envText.split(/\r?\n/).filter(line => RESET_ENV.test(line));
493
+ const saved = await readConnection(root).catch(() => null);
494
+ if (!present.length && !envLines.length) { io.write('This folder is not connected to a CMS website. Nothing to reset.\n'); return false; }
495
+ io.write(`\nDisconnect this folder${saved ? ` from website ${saved.siteId} (${saved.websiteOrigin})` : ''}:\n`);
496
+ for (const file of present) io.write(` remove: ${file}\n`);
497
+ if (envLines.length) io.write(` remove from .env.local: ${envLines.map(line => line.split('=')[0].replace(/^\s*(export\s+)?/, '').trim()).join(', ')}\n`);
498
+ io.write('Your pages, design and other settings stay as they are. You will connect it to a website right after this.\n');
499
+ if (!yes(await io.ask('Disconnect? [Y/n] '))) fail('Nothing was changed.');
500
+ for (const file of present) await rm(path.join(root, file), { force: true });
501
+ if (envLines.length) {
502
+ const kept = envText.split(/(\r?\n)/).reduce((out, part, index, parts) => { if (index % 2 === 0 && !RESET_ENV.test(part)) out.push(part + (parts[index + 1] ?? '')); return out; }, []).join('');
503
+ await writeFile(path.join(root, '.env.local'), kept, { mode: 0o600 });
504
+ await chmod(path.join(root, '.env.local'), 0o600);
505
+ }
506
+ io.write('✔ Disconnected. The old website key still works until you revoke it in that website\'s Developer setup → Advanced → website keys.\n');
507
+ return true;
508
+ }
509
+
459
510
  /** Save the public theme connection and the private key after pairing. */
460
511
  async function connectTheme(root, paired, io) {
461
512
  const { connection } = paired;
@@ -520,12 +571,12 @@ async function settingsReady(connection, io) {
520
571
  url.search = new URLSearchParams({ check: 'connection', siteId: connection.siteId, environmentId: connection.environmentId }).toString();
521
572
  const response = await io.fetch(url, { headers: { Origin: connection.adminOrigin, Accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(10000) });
522
573
  const body = response.ok ? await response.json() : null;
523
- return body?.mode === 'settings-only' && body.siteId === connection.siteId && body.environmentId === connection.environmentId;
574
+ return body?.mode === 'settings-only' && body.siteId === connection.siteId && body.environmentId === connection.environmentId && body.discovered?.version === 1;
524
575
  } catch { return false; }
525
576
  }
526
577
 
527
- async function finishSettingsOnly(root, connection, manifest, io) {
528
- if (!await settingsReady(connection, io)) {
578
+ async function finishSettingsOnly(root, connection, manifest, io, restart = false) {
579
+ if (restart || !await settingsReady(connection, io)) {
529
580
  const local = /^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|$)/.test(connection.websiteOrigin), command = await restartHint(root, manifest);
530
581
  io.write(local
531
582
  ? `\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`
@@ -538,7 +589,7 @@ async function finishSettingsOnly(root, connection, manifest, io) {
538
589
  }
539
590
  const settings = `${connection.adminOrigin}/sites/${connection.siteId}#setup?environmentId=${connection.environmentId}`;
540
591
  io.write(`✔ Your website is connected for site settings\n\n🎉 All done! Manage it from Universal CMS (this link opens the right environment):\n ${settings}\n`);
541
- io.write(' • SEO, branding, analytics, scripts, cookie consent and redirects apply automatically.\n');
592
+ 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');
542
593
  io.write(' • To show the CMS menu in your own header or footer, add one line where it should appear:\n');
543
594
  io.write(" import { CmsNavigation } from '@universalcms/connect/react-website';\n <CmsNavigation location=\"header\" /> (or location=\"footer\")\n");
544
595
  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));
@@ -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> };
@@ -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
- export function createSettingsOnlyHandler({ configuration, adminOrigin, env = () => process.env, fetcher = globalThis.fetch }) {
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
- // Every page belongs to the website in settings-only mode.
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({ state: 'missing' }, 404);
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';
@@ -39,7 +57,7 @@ export function createSettingsOnlyHandler({ configuration, adminOrigin, env = ()
39
57
  return reply({ error: 'Website scope mismatch', ...(sameSite ? { siteId: configuration.siteId, environmentId: configuration.environmentId } : {}) }, 409, cors);
40
58
  }
41
59
  const snapshot = await runtime.settings();
42
- 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 } : {}),
43
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);
44
62
  }
45
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@universalcms/connect",
3
- "version": "0.13.2",
3
+ "version": "0.13.4",
4
4
  "description": "Deterministic framework-aware Universal CMS setup for reviewed native websites",
5
5
  "type": "module",
6
6
  "bin": {