@universalcms/connect 0.1.0 → 0.13.1

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.
Files changed (130) hide show
  1. package/README.md +699 -37
  2. package/dist/angular-runtime.d.mts +4 -0
  3. package/dist/angular-runtime.mjs +7 -0
  4. package/dist/angular-setup.mjs +124 -0
  5. package/dist/astro-runtime.d.mts +6 -0
  6. package/dist/astro-runtime.mjs +255 -0
  7. package/dist/astro-setup.mjs +215 -0
  8. package/dist/astro-source.mjs +451 -0
  9. package/dist/astro-website.d.mts +5 -0
  10. package/dist/astro-website.mjs +42 -0
  11. package/dist/cli.mjs +418 -15
  12. package/dist/connector-runtime.mjs +130 -0
  13. package/dist/content-next-setup.mjs +207 -0
  14. package/dist/content-registry-validation.mjs +40 -0
  15. package/dist/content-registry.mjs +44 -0
  16. package/dist/content-setup.mjs +13 -0
  17. package/dist/content-storefront-setup.mjs +155 -0
  18. package/dist/content-vite-setup.mjs +107 -0
  19. package/dist/core.mjs +32 -51
  20. package/dist/framework-ownership.mjs +68 -0
  21. package/dist/framework.mjs +54 -0
  22. package/dist/guided.mjs +514 -0
  23. package/dist/native-adapters.mjs +31 -0
  24. package/dist/native-chrome-model.d.mts +3 -0
  25. package/dist/native-chrome-model.mjs +14 -0
  26. package/dist/native-chrome-review.mjs +49 -0
  27. package/dist/native-chrome-source.mjs +69 -0
  28. package/dist/native-route-review.mjs +152 -0
  29. package/dist/native-routes.mjs +27 -0
  30. package/dist/native-website-model.d.mts +18 -0
  31. package/dist/native-website-model.mjs +67 -0
  32. package/dist/native-website-server.d.mts +1 -0
  33. package/dist/native-website-server.mjs +35222 -0
  34. package/dist/native-website-server.mjs.LEGAL.txt +56 -0
  35. package/dist/native-website.d.mts +3 -0
  36. package/dist/native-website.mjs +120 -0
  37. package/dist/native-website.mjs.LEGAL.txt +56 -0
  38. package/dist/next-publication.d.mts +12 -0
  39. package/dist/next-publication.mjs +107 -0
  40. package/dist/next-settings-layout.mjs +233 -0
  41. package/dist/next-source.d.mts +8 -0
  42. package/dist/next-source.mjs +93 -0
  43. package/dist/page-discovery.mjs +236 -0
  44. package/dist/page-mapping-review.mjs +160 -0
  45. package/dist/preflight-recovery.mjs +130 -0
  46. package/dist/preflight.mjs +138 -0
  47. package/dist/project-files.mjs +56 -0
  48. package/dist/public-bindings.d.mts +3 -0
  49. package/dist/public-bindings.mjs +28 -0
  50. package/dist/publication-browser.d.mts +5 -0
  51. package/dist/publication-browser.mjs +258 -0
  52. package/dist/publication-check.mjs +141 -0
  53. package/dist/publication-document.d.mts +2 -0
  54. package/dist/publication-document.mjs +59 -0
  55. package/dist/publication-next-setup.mjs +134 -0
  56. package/dist/publication-setup.mjs +138 -0
  57. package/dist/publication-verification.mjs +85 -0
  58. package/dist/publication.css +6 -0
  59. package/dist/publication.d.mts +31 -0
  60. package/dist/publication.mjs +320 -0
  61. package/dist/react-publication.d.mts +22 -0
  62. package/dist/react-publication.mjs +301 -0
  63. package/dist/react-website.d.mts +15 -0
  64. package/dist/react-website.mjs +735 -0
  65. package/dist/reviewed-commerce-ownership.mjs +26 -0
  66. package/dist/reviewed-commerce-settings-setup.mjs +243 -0
  67. package/dist/reviewed-native-ownership.mjs +118 -0
  68. package/dist/reviewed-storefront-source.mjs +227 -0
  69. package/dist/runtime-dependencies.mjs +59 -0
  70. package/dist/settings-only-setup.mjs +100 -0
  71. package/dist/settings-only.d.mts +4 -0
  72. package/dist/settings-only.mjs +49 -0
  73. package/dist/setup-environment.mjs +70 -0
  74. package/dist/source-bindings.mjs +77 -0
  75. package/dist/source-check.mjs +149 -0
  76. package/dist/source-collect.mjs +80 -0
  77. package/dist/source-config.mjs +20 -0
  78. package/dist/source-extract.mjs +514 -0
  79. package/dist/source-finalize.mjs +52 -0
  80. package/dist/source-routes.mjs +65 -0
  81. package/dist/source-setup.mjs +490 -0
  82. package/dist/staged-setup.mjs +287 -0
  83. package/dist/static-runtime.mjs +67 -0
  84. package/dist/static-setup.mjs +136 -0
  85. package/dist/svelte-runtime.d.mts +27 -0
  86. package/dist/svelte-runtime.mjs +40 -0
  87. package/dist/svelte-setup.mjs +135 -0
  88. package/dist/svelte-template.mjs +48 -0
  89. package/dist/templates.mjs +167 -3
  90. package/dist/vendor/astro-compiler/LICENSE +53 -0
  91. package/dist/vendor/astro-compiler/astro.wasm +0 -0
  92. package/dist/vendor/astro-compiler/chunk-W5DTLHV4.js +1 -0
  93. package/dist/vendor/astro-compiler/node/index.js +1 -0
  94. package/dist/vendor/project-analysis.mjs +58 -4
  95. package/dist/vendor/react-LICENSE.txt +21 -0
  96. package/dist/vendor/react-document.mjs +124 -0
  97. package/dist/vendor/react-dom-LICENSE.txt +21 -0
  98. package/dist/vendor/react-form.mjs +63 -0
  99. package/dist/vendor/react-safety.mjs +47 -0
  100. package/dist/vendor/source-manifest-provider.mjs +104 -0
  101. package/dist/vendor/typescript-LICENSE.txt +55 -0
  102. package/dist/vendor/typescript.cjs +200276 -0
  103. package/dist/vite-publication-server.d.mts +3 -0
  104. package/dist/vite-publication-server.mjs +82 -0
  105. package/dist/vite-publication.d.mts +15 -0
  106. package/dist/vite-publication.mjs +241 -0
  107. package/dist/vite-source-discovery.mjs +315 -0
  108. package/dist/vite-source.d.mts +30 -0
  109. package/dist/vite-source.mjs +141 -0
  110. package/dist/vue-runtime.d.mts +10 -0
  111. package/dist/vue-runtime.mjs +50 -0
  112. package/dist/vue-server.d.mts +2 -0
  113. package/dist/vue-server.mjs +58 -0
  114. package/dist/vue-setup.mjs +127 -0
  115. package/dist/vue-source.mjs +168 -0
  116. package/dist/website-check.mjs +41 -0
  117. package/dist/website-http.mjs +149 -0
  118. package/dist/website-model.d.mts +20 -0
  119. package/dist/website-model.mjs +155 -0
  120. package/dist/website-next.d.mts +2 -0
  121. package/dist/website-next.mjs +29 -0
  122. package/dist/website-node.d.mts +3 -0
  123. package/dist/website-node.mjs +50 -0
  124. package/dist/website-runtime.d.mts +13 -0
  125. package/dist/website-runtime.mjs +404 -0
  126. package/dist/website-schema.mjs +22175 -0
  127. package/dist/website-setup.mjs +256 -0
  128. package/dist/website.css +49 -0
  129. package/dist/wizard.mjs +237 -38
  130. package/package.json +44 -7
@@ -0,0 +1,514 @@
1
+ /**
2
+ * One-command website connection: `npx @universalcms/connect`.
3
+ *
4
+ * Pairs with the CMS (browser approval or a one-time code), installs the
5
+ * matching connector, then runs the same reviewed setup, key, check, import
6
+ * and finalize steps as the individual commands. Every effect is injected so
7
+ * the flow is testable; rerunning it continues from the first unfinished step.
8
+ */
9
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
10
+ import { createServer } from 'node:http';
11
+ import { spawn } from 'node:child_process';
12
+ import { lstat, readFile } from 'node:fs/promises';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { analyze, applyPlan, projectRoot, readProjectFile, SetupError } from './core.mjs';
16
+ import { runSetup } from './wizard.mjs';
17
+ import { readConnection, runServerKey, validateConnection } from './staged-setup.mjs';
18
+ import { readSetupEnvironment } from './setup-environment.mjs';
19
+ import { readSourceSettings } from './source-setup.mjs';
20
+ import { checkPublication } from './publication-check.mjs';
21
+ import { finalizeSource } from './source-finalize.mjs';
22
+ import { preflightSetup } from './preflight.mjs';
23
+ import { planSettingsOnly, SETTINGS_ONLY_FILE } from './settings-only-setup.mjs';
24
+ import { applyEnvironment, planEnvironment } from './setup-environment.mjs';
25
+
26
+ export const PAIRING_WAIT_MS = 10 * 60 * 1000;
27
+ export const WEBSITE_WAIT_MS = 15 * 60 * 1000;
28
+ const codePattern = /^cms_pair\.[0-9a-f-]{36}\.[0-9a-f-]{36}\.[A-Za-z0-9_-]{43}$/;
29
+ const serverKey = /^cms_server\.[0-9a-f-]{36}\.[0-9a-f-]{36}\.[A-Za-z0-9_-]{43}$/;
30
+ const fail = message => { throw new SetupError(message); };
31
+ const yes = answer => ['', 'y', 'yes'].includes(answer.trim().toLowerCase());
32
+ // Prompts from the reviewed setup that the single plain-language review already covers.
33
+ const covered = new Map([
34
+ ['Install header/footer and website settings? [Y/n] ', 'y'],
35
+ ['Save a server key now? [Y/n] ', 'y'],
36
+ ['Save this server key? [Y/n] ', 'y'],
37
+ ]);
38
+
39
+ export function cmsOrigin(value) {
40
+ if (typeof value !== 'string' || value.length > 2048) return null;
41
+ try {
42
+ const url = new URL(value.includes('://') ? value : `https://${value}`);
43
+ if (url.username || url.password || url.search || url.hash || url.pathname !== '/') return null;
44
+ return url.protocol === 'https:' || url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) ? url.origin : null;
45
+ } catch { return null; }
46
+ }
47
+
48
+ async function connectorVersion() {
49
+ const manifest = JSON.parse(await readFile(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'));
50
+ return manifest.version;
51
+ }
52
+
53
+ async function manifestOf(root) {
54
+ try {
55
+ const value = JSON.parse(await readProjectFile(root, 'package.json'));
56
+ if (value && typeof value === 'object' && !Array.isArray(value)) return value;
57
+ } catch { /* Fixed guidance follows. */ }
58
+ return fail('Run this command inside your website folder — the folder that contains package.json.');
59
+ }
60
+
61
+ export async function packageManager(root, manifest) {
62
+ const declared = typeof manifest.packageManager === 'string' && /^(npm|pnpm|yarn|bun)@/.exec(manifest.packageManager);
63
+ if (declared) return declared[1];
64
+ for (const [file, manager] of [['pnpm-lock.yaml', 'pnpm'], ['yarn.lock', 'yarn'], ['bun.lock', 'bun'], ['bun.lockb', 'bun']]) {
65
+ try { if ((await lstat(path.join(root, file))).isFile()) return manager; } catch { /* Not this manager. */ }
66
+ }
67
+ return 'npm';
68
+ }
69
+ export function installArguments(manager, spec) { return manager === 'npm' ? ['install', '--save-prod', spec] : ['add', spec]; }
70
+
71
+ /** Best guess for the address the website runs on locally; the person confirms it in the browser. */
72
+ export function suggestedOrigin(manifest, framework) {
73
+ const dev = typeof manifest.scripts?.dev === 'string' ? manifest.scripts.dev : '';
74
+ const port = /(?:--port|-p)[ =](\d{2,5})\b/.exec(dev)?.[1];
75
+ const defaults = { 'Next.js': 3000, Nuxt: 3000, React: 5173, Vue: 5173, SvelteKit: 5173, Astro: 4321, Angular: 4200 };
76
+ const chosen = port ? Number(port) : defaults[framework];
77
+ return chosen && chosen <= 65535 ? `http://localhost:${chosen}` : null;
78
+ }
79
+
80
+ async function installedConnector(root) {
81
+ try { return JSON.parse(await readFile(path.join(root, 'node_modules', '@universalcms', 'connect', 'package.json'), 'utf8')).version ?? null; }
82
+ catch { return null; }
83
+ }
84
+
85
+ /** The website imports the connector at runtime, so it must be a matching production dependency. */
86
+ async function ensureConnector(root, manifest, io) {
87
+ const version = await connectorVersion();
88
+ if (typeof manifest.dependencies?.['@universalcms/connect'] === 'string' && await installedConnector(root) === version) return;
89
+ const override = io.env.UNIVERSALCMS_CONNECT_PACKAGE;
90
+ if (override !== undefined && !/^[^\s'"`;&|<>$]{1,2048}$/.test(override)) fail('UNIVERSALCMS_CONNECT_PACKAGE must be a package spec, file path or URL without spaces.');
91
+ const spec = override ?? `@universalcms/connect@${version}`;
92
+ const manager = await packageManager(root, manifest);
93
+ const args = installArguments(manager, spec);
94
+ io.write(`\nYour website needs the Universal CMS package. I'll run:\n ${manager} ${args.join(' ')}\n`);
95
+ if (!yes(await io.ask('Add it now? [Y/n] '))) fail(`Nothing was changed. When you're ready, run the same command again.`);
96
+ if (await io.run(manager, args, root) !== 0) fail(`The package could not be installed. Check your internet connection, then run:\n ${manager} ${args.join(' ')}\nand start this command again.`);
97
+ if (await installedConnector(root) !== version) fail(`The installed package version does not match this setup (${version}). Run the same command again; if it repeats, run ${manager} ${args.join(' ')} yourself.`);
98
+ io.write('✔ Universal CMS package added\n');
99
+ }
100
+
101
+ /**
102
+ * A website that already renders its own header and footer needs a saved
103
+ * layout review before setup. Present that same review in plain language;
104
+ * when it does not apply, setup reports its own reason.
105
+ */
106
+ async function reviewLayout(root, manifest, framework, io) {
107
+ const { nativeFramework } = await import('./native-adapters.mjs');
108
+ let plan, replan;
109
+ try {
110
+ if (nativeFramework(manifest)) {
111
+ const { planNativeChromeReview } = await import('./native-chrome-review.mjs');
112
+ replan = () => planNativeChromeReview(root);
113
+ } else if (framework === 'Next.js') {
114
+ const { planSettingsLayoutReview } = await import('./next-settings-layout.mjs');
115
+ replan = () => planSettingsLayoutReview(root);
116
+ } else return false;
117
+ plan = await replan();
118
+ } catch { return false; }
119
+ const changes = plan.files.filter(file => ['create', 'update'].includes(file.state));
120
+ if (!changes.length) return false;
121
+ const sources = [...new Set((plan.slots ?? plan.pages ?? []).map(item => item.path))];
122
+ io.write(`\nYour website already has its own header and footer${sources.length ? ` (${sources.join(', ')})` : ''}.\nI'll keep them exactly as they are and add places where menus and settings from the CMS can appear.\n`);
123
+ if (!yes(await io.ask('Okay? [Y/n] '))) fail('Nothing was changed. Run the same command again when you are ready.');
124
+ if (JSON.stringify(await replan()) !== JSON.stringify(plan)) fail('Your website files changed while reviewing. Nothing was saved; run the same command again.');
125
+ await applyPlan(plan);
126
+ io.write('✔ Header and footer reviewed\n');
127
+ return true;
128
+ }
129
+
130
+ // Static paths that are usually interactive application screens rather than
131
+ // editable pages. They are proposed as website-owned; the person can change it.
132
+ export const ALL_APP_SCREENS = 'Every page in this website is an interactive app screen (built with state, tabs or data lists), so none of its page text can be made editable automatically yet.';
133
+ const appScreen = /(^|\/)(cart|basket|bag|checkout|payment|pay|orders?|order-history|track(ing)?|account|my-account|profile|settings|login|log-in|signin|sign-in|signup|sign-up|register|logout|auth|dashboard|admin|portal|wishlist|favou?rites|search|upload[\w-]*|book(ing)?s?|reservations?|messages|inbox|notifications|api)(\/|$)/i;
134
+
135
+ /** Propose which routes stay part of the website app; ask once; save the reviewed manifest. */
136
+ async function reviewRoutes(root, blocker, io, reason) {
137
+ const { inspectNativeRouteCandidates, planNativeRouteReview } = await import('./native-route-review.mjs');
138
+ let inventory;
139
+ try { inventory = await inspectNativeRouteCandidates(root); } catch { return false; }
140
+ const files = new Set(blocker?.paths ?? []);
141
+ const flagged = new Set([...files].map(name => name.replace(/^(?:src\/)?app/, '')));
142
+ for (const row of inventory.candidates) if (files.has(row.file)) flagged.add(row.path);
143
+ const existing = await readProjectFile(root, '.universalcms/native.routes.json').then(text => text ? JSON.parse(text) : null).catch(() => null);
144
+ for (const path of [...existing?.nativePaths ?? [], ...existing?.nativePatterns ?? []]) flagged.add(path);
145
+ // Client components render from state, so their text cannot be bound to CMS content.
146
+ const client = new Set();
147
+ for (const row of inventory.candidates) {
148
+ const text = await readProjectFile(root, row.file).catch(() => null);
149
+ if (text && /^\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*['"]use client['"]/.test(text)) client.add(row.path);
150
+ }
151
+ let owned = inventory.candidates.filter(row => row.kind === 'dynamic' || flagged.has(row.path) || client.has(row.path) || appScreen.test(row.path)).map(row => row.path);
152
+ if (!inventory.candidates.some(row => row.kind === 'static' && !owned.includes(row.path)) && inventory.candidates.filter(row => row.kind === 'static').every(row => client.has(row.path) || flagged.has(row.path) || appScreen.test(row.path)) && inventory.candidates.some(row => client.has(row.path) || flagged.has(row.path))) throw new SetupError(ALL_APP_SCREENS);
153
+ const cmsPages = () => inventory.candidates.filter(row => row.kind === 'static' && !owned.includes(row.path));
154
+ if (!cmsPages().length) {
155
+ if (reason) throw new SetupError(ALL_APP_SCREENS);
156
+ owned = owned.filter(path => path !== '/');
157
+ }
158
+ const show = () => {
159
+ io.write('\nI looked at every page in your website:\n');
160
+ inventory.candidates.forEach((row, index) => io.write(` ${String(index + 1).padStart(2)}. ${row.path === '/' ? '/ (home page)' : row.path}${row.kind === 'dynamic' ? ' (one page per item)' : client.has(row.path) && owned.includes(row.path) ? ' (interactive)' : ''} — ${owned.includes(row.path) ? 'stays as part of your website app' : 'editable in the CMS'}\n`));
161
+ };
162
+ if (reason) io.write(`\n${reason}\n`);
163
+ show();
164
+ io.write('Pages that stay part of your website app keep working exactly as they do now (shopping, accounts, forms, product details).\nAll other pages become editable in the CMS. Header, footer and site settings are managed in the CMS either way.\n');
165
+ while (true) {
166
+ const answer = (await io.ask('Use this? [Y/n, or type page numbers to switch them, e.g. 3,5] ')).trim();
167
+ if (yes(answer)) break;
168
+ if (/^n(o)?$/i.test(answer)) fail('Nothing was changed. Run the same command again when you are ready.');
169
+ if (!/^\d+(\s*,\s*\d+)*$/.test(answer)) { io.write('Type Y, n, or page numbers separated by commas.\n'); continue; }
170
+ for (const index of answer.split(',').map(value => Number(value.trim()) - 1)) {
171
+ const row = inventory.candidates[index];
172
+ if (!row) continue;
173
+ owned = owned.includes(row.path) ? owned.filter(path => path !== row.path) : [...owned, row.path];
174
+ }
175
+ if (!cmsPages().length) { io.write('At least one page must stay editable in the CMS.\n'); owned = owned.filter(path => path !== '/'); }
176
+ show();
177
+ }
178
+ if (!owned.length) return false;
179
+ const plan = await planNativeRouteReview(root, owned);
180
+ if (JSON.stringify(await planNativeRouteReview(root, owned)) !== JSON.stringify(plan)) fail('Your website files changed while reviewing. Nothing was saved; run the same command again.');
181
+ await applyPlan(plan);
182
+ io.write('✔ Website app pages reviewed\n');
183
+ return true;
184
+ }
185
+
186
+ /** Plain-language guidance for what to change in the website when a blocker cannot be fixed automatically. */
187
+ export function siteChangeAdvice(blocker) {
188
+ const message = blocker?.message ?? '', paths = blocker?.paths ?? [];
189
+ const where = paths.length ? ` (${paths.slice(0, 6).join(', ')}${paths.length > 6 ? ', …' : ''})` : '';
190
+ if (/next\.config|Configuration needs developer inspection|Dynamic Next configuration|configuration uses values/i.test(message)) return [`Your Next.js config is computed when it runs, so it can't be read safely. Make next.config export a plain object, for example:\n module.exports = { reactStrictMode: true };`];
191
+ if (/static export|output:\s*'export'/i.test(message)) return ['Your site is built as static files only. The CMS needs your site to run its Node server: remove `output: "export"` from next.config and deploy with `next start` (or another Node host).'];
192
+ if (/Pages Router|pages\//.test(message)) return ['Your site uses the older Next.js Pages Router (pages/ folder). Move the pages you want editable into the App Router (app/ folder), or ask your developer for a custom integration.'];
193
+ if (/Workspaces|workspace/i.test(message)) return ['Run this command inside the single website folder (the one with its own package.json), not the monorepo root.'];
194
+ if (/version|Node\.js|supported/i.test(message) && /requires?|supports?|unsupported/i.test(message)) return [`A framework or Node.js version is outside the supported range. ${message}`];
195
+ if (message === ALL_APP_SCREENS) return [message, 'To make a page editable from the CMS, write its main text directly in the page file (headings, paragraphs, images as plain JSX in a server component, without \'use client\'), and keep interactive parts like carts, tabs and product lists in separate components. Then run this command again.', 'Ask your developer if you need a custom integration for data-driven pages.'];
196
+ if (blocker?.code === 'content-bindings-review' || /content adapter|bindings|repeated|data-driven|route props/i.test(message)) return [`Some pages${where} build their text from data, props or loops, so the CMS can't tell which words to make editable. Either keep those pages as part of your website app (rerun and switch them), or write the editable text directly in the page (headings, paragraphs and images as plain JSX).`];
197
+ if (/header|footer|layout|chrome/i.test(message)) return [`Your header or footer${where} is built from components or logic the CMS can't safely extend. Put the header and footer markup directly in the root layout (plain <header>/<footer> elements), then rerun.`];
198
+ if (/route|dynamic|parallel/i.test(message)) return [`Some routes${where} use patterns that need a developer: catch-all, parallel or intercepted routes. Keep them in your app and ask your developer for a reviewed adapter.`];
199
+ return ['This website uses a pattern the automatic setup does not support yet.'];
200
+ }
201
+
202
+ /**
203
+ * Check the website before anything is installed, fix what can be fixed safely
204
+ * with one plain question each, and explain the rest. Resolving one finding can
205
+ * reveal the next, so it loops a bounded number of times.
206
+ */
207
+ async function prepareWebsite(root, connection, manifest, framework, io) {
208
+ const scope = Object.fromEntries(['apiOrigin', 'siteId', 'environmentId', 'locale', 'websiteOrigin', 'adminOrigin'].map(name => [name, connection[name]]));
209
+ const attempted = new Set();
210
+ for (let round = 0; round < 6; round++) {
211
+ const result = await preflightSetup(root, scope);
212
+ if (result.status === 'planned') return 'full';
213
+ const blocker = result.blockers?.[0];
214
+ const key = `${blocker?.code}:${blocker?.message}:${(blocker?.paths ?? []).join(',')}`;
215
+ let advice = blocker;
216
+ if (blocker && !attempted.has(key)) try {
217
+ attempted.add(key);
218
+ if (blocker.code === 'next-route-ownership' && await reviewRoutes(root, blocker, io)) continue;
219
+ if (blocker.code === 'content-bindings-review' && framework === 'Next.js' && blocker.paths?.length && await reviewRoutes(root, blocker, io, `${blocker.paths.join(', ')} builds its text from data or loops, so its words can't be made editable automatically. I've switched it to stay part of your website app (it keeps working as it does now).`)) continue;
220
+ if (['native-chrome-layout', 'native-settings-layout'].includes(blocker.code) && await reviewLayout(root, manifest, framework, io)) continue;
221
+ } catch (error) { if (error?.message !== ALL_APP_SCREENS) throw error; advice = { message: ALL_APP_SCREENS }; }
222
+ // Pages that cannot be edited automatically can still have their site
223
+ // settings managed by the CMS while the website keeps rendering every page.
224
+ if (framework === 'Next.js' && (advice?.message === ALL_APP_SCREENS || ['content-bindings-review', 'next-route-ownership'].includes(blocker?.code))) {
225
+ io.write(`\n${advice?.message === ALL_APP_SCREENS ? ALL_APP_SCREENS : 'Some pages in this website can\'t be made editable automatically.'}\n`);
226
+ io.write('You can still connect it for site settings: the CMS manages your menus, logo and branding, SEO (titles, descriptions, social previews), analytics, scripts, cookie consent and redirects. Every page keeps working exactly as it does now.\n');
227
+ if (yes(await io.ask('Connect for site settings? [Y/n] '))) return 'settings-only';
228
+ }
229
+ io.write('\n✋ Your website needs a small change before it can connect:\n');
230
+ for (const line of siteChangeAdvice(advice)) io.write(` • ${line}\n`);
231
+ io.write(`\nDetails for your developer:\n ${result.message ?? blocker?.message ?? 'Preflight did not pass.'}\n`);
232
+ fail('Your website files were not changed (only the Universal CMS package was added). Make the change above, then run the same command again.');
233
+ }
234
+ fail('Setup found more items to review than it can handle automatically. Ask your developer to run the step-by-step setup in Developer setup.');
235
+ }
236
+
237
+ function pkce() {
238
+ const verifier = randomBytes(32).toString('base64url');
239
+ return { verifier, challenge: createHash('sha256').update(verifier).digest('base64url'), state: randomBytes(32).toString('base64url') };
240
+ }
241
+ const page = (title, body) => `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${title}</title></head><body style="font:16px/1.6 system-ui,sans-serif;margin:0;padding:64px 16px;text-align:center;color:#0f172a;background:#f8fafc"><h1 style="font-size:22px">${title}</h1><p>${body}</p></body></html>`;
242
+
243
+ /** Listen on loopback for the admin to hand back a pairing code bound to this run's state. */
244
+ export async function waitForBrowserApproval({ cms, request, io, timeoutMs = PAIRING_WAIT_MS }) {
245
+ const { verifier, challenge, state } = pkce();
246
+ let settle;
247
+ const result = new Promise((resolve, reject) => { settle = { resolve, reject }; });
248
+ const server = createServer((req, res) => {
249
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
250
+ if (req.method !== 'GET' || url.pathname !== '/callback') { res.writeHead(404).end(); return; }
251
+ const code = url.searchParams.get('code') ?? '', returned = Buffer.from(url.searchParams.get('state') ?? ''), expected = Buffer.from(state);
252
+ const valid = returned.length === expected.length && timingSafeEqual(returned, expected) && codePattern.test(code);
253
+ res.writeHead(valid ? 200 : 400, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' });
254
+ res.end(valid ? page('Approved — back to your terminal', 'You can close this tab. Setup continues in your terminal.') : page('This link is not valid', 'Start the connection again from your terminal.'));
255
+ if (valid) {
256
+ let scope = null;
257
+ try { scope = validateConnection({ version: 1, ...Object.fromEntries(['apiOrigin', 'siteId', 'environmentId', 'locale', 'websiteOrigin', 'adminOrigin'].map(name => [name, url.searchParams.get(name) ?? undefined])) }); } catch { /* Older CMS: check after redemption. */ }
258
+ settle.resolve({ code, scope });
259
+ }
260
+ });
261
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
262
+ const timer = setTimeout(() => settle.reject(new SetupError('No approval arrived within 10 minutes. Run the same command again to start over.')), timeoutMs);
263
+ try {
264
+ const { port } = server.address();
265
+ const query = new URLSearchParams({ callback: `http://127.0.0.1:${port}/callback`, state, challenge, ...Object.fromEntries(Object.entries(request).filter(([, value]) => value)) });
266
+ const link = `${cms}/connect?${query}`;
267
+ io.write('\nOpening your browser to approve this website…\n');
268
+ if (!await io.openUrl(link)) io.write('Your browser did not open. Open this link to continue:\n');
269
+ io.write(` ${link}\n\nWaiting for approval… (no browser on this computer? In the CMS open Developer setup → “Connect without a browser”)\n`);
270
+ const { code, scope } = await result;
271
+ return { code, verifier, scope };
272
+ } finally { clearTimeout(timer); server.close(); }
273
+ }
274
+
275
+ export async function redeem(cms, code, verifier, fetcher) {
276
+ let response;
277
+ try {
278
+ response = await fetcher(`${cms}/api/cms/connect/pairings/redeem`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, ...(verifier ? { verifier } : {}) }), redirect: 'error', signal: AbortSignal.timeout(15000) });
279
+ } catch { fail(`The CMS at ${cms} could not be reached. Check the address and your internet connection, then try again.`); }
280
+ if (response.status === 401) fail('This approval expired or was already used. Run the same command again to start over.');
281
+ let body;
282
+ try { body = await response.json(); } catch { /* Fixed guidance follows. */ }
283
+ if (!response.ok || !body?.data) fail(`The CMS could not finish the connection (HTTP ${response.status}). Try again in a minute.`);
284
+ const data = body.data;
285
+ const connection = validateConnection({ version: 1, apiOrigin: data.apiOrigin, siteId: data.siteId, environmentId: data.environmentId, locale: data.locale, websiteOrigin: data.websiteOrigin, adminOrigin: data.adminOrigin });
286
+ if (typeof data.serverKey !== 'string' || !serverKey.test(data.serverKey)) fail('The CMS returned an unexpected response. No key was saved.');
287
+ return { connection, siteName: typeof data.siteName === 'string' ? data.siteName.slice(0, 100) : '', serverKey: data.serverKey };
288
+ }
289
+
290
+ function describeRoute(route) { return route.path === '/' ? '/ (home page)' : route.path; }
291
+
292
+ /** Adapts the reviewed setup's terminal prompts: one review for the person, the key from pairing. */
293
+ function setupIo(io, key, details) {
294
+ let pending = key;
295
+ const log = [];
296
+ return {
297
+ log,
298
+ write: text => { log.push(text); if (details) io.write(text); },
299
+ ask: async prompt => covered.has(prompt) ? covered.get(prompt) : (io.write(log.splice(0).join('')), io.ask(prompt)),
300
+ hidden: async () => {
301
+ if (!pending) fail('The website key from the CMS was not accepted. Run the same command again.');
302
+ const value = pending; pending = undefined; return value;
303
+ },
304
+ review: async ({ framework, routes, files }) => {
305
+ const added = files.filter(file => file.state === 'create').length, changed = files.filter(file => ['update', 'append', 'replace'].includes(file.state)).length;
306
+ io.write(`\nReady to connect your ${framework} website:\n`);
307
+ if (routes.length) io.write(` • These pages become editable in the CMS: ${routes.slice(0, 8).map(describeRoute).join(', ')}${routes.length > 8 ? ` and ${routes.length - 8} more` : ''}\n`);
308
+ io.write(' • Header, footer and site settings can be managed in the CMS\n');
309
+ io.write(` • ${added} file${added === 1 ? '' : 's'} will be added${changed ? ` and ${changed} updated` : ''} — your design stays the same\n`);
310
+ io.write(' • The website key is saved privately in .env.local (never committed to Git)\n');
311
+ if (routes.length) io.write(' ! After setup, these pages show “not found” until you publish them in the CMS.\n');
312
+ if (details) for (const file of files) io.write(` ${file.state}: ${file.path}\n`);
313
+ else io.write(' (run with --details to list every file)\n');
314
+ return yes(await io.ask('\nGo ahead? [Y/n] '));
315
+ },
316
+ };
317
+ }
318
+
319
+ async function restartHint(root, manifest) {
320
+ const manager = await packageManager(root, manifest);
321
+ return typeof manifest.scripts?.dev === 'string' ? `${manager} run dev` : null;
322
+ }
323
+
324
+ /** Poll until the running website serves the installed integration. */
325
+ async function waitForWebsite(root, connection, manifest, io, reason) {
326
+ const values = { websiteOrigin: connection.websiteOrigin, adminOrigin: connection.adminOrigin, siteId: connection.siteId, environmentId: connection.environmentId };
327
+ let outcome = await io.checkPublication(root, values);
328
+ if (outcome.checked) return true;
329
+ const local = /^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|$)/.test(connection.websiteOrigin);
330
+ const command = await restartHint(root, manifest);
331
+ io.write(`\n→ ${reason}\n`);
332
+ io.write(local
333
+ ? ` 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`
334
+ : ` Deploy your website the way you normally do. It must be reachable at ${connection.websiteOrigin}\n`);
335
+ io.write(` Waiting for ${connection.websiteOrigin} … (Ctrl+C stops waiting; run this command again later to continue)\n`);
336
+ const deadline = io.now() + WEBSITE_WAIT_MS;
337
+ while (io.now() < deadline) {
338
+ await io.sleep(3000);
339
+ outcome = await io.checkPublication(root, values);
340
+ if (outcome.checked) return true;
341
+ }
342
+ io.write(`\nThe website at ${connection.websiteOrigin} is not responding with the CMS connection yet (${outcome.message})\nRun this command again once it's running.\n`);
343
+ return false;
344
+ }
345
+
346
+ /** After setup: confirm the running site, guide the content import, close the bootstrap export. */
347
+ async function goLive(root, connection, manifest, io) {
348
+ if (!await waitForWebsite(root, connection, manifest, io, 'Restart your website so the changes take effect:')) return { connected: false };
349
+ io.write('✔ Your website is connected to the CMS\n');
350
+ const pages = `${connection.adminOrigin}/sites/${connection.siteId}`, setup = `${pages}#setup?environmentId=${connection.environmentId}&step=import`;
351
+ if (!(await readSourceSettings(root)).exportClosed) {
352
+ io.write('\nNext, copy your existing page text into the CMS:\n');
353
+ io.write(` In the CMS page that just opened, click “Connect & fetch content”\n ${setup}\n`);
354
+ await io.openUrl(setup);
355
+ // Closing the export before the import ran would strand the original content,
356
+ // so this needs an explicit yes; anything else leaves it open for a rerun.
357
+ const answer = (await io.ask('\nDid the CMS show “Website content fetched”? [y/N] ')).trim().toLowerCase();
358
+ if (!['y', 'yes'].includes(answer)) { io.write('No problem — nothing was closed. Run this command again after the import shows “Website content fetched”.\n'); return { connected: true, imported: false }; }
359
+ await finalizeSource(root, { apply: true });
360
+ io.write('✔ Import finished; the temporary content export is closed\n');
361
+ if (!await waitForWebsite(root, connection, manifest, io, 'Restart your website one more time:')) return { connected: true, imported: true };
362
+ }
363
+ io.write(`\n🎉 All done! Your website is connected to Universal CMS.\n Edit your pages: ${pages}\n Publish a page in the CMS to show your changes on the website.\n Run this command again any time to check the connection.\n`);
364
+ return { connected: true, imported: true, done: true };
365
+ }
366
+
367
+ /**
368
+ * @param {string} directory website folder
369
+ * @param {{ cms?: string, code?: string, details?: boolean }} options
370
+ * @param {{ ask, write, openUrl, run, fetch, env, sleep, now, checkPublication }} io
371
+ */
372
+ export async function runGuided(directory, options, io) {
373
+ const root = await projectRoot(directory);
374
+ const manifest = await manifestOf(root);
375
+ io.write('\nUniversal CMS · connect your website\n');
376
+ const analysis = await analyze(root).catch(() => ({}));
377
+ const framework = typeof analysis.framework === 'string' && analysis.framework !== 'Unknown' ? analysis.framework : null;
378
+ io.write(`✔ Found ${framework ? `a ${framework} website` : 'a website'} in ${path.basename(root)}\n`);
379
+
380
+ const saved = await readConnection(root);
381
+ const environment = await readSetupEnvironment(root);
382
+ const installed = await readProjectFile(root, '.universalcms/website.integration.json') !== null;
383
+ if (saved && environment.keyConfigured && !options.code && await readProjectFile(root, SETTINGS_ONLY_FILE) !== null) {
384
+ io.write(`✔ Already connected for site settings (${saved.websiteOrigin})\n`);
385
+ return finishSettingsOnly(root, saved, manifest, io);
386
+ }
387
+ if (saved && installed && environment.keyConfigured && !options.code) {
388
+ io.write(`✔ Already set up for ${saved.websiteOrigin}\n`);
389
+ return goLive(root, saved, await manifestOf(root), io);
390
+ }
391
+
392
+ const cms = cmsOrigin(options.cms ?? io.env.UNIVERSALCMS_URL ?? saved?.adminOrigin ?? '') ?? await (async () => {
393
+ while (true) {
394
+ const value = cmsOrigin((await io.ask('Your Universal CMS address (for example https://cms.example.com): ')).trim());
395
+ if (value) return value;
396
+ io.write('Enter the address you use to sign in to the CMS.\n');
397
+ }
398
+ })();
399
+ if (options.code !== undefined && !codePattern.test(options.code)) fail('That connection code is not valid. Copy the whole command from the CMS again.');
400
+
401
+ await ensureConnector(root, manifest, io);
402
+ const approval = options.code
403
+ ? { code: options.code, verifier: undefined }
404
+ : await waitForBrowserApproval({ cms, io, request: { origin: saved?.websiteOrigin ?? suggestedOrigin(manifest, framework) ?? '', project: path.basename(root).slice(0, 80), framework: framework ?? '' } });
405
+ let mode = 'full';
406
+ const mismatch = connection => saved && (saved.siteId !== connection.siteId || saved.environmentId !== connection.environmentId);
407
+ 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.';
408
+ // Check (and safely fix) the website before redeeming, so a blocked site
409
+ // never leaves an unused key behind. The approval expires on its own.
410
+ if (approval.scope && !installed) {
411
+ if (mismatch(approval.scope)) fail(different);
412
+ io.write('✔ Approved in the CMS — checking your website…\n');
413
+ mode = await prepareWebsite(root, approval.scope, manifest, framework, io);
414
+ }
415
+ const paired = await redeem(cms, approval.code, approval.verifier, io.fetch);
416
+ io.write(`✔ Connected to ${paired.siteName || 'your website'} (${paired.connection.websiteOrigin})\n`);
417
+ if (mismatch(paired.connection)) fail(different);
418
+ 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
+ if (!approval.scope && !installed) mode = await prepareWebsite(root, paired.connection, manifest, framework, io);
420
+ if (mode === 'settings-only') return runSettingsOnly(root, paired, manifest, options, io);
421
+
422
+ const adapter = setupIo(io, paired.serverKey, options.details === true);
423
+ try {
424
+ if (saved && installed) {
425
+ io.write('Saving the new website key…\n');
426
+ if (!(await runServerKey(root, adapter, { validateKey: true })).saved) fail('The website key was not saved.');
427
+ } else {
428
+ const result = await runSetup(root, { originalRoutesOnly: true, ...Object.fromEntries(['apiOrigin', 'siteId', 'environmentId', 'locale', 'websiteOrigin', 'adminOrigin'].map(name => [name, paired.connection[name]])) }, adapter);
429
+ if (!result.saved) { io.write('Cancelled. Nothing was changed. The approval expires on its own in 10 minutes.\n'); return { connected: false }; }
430
+ if (result.settingsInstalled === false) {
431
+ io.write(adapter.log.splice(0).join(''));
432
+ fail('Your pages were connected, but the header/footer part could not be finished automatically. Ask your developer to review the messages above.');
433
+ }
434
+ }
435
+ } catch (error) {
436
+ // The reviewed setup explains blockers in technical detail; show it on failure only.
437
+ const technical = adapter.log.splice(0).join('').trim();
438
+ if (technical && !options.details) io.write(`\nDetails for your developer:\n${technical}\n\n`);
439
+ throw error;
440
+ }
441
+ io.write('✔ Website files and private key saved\n');
442
+ return goLive(root, paired.connection, await manifestOf(root), io);
443
+ }
444
+
445
+ /** Ask the running website's settings endpoint, as the CMS admin does. */
446
+ async function settingsReady(connection, io) {
447
+ try {
448
+ const url = new URL('/api/universalcms/settings', connection.websiteOrigin);
449
+ url.search = new URLSearchParams({ check: 'connection', siteId: connection.siteId, environmentId: connection.environmentId }).toString();
450
+ const response = await io.fetch(url, { headers: { Origin: connection.adminOrigin, Accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(10000) });
451
+ const body = response.ok ? await response.json() : null;
452
+ return body?.mode === 'settings-only' && body.siteId === connection.siteId && body.environmentId === connection.environmentId;
453
+ } catch { return false; }
454
+ }
455
+
456
+ async function finishSettingsOnly(root, connection, manifest, io) {
457
+ if (!await settingsReady(connection, io)) {
458
+ const local = /^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|$)/.test(connection.websiteOrigin), command = await restartHint(root, manifest);
459
+ io.write(local
460
+ ? `\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`
461
+ : `\n→ Deploy your website the way you normally do. It must be reachable at ${connection.websiteOrigin}\n`);
462
+ io.write(` Waiting for ${connection.websiteOrigin} … (Ctrl+C stops waiting; run this command again later to continue)\n`);
463
+ const deadline = io.now() + WEBSITE_WAIT_MS;
464
+ let ready = false;
465
+ while (!ready && io.now() < deadline) { await io.sleep(3000); ready = await settingsReady(connection, io); }
466
+ 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
+ }
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, branding, analytics, scripts, cookie consent and redirects apply automatically.\n');
471
+ io.write(' • To show the CMS menu in your own header or footer, add one line where it should appear:\n');
472
+ io.write(" import { CmsNavigation } from '@universalcms/connect/react-website';\n <CmsNavigation location=\"header\" /> (or location=\"footer\")\n");
473
+ io.write(' Other drop-ins: <CmsBranding />, <CmsSocialLinks />, and useWebsiteSettings() for anything custom.\n');
474
+ return { connected: true, mode: 'settings-only', done: true };
475
+ }
476
+
477
+ /** Install the settings-only integration and save the website key privately. */
478
+ async function runSettingsOnly(root, paired, manifest, options, io) {
479
+ const plan = await planSettingsOnly(root, Object.fromEntries(['apiOrigin', 'siteId', 'environmentId', 'locale', 'websiteOrigin', 'adminOrigin'].map(name => [name, paired.connection[name]])));
480
+ const environment = await planEnvironment(root, { ...Object.fromEntries(['apiOrigin', 'siteId', 'environmentId', 'locale', 'websiteOrigin', 'adminOrigin'].map(name => [name, paired.connection[name]])), apiKey: paired.serverKey });
481
+ const added = plan.files.filter(file => file.state === 'create').length, changed = plan.files.filter(file => file.state === 'update').length;
482
+ io.write(`\nReady to connect your website for site settings:\n • ${added} file${added === 1 ? '' : 's'} will be added${changed ? ` and ${changed} updated (your root layout gets one wrapper)` : ''} — your pages and design stay the same\n • The website key is saved privately in .env.local (never committed to Git)\n`);
483
+ if (options.details) for (const file of plan.files) io.write(` ${file.state}: ${file.path}\n`);
484
+ else io.write(' (run with --details to list every file)\n');
485
+ if (!yes(await io.ask('\nGo ahead? [Y/n] '))) { io.write('Cancelled. Nothing was changed.\n'); return { connected: false }; }
486
+ const current = await planSettingsOnly(root, plan.connection);
487
+ if (JSON.stringify(current.files) !== JSON.stringify(plan.files)) fail('Your website files changed while reviewing. Nothing was saved; run the same command again.');
488
+ await applyEnvironment(environment);
489
+ await applyPlan(plan);
490
+ io.write('✔ Website files and private key saved\n');
491
+ return finishSettingsOnly(root, paired.connection, await manifestOf(root), io);
492
+ }
493
+
494
+ /** Process-backed effects for the CLI. */
495
+ export function processIo({ ask, write }) {
496
+ return {
497
+ ask, write, env: process.env, fetch: globalThis.fetch, checkPublication,
498
+ sleep: ms => new Promise(resolve => setTimeout(resolve, ms)), now: () => Date.now(),
499
+ openUrl: url => new Promise(resolve => {
500
+ // Fixed launcher per platform with the URL as a single argument; never a shell string.
501
+ const [command, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]] : ['xdg-open', [url]];
502
+ try {
503
+ const child = spawn(command, args, { stdio: 'ignore', detached: true });
504
+ child.once('error', () => resolve(false)); child.once('spawn', () => { child.unref(); resolve(true); });
505
+ } catch { resolve(false); }
506
+ }),
507
+ run: (command, args, cwd) => new Promise(resolve => {
508
+ // Windows package managers are .cmd shims, which Node only starts through a shell.
509
+ // The spec is validated above; arguments contain no shell metacharacters.
510
+ const child = spawn(process.platform === 'win32' ? `${command}.cmd` : command, args, { cwd, stdio: 'inherit', shell: process.platform === 'win32' });
511
+ child.once('error', () => resolve(1)); child.once('exit', code => resolve(code ?? 1));
512
+ }),
513
+ };
514
+ }
@@ -0,0 +1,31 @@
1
+ /** Framework dispatch belongs in the connector, never in tenant services. */
2
+ export const nativeFrameworks = Object.freeze(['vue', 'nuxt', 'angular', 'svelte', 'static']);
3
+ export const nativeFrameworkLabels = Object.freeze({ vue: 'Vue', nuxt: 'Nuxt', angular: 'Angular', svelte: 'SvelteKit', static: 'Static' });
4
+ export const isNativeFramework = value => nativeFrameworks.includes(value);
5
+ export function nativeFramework(manifest) {
6
+ const dependencies = { ...manifest?.devDependencies, ...manifest?.dependencies };
7
+ if (dependencies.nuxt) return 'nuxt';
8
+ if (dependencies['@sveltejs/kit']) return 'svelte';
9
+ if (dependencies['@angular/core']) return 'angular';
10
+ if (dependencies.vue) return 'vue';
11
+ if (!Object.keys(dependencies).some(name => name !== '@universalcms/connect') && dependencies['@universalcms/connect']) return 'static';
12
+ return null;
13
+ }
14
+ async function adapter(framework) {
15
+ if (framework === 'vue' || framework === 'nuxt') {
16
+ const setup = await import('./vue-setup.mjs'), source = await import('./vue-source.mjs');
17
+ return { inspect: (root, manifest) => setup.inspectVueProject(root, manifest, framework), source: (root, options) => source.inspectVueSource(root, {...options,framework}), integration: (root, options) => setup.planVueIntegration(root,{...options,framework}), content: (root, config) => source.planVueContent(root,config,undefined,framework), installed: (root, config) => setup.validateInstalledVue(root,config,framework), settings: (root, options) => setup.planVueSettings(root,{...options,framework}) };
18
+ }
19
+ const name = framework;
20
+ if (!['angular','svelte','static'].includes(name)) throw Error('Unsupported native framework.');
21
+ const setup = name === 'angular' ? await import('./angular-setup.mjs') : name === 'svelte' ? await import('./svelte-setup.mjs') : await import('./static-setup.mjs');
22
+ const source = setup;
23
+ const prefix = name[0].toUpperCase()+name.slice(1);
24
+ return { inspect: setup[`inspect${prefix}Project`], source: source[`inspect${prefix}Source`], integration: setup[`plan${prefix}Integration`], content: source[`plan${prefix}Content`], installed: setup[`validateInstalled${prefix}`], settings: setup[`plan${prefix}Settings`] };
25
+ }
26
+ export async function inspectNativeProject(root,framework,manifest) { return (await adapter(framework)).inspect(root,manifest); }
27
+ export async function inspectNativeSource(root,framework,options) { return (await adapter(framework)).source(root,options); }
28
+ export async function planNativeIntegration(root,framework,options) { return (await adapter(framework)).integration(root,options); }
29
+ export async function planNativeContent(root,framework,config) { return (await adapter(framework)).content(root,config); }
30
+ export async function validateInstalledNative(root,framework,config) { return (await adapter(framework)).installed(root,config); }
31
+ export async function planNativeSettings(root,framework,options) { return (await adapter(framework)).settings(root,options); }
@@ -0,0 +1,3 @@
1
+ export type NativeChromeDescriptor = {version:1; slots:string[]; exclude:{header:string[];footer:string[]}};
2
+ export const NATIVE_CHROME_SLOTS: readonly string[];
3
+ export function nativeChromeDescriptor(value: unknown): NativeChromeDescriptor;
@@ -0,0 +1,14 @@
1
+ /** Public structural contract only. No frozen native text or credential crosses it. */
2
+ import { publicHref } from './website-model.mjs';
3
+ export const NATIVE_CHROME_SLOTS = Object.freeze(['branding','header-navigation','mobile-navigation','languages','footer-navigation','social','experience']);
4
+ export function nativeChromeDescriptor(value) {
5
+ const fail = () => { throw Error('Reviewed native chrome is unavailable.'); };
6
+ if (!value || value.version !== 1 || !Array.isArray(value.slots) || JSON.stringify(value.slots) !== JSON.stringify(NATIVE_CHROME_SLOTS) || !value.exclude || Object.keys(value).some(key => !['version','slots','exclude'].includes(key)) || Object.keys(value.exclude).some(key => !['header','footer'].includes(key))) fail();
7
+ const exclude = {};
8
+ for (const name of ['header','footer']) {
9
+ const links = value.exclude[name];
10
+ if (!Array.isArray(links) || links.length > 100 || new Set(links).size !== links.length || links.some(link => typeof link !== 'string' || link.length > 2000 || publicHref(link, {mail:true}) !== link)) fail();
11
+ exclude[name] = [...links];
12
+ }
13
+ return {version:1, slots:[...NATIVE_CHROME_SLOTS], exclude};
14
+ }