@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,287 @@
1
+ import { lstat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { applyPlan, CONFIG_FILE, planSetup, projectRoot, readProjectFile, SetupError, validateConfig, validatePlan } from './core.mjs';
4
+ import { applyEnvironment, assertDeliveryKeyValid, assertEnvironmentBinding, planEnvironment, readSetupEnvironment, validateEnvironmentPlan } from './setup-environment.mjs';
5
+ import { readSourceSettings } from './source-setup.mjs';
6
+ import { planInstalledPublication, readPublicationConfiguration } from './publication-setup.mjs';
7
+ import { assertRuntimeDependencies } from './runtime-dependencies.mjs';
8
+ import { assertConnectorRuntime } from './connector-runtime.mjs';
9
+ import { inspectSetupFramework } from './framework.mjs';
10
+
11
+ export const CONNECTION_FILE = '.universalcms/connection.json';
12
+ const fields = ['apiOrigin', 'siteId', 'environmentId', 'locale', 'websiteOrigin', 'adminOrigin'];
13
+ const authority = ['apiOrigin', 'siteId', 'environmentId', 'locale'];
14
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
15
+ const key = /^cms_server\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[A-Za-z0-9_-]{43}$/;
16
+ const json = value => `${JSON.stringify(value, null, 2)}\n`;
17
+ const fail = message => { throw new SetupError(message); };
18
+ function origin(value) {
19
+ try {
20
+ if (typeof value !== 'string' || value.length > 2048 || /[\s\\]|cms_server\.|sk-[A-Za-z0-9_-]{12,}|AIza[A-Za-z0-9_-]{20,}/i.test(value)) return null;
21
+ const url = new URL(value);
22
+ if (url.username || url.password || url.search || url.hash || url.pathname !== '/' || !(url.protocol === 'https:' || url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) return null;
23
+ return url.origin;
24
+ } catch { return null; }
25
+ }
26
+ function locale(value) { try { return typeof value === 'string' && value.length <= 35 ? Intl.getCanonicalLocales(value)[0] : null; } catch { return null; } }
27
+ const validators = {
28
+ apiOrigin: origin, siteId: value => typeof value === 'string' && uuid.test(value) && value.toLowerCase(),
29
+ environmentId: value => typeof value === 'string' && uuid.test(value) && value.toLowerCase(),
30
+ locale, websiteOrigin: origin, adminOrigin: origin,
31
+ };
32
+ function connectionMismatch(root, savedFile, saved, requested, names = fields) {
33
+ const labels = { apiOrigin: 'CMS API origin', siteId: 'Website ID', environmentId: 'Environment ID', locale: 'Content locale', websiteOrigin: 'Website origin', adminOrigin: 'CMS admin origin' };
34
+ // Only independently validated public values reach the diagnostic. Never
35
+ // include raw config, private environment values, credentials or URLs with
36
+ // userinfo/query strings, even when a malformed file caused the mismatch.
37
+ const values = names.map(name => [name, validators[name](saved[name]), validators[name](requested[name])]);
38
+ if (values.some(([, before, after]) => !before || !after)) fail('This website folder has a different saved connection. Open the matching website project and use its Developer setup commands. Existing settings were preserved.');
39
+ const describe = index => values.map(value => ` ${labels[value[0]]}: ${value[index]}`).join('\n');
40
+ fail(`Website connection mismatch. These commands do not match this folder's saved website.\nProject folder: ${JSON.stringify(root)}\nSaved public settings (${savedFile}):\n${describe(1)}\nRequested command:\n${describe(2)}\nOpen the project folder for the requested website and run its setup commands there. If you intended to set up this existing website, copy the commands from its matching website and environment in UniversalWeb.\nThis check preserved existing settings. Changing a key or restarting this folder cannot resolve a website or port mismatch.`);
41
+ }
42
+ export function validateConnection(input) {
43
+ if (!input || typeof input !== 'object' || Array.isArray(input) || input.version !== 1 || Object.keys(input).some(name => !['version', ...fields].includes(name))) fail('The saved connection must contain only supported public configuration. Credentials are never accepted here.');
44
+ const result = { version: 1 };
45
+ for (const name of fields) {
46
+ const value = validators[name](input[name]);
47
+ if (!value) fail('Use valid public connection origins, website/environment UUIDs and content locale from Developer setup.');
48
+ result[name] = value;
49
+ }
50
+ return result;
51
+ }
52
+ export async function readConnection(directory) {
53
+ const root = await projectRoot(directory), content = await readProjectFile(root, CONNECTION_FILE);
54
+ if (content === null) return null;
55
+ if ((await lstat(path.join(root, CONNECTION_FILE))).nlink !== 1) fail('The saved connection must be an ordinary file without hard links.');
56
+ let value;
57
+ try { value = validateConnection(JSON.parse(content)); }
58
+ catch { fail('The saved public connection is invalid. Review .universalcms/connection.json before continuing.'); }
59
+ if (json(value) !== content) fail('The saved public connection was edited. Review .universalcms/connection.json before continuing.');
60
+ return value;
61
+ }
62
+ export async function assertConnectionMatches(directory, connection) {
63
+ const root = await projectRoot(directory), value = validateConnection(connection);
64
+ const configText = await readProjectFile(root, CONFIG_FILE);
65
+ let config = null;
66
+ if (configText !== null) {
67
+ try { config = validateConfig(JSON.parse(configText)); }
68
+ catch { fail('The generated delivery configuration needs manual review before continuing.'); }
69
+ if (authority.some(name => config[name] !== value[name])) connectionMismatch(root, CONFIG_FILE, config, value, authority);
70
+ }
71
+ const source = await readSourceSettings(root);
72
+ if (Object.keys(source).length && fields.filter(name => name !== 'apiOrigin').some(name => source[name] !== value[name])) connectionMismatch(root, '.universalcms/source.config.json', source, value, fields.filter(name => name !== 'apiOrigin'));
73
+ const saved = await assertEnvironmentBinding(root, value);
74
+ const publication = await readPublicationConfiguration(root);
75
+ if (publication && (['apiOrigin', 'siteId', 'environmentId', 'websiteOrigin'].some(name => publication[name] !== value[name]) || publication.routes.some(route => route.locale !== value.locale))) fail('Original-route publication configuration differs from this connection. Review it before changing scope or origins.');
76
+ return { config, configText, source, publication, keyConfigured: saved.keyConfigured };
77
+ }
78
+ export async function assertSavedConnection(directory, connection) {
79
+ const current = await readConnection(directory);
80
+ if (!current || json(current) !== json(connection)) fail('The saved connection changed during setup. Review it and run the command again.');
81
+ return assertConnectionMatches(directory, connection);
82
+ }
83
+ async function validateSavedRuntime(plan) {
84
+ await validatePlan(plan);
85
+ // The additive writer permits a missing unchanged file so it can recreate it.
86
+ // Saving a key does not apply that writer: every reviewed runtime file must
87
+ // still exist exactly as prepared, including during the confirmation window.
88
+ for (const file of plan.files) {
89
+ if (await readProjectFile(plan.root, file.path) !== file.content) fail('The generated delivery integration changed or is incomplete. Run setup or review the files before saving a server key.');
90
+ if ((await lstat(path.join(plan.root, file.path))).nlink !== 1) fail('The generated delivery integration contains a linked file. Review it before saving a server key.');
91
+ }
92
+ }
93
+ export async function runConnect(directory, options, io) {
94
+ const root = await projectRoot(directory);
95
+ if (!options || typeof options !== 'object' || Array.isArray(options) || Object.keys(options).some(name => !fields.includes(name))) fail('Connect accepts public connection fields only. Secrets are not accepted as options.');
96
+ let manifest;
97
+ try { manifest = JSON.parse(await readProjectFile(root, 'package.json')); } catch { /* A fixed diagnostic follows. */ }
98
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) fail('Run connect in the website folder containing a valid package.json.');
99
+ const previous = await readConnection(root), saved = await readSetupEnvironment(root);
100
+ const defaults = { ...saved.defaults, ...(previous ?? {}) };
101
+ const labels = { apiOrigin: 'CMS API origin', siteId: 'Website ID', environmentId: 'Environment ID', locale: 'Content locale', websiteOrigin: 'Website origin', adminOrigin: 'CMS admin origin' };
102
+ const entered = { version: 1 };
103
+ for (const name of fields) {
104
+ const fallback = defaults[name] ?? (name === 'locale' ? 'en' : undefined);
105
+ if (options[name] !== undefined) {
106
+ const value = validators[name](options[name]);
107
+ if (!value) fail('Use valid public connection origins, website/environment UUIDs and content locale from Developer setup.');
108
+ entered[name] = value;
109
+ continue;
110
+ }
111
+ while (true) {
112
+ const answer = (await io.ask(`${labels[name]}${fallback ? ` [${fallback}]` : ''}: `)).trim();
113
+ const value = validators[name](answer || fallback || '');
114
+ if (value) { entered[name] = value; break; }
115
+ io.write('Enter the valid public value from Developer setup. Do not enter a credential.\n');
116
+ }
117
+ }
118
+ const connection = validateConnection(entered);
119
+ if (previous && json(previous) !== json(connection)) connectionMismatch(root, CONNECTION_FILE, previous, connection);
120
+ await assertConnectionMatches(root, connection);
121
+ const plan = { root, files: [{ path: CONNECTION_FILE, content: json(connection), state: previous ? 'unchanged' : 'create' }] };
122
+ io.write(`Website: ${connection.siteId}\nEnvironment: ${connection.environmentId}\nAPI: ${connection.apiOrigin}\nWebsite origin: ${connection.websiteOrigin}\nCMS admin: ${connection.adminOrigin}\nLocale: ${connection.locale}\n`);
123
+ io.write('Save these public values for the setup command. No server key is requested and no connection has been verified.\n');
124
+ const answer = (await io.ask('Save this connection? [Y/n] ')).trim().toLowerCase();
125
+ if (answer && !['y', 'yes'].includes(answer)) { io.write('Connection cancelled. No files changed.\n'); return { saved: false }; }
126
+ if (previous) await assertSavedConnection(root, previous);
127
+ else {
128
+ const current = await readConnection(root);
129
+ if (current && json(current) !== json(connection)) fail('The saved connection changed while reviewing. No existing file was overwritten.');
130
+ await assertConnectionMatches(root, connection);
131
+ }
132
+ await applyPlan(plan);
133
+ io.write('Public connection saved. Next run: npx --no-install universalcms-connect setup\n');
134
+ return { saved: true };
135
+ }
136
+ export async function runServerKey(directory, io, { validateKey = false } = {}) {
137
+ const root = await projectRoot(directory), connection = await readConnection(root);
138
+ if (!connection) fail('Run connect, then setup before saving a server key.');
139
+ const context = await assertConnectionMatches(root, connection);
140
+ const publicationPlan = await planInstalledPublication(root);
141
+ if (!context.config && !publicationPlan) fail('No generated delivery configuration is available. Run setup first. Source export alone does not require a delivery key.');
142
+ const runtimePlan = context.config ? await planSetup(root, { apiOrigin: connection.apiOrigin, siteId: connection.siteId, environmentId: connection.environmentId, locale: connection.locale, framework: context.config.framework, mount: context.config.mount }) : publicationPlan;
143
+ if (runtimePlan.files.some(file => file.state !== 'unchanged')) fail('The generated delivery integration is incomplete. Run setup before saving a server key, or review custom integration files manually.');
144
+ await validateSavedRuntime(runtimePlan);
145
+ if (publicationPlan && publicationPlan !== runtimePlan) await validateSavedRuntime(publicationPlan);
146
+ const assertPublicationRuntime = async () => {
147
+ if (!publicationPlan) return;
148
+ const detected = await inspectSetupFramework(root);
149
+ await assertConnectorRuntime(root, detected.framework);
150
+ };
151
+ await assertPublicationRuntime();
152
+ if (context.config) await assertRuntimeDependencies(root, context.config.framework, connection.adminOrigin);
153
+ let apiKey;
154
+ while (true) {
155
+ const answer = await io.hidden(context.keyConfigured ? 'Server delivery key (hidden; Enter keeps the saved key): ' : 'Server delivery key (hidden): ');
156
+ if (!answer && context.keyConfigured) break;
157
+ if (typeof answer === 'string' && key.test(answer)) { apiKey = answer; break; }
158
+ io.write('Enter a valid server delivery key from Developer setup. It is not an AI provider key.\n');
159
+ }
160
+ if (apiKey && validateKey) {
161
+ const check = await assertDeliveryKeyValid({
162
+ apiOrigin: connection.apiOrigin,
163
+ siteId: connection.siteId,
164
+ environmentId: connection.environmentId,
165
+ apiKey,
166
+ fetcher: io.fetcher,
167
+ });
168
+ if (check.valid) {
169
+ io.write('Server key verified with CMS API.\n');
170
+ } else if (check.offline) {
171
+ io.write(`Notice: CMS API is unreachable (${check.error}). Key format is valid, but online authorization could not be confirmed.\n`);
172
+ }
173
+ }
174
+ const options = Object.fromEntries(fields.map(name => [name, connection[name]]));
175
+ const environment = await planEnvironment(root, { ...options, ...(apiKey ? { apiKey } : {}) });
176
+ apiKey = undefined;
177
+ for (const file of environment.files) io.write(` ${file.action}: ${file.path}\n`);
178
+ io.write('The server key will be saved only in Git-ignored .env.local with owner-only access. Published delivery remains unverified.\n');
179
+ const answer = (await io.ask('Save this server key? [Y/n] ')).trim().toLowerCase();
180
+ if (answer && !['y', 'yes'].includes(answer)) { io.write('Server key cancelled. No files changed.\n'); return { saved: false }; }
181
+ const current = await assertSavedConnection(root, connection);
182
+ if (current.configText !== context.configText || json(current.source) !== json(context.source) || json(current.publication) !== json(context.publication)) fail('The delivery or source configuration changed while entering the key. Review it and rerun server-key.');
183
+ await validateSavedRuntime(runtimePlan);
184
+ if (publicationPlan && publicationPlan !== runtimePlan) await validateSavedRuntime(publicationPlan);
185
+ await validateEnvironmentPlan(environment);
186
+ await assertPublicationRuntime();
187
+ if (context.config) await assertRuntimeDependencies(root, context.config.framework, connection.adminOrigin);
188
+ await applyEnvironment(environment);
189
+ io.write('Server key saved privately. Restart your website, then verify a published page with check and selected configuration access with check-settings. Existing header/footer integration was preserved. Saving a key does not verify delivery or publish content.\n');
190
+ return { saved: true };
191
+ }
192
+
193
+ // Explicit key preparation is independent of installing a renderer. It still
194
+ // binds every saved public/private setting before receiving a secret. Keep the
195
+ // normal server-key runtime checks above for the already-installed flow.
196
+ const preparationFiles = ['package.json', CONNECTION_FILE, CONFIG_FILE,
197
+ '.universalcms/source.config.json', '.universalcms/publication.config.json',
198
+ '.env.local', '.gitignore', '.env.development', '.env.development.local', '.env.production', '.env.production.local'];
199
+ async function preparationSnapshot(root) {
200
+ const result = [];
201
+ for (const filename of preparationFiles) {
202
+ let before;
203
+ try { before = await lstat(path.join(root, filename)); }
204
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
205
+ const content = await readProjectFile(root, filename);
206
+ if (!before) {
207
+ if (content !== null) fail('Connection settings changed during key preparation. Review them and retry.');
208
+ result.push([filename, null]);
209
+ continue;
210
+ }
211
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) fail('Key preparation requires ordinary configuration files without symbolic links or hard links.');
212
+ const after = await lstat(path.join(root, filename));
213
+ const metadata = stat => [stat.dev, stat.ino, stat.mode, stat.nlink, stat.size, stat.mtimeMs, stat.ctimeMs];
214
+ if (JSON.stringify(metadata(before)) !== JSON.stringify(metadata(after))) fail('Connection settings changed during key preparation. Review them and retry.');
215
+ result.push([filename, content, metadata(after)]);
216
+ }
217
+ return JSON.stringify(result);
218
+ }
219
+
220
+ /** Explicit key-first source flow. Ordinary public source setup never calls this. */
221
+ export async function prepareSourceKeyCheck(directory, options) {
222
+ const root = await projectRoot(directory), initial = await preparationSnapshot(root);
223
+ const connection = validateConnection({ version: 1, ...options });
224
+ const check = async () => {
225
+ if (await projectRoot(directory) !== root || await preparationSnapshot(root) !== initial) fail('Connection settings changed during source preparation. No source files were saved; review them and retry.');
226
+ const saved = await readConnection(root);
227
+ if (saved && json(saved) !== json(connection)) connectionMismatch(root, CONNECTION_FILE, saved, connection);
228
+ const context = await assertConnectionMatches(root, connection);
229
+ if (!context.keyConfigured) fail('Save a server delivery key first with server-key --prepare-only and the matching public connection flags, then rerun source setup.');
230
+ };
231
+ await check();
232
+ return check;
233
+ }
234
+
235
+ export async function runPrepareServerKey(directory, options, io) {
236
+ const allowed = [...fields, 'validateKey'];
237
+ if (!options || typeof options !== 'object' || Array.isArray(options) || Object.keys(options).some(name => !allowed.includes(name))) fail('Key preparation accepts public connection fields only. Secrets are not accepted as options.');
238
+ const root = await projectRoot(directory), initial = await preparationSnapshot(root);
239
+ let manifest;
240
+ try { manifest = JSON.parse(await readProjectFile(root, 'package.json')); } catch { /* Fixed diagnostic below. */ }
241
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) fail('Run key preparation in the website folder containing a valid package.json.');
242
+ const previous = await readConnection(root), saved = await readSetupEnvironment(root), source = await readSourceSettings(root);
243
+ const explicit = Object.fromEntries(Object.entries(options).filter(([name, value]) => value !== undefined && fields.includes(name)));
244
+ const connection = validateConnection({ version: 1, locale: 'en', ...saved.defaults, ...Object.fromEntries(fields.filter(name => source[name] !== undefined).map(name => [name, source[name]])), ...(previous ?? {}), ...explicit });
245
+ if (previous && json(previous) !== json(connection)) connectionMismatch(root, CONNECTION_FILE, previous, connection);
246
+ const context = await assertConnectionMatches(root, connection);
247
+ const recheck = async () => {
248
+ if (await projectRoot(directory) !== root || await preparationSnapshot(root) !== initial) fail('Connection settings changed during key preparation. No server key was saved; review them and retry.');
249
+ await assertConnectionMatches(root, connection);
250
+ };
251
+ await recheck();
252
+ io.write(`Website: ${connection.siteId}\nEnvironment: ${connection.environmentId}\nAPI: ${connection.apiOrigin}\nWebsite origin: ${connection.websiteOrigin}\nCMS admin: ${connection.adminOrigin}\nLocale: ${connection.locale}\n`);
253
+ io.write('PREPARE KEY ONLY: Save private delivery settings before website integration. This does not install a renderer, verify the key, import content or publish anything. The public source endpoint never receives this key.\n');
254
+ let apiKey;
255
+ while (true) {
256
+ const answer = await io.hidden(context.keyConfigured ? 'Server delivery key (hidden; Enter keeps the saved key): ' : 'Server delivery key (hidden): ');
257
+ if (!answer && context.keyConfigured) break;
258
+ if (typeof answer === 'string' && key.test(answer)) { apiKey = answer; break; }
259
+ io.write('Enter a valid server delivery key from Developer setup. It is not an AI provider key.\n');
260
+ }
261
+ if (apiKey && options.validateKey) {
262
+ const check = await assertDeliveryKeyValid({
263
+ apiOrigin: connection.apiOrigin,
264
+ siteId: connection.siteId,
265
+ environmentId: connection.environmentId,
266
+ apiKey,
267
+ fetcher: io.fetcher,
268
+ });
269
+ if (check.valid) {
270
+ io.write('Server key verified with CMS API.\n');
271
+ } else if (check.offline) {
272
+ io.write(`Notice: CMS API is unreachable (${check.error}). Key format is valid, but online authorization could not be confirmed.\n`);
273
+ }
274
+ }
275
+ await recheck();
276
+ const environment = await planEnvironment(root, { ...Object.fromEntries(fields.map(name => [name, connection[name]])), ...(apiKey ? { apiKey } : {}) });
277
+ apiKey = undefined;
278
+ for (const file of environment.files) io.write(` ${file.action}: ${file.path}\n`);
279
+ io.write('The server key will be saved only in Git-ignored .env.local with owner-only access. Key authorization and published rendering remain unverified.\n');
280
+ const answer = (await io.ask('Save this server key? [Y/n] ')).trim().toLowerCase();
281
+ if (answer && !['y', 'yes'].includes(answer)) { io.write('Server key cancelled. No files changed.\n'); return { saved: false }; }
282
+ await recheck();
283
+ await validateEnvironmentPlan(environment);
284
+ await applyEnvironment(environment);
285
+ io.write('Server key saved privately. Existing website integration files were preserved. Saving a key does not install missing endpoints, content bindings or header/footer integration. If integration is incomplete, resolve the scoped preflight blockers first. For a custom adapter, follow its reviewed installation and validation steps; do not rerun generic setup over custom files. After the complete adapter is installed, restart or deploy the website to load the adapter and key. Then run npx --no-install universalcms-connect check-publication and npx --no-install universalcms-connect check-settings with the matching public scope from Developer setup, and verify the actual pages. Key authorization and published rendering remain unverified; no content was published.\n');
286
+ return { saved: true };
287
+ }
@@ -0,0 +1,67 @@
1
+ import { createServer } from 'node:http';
2
+ import { lstat, readFile, realpath } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { createAstroRuntime, astroBound } from './astro-runtime.mjs';
6
+ import { renderNativeWebsite } from './native-website-server.mjs';
7
+ const escape=value=>String(value??'').replace(/[&<>"']/g,char=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[char]));
8
+ export function renderStaticPage(context,snapshot){
9
+ const payload={pathname:context.pathname,scope:context.scope,initialResult:context.result,initialSettings:context.settings,...(context.publicInventory?{inventory:context.publicInventory}:{})};
10
+ const template=snapshot.templates?.[context.pathname];
11
+ if(!template)return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body>${renderNativeWebsite(payload)}<script type="module" src="/.well-known/universalcms-native.mjs"></script></body></html>`;
12
+ let html=template.body;
13
+ for(const slot of template.slots){
14
+ const block=astroBound(context,slot.id,slot.type);let value='';
15
+ if(block){
16
+ if(slot.type==='image')value=slot.opening.replace(/\s*\/?>$/,` src="${escape(block.url)}" alt="${escape(block.alt)}">`);
17
+ else value=slot.opening+(slot.type==='list'?block.text.split('\n').map(text=>slot.itemOpening+escape(text)+'</li>').join(''):escape(block.text))+slot.closing;
18
+ }
19
+ html=html.replace(`<!--universalcms:block:${slot.id}-->`,()=>value);
20
+ }
21
+ return template.prefix+renderNativeWebsite(payload,html)+'<script type="module" src="/.well-known/universalcms-native.mjs"></script>'+template.suffix;
22
+ }
23
+ const types={'.css':'text/css','.png':'image/png','.jpg':'image/jpeg','.jpeg':'image/jpeg','.gif':'image/gif','.webp':'image/webp','.svg':'image/svg+xml','.ico':'image/x-icon','.woff':'font/woff','.woff2':'font/woff2','.ttf':'font/ttf','.otf':'font/otf','.pdf':'application/pdf'};
24
+ export async function startStaticWebsite({root}){
25
+ root=await realpath(root);const load=async name=>JSON.parse(await readFile(path.join(root,'.universalcms',name),'utf8'));
26
+ const optional=async name=>{try{return await load(name);}catch(error){if(error.code==='ENOENT')return undefined;throw error;}};
27
+ const sourceConfiguration=await load('source.config.json'),snapshot=await load('source.generated.json');
28
+ const configuration=await optional('publication.config.json'),contentBindings=await optional('content.bindings.json'),originalPages=new Map();
29
+ if((configuration===undefined)!==(contentBindings===undefined))throw Error('Static publication configuration or content bindings are missing. Repair the installed integration before starting.');
30
+ if(configuration===undefined){
31
+ if(await optional('website.integration.json')!==undefined)throw Error('Static publication configuration is missing. Repair the installed integration before starting.');
32
+ // Only standalone source export retains the original public website. Once
33
+ // publication owns these routes, missing delivery must never restore it.
34
+ const {inspectStaticSource}=await import('./static-setup.mjs');
35
+ const source=await inspectStaticSource(root,{locale:sourceConfiguration.locale,websiteOrigin:sourceConfiguration.websiteOrigin});
36
+ if(source.prepared.some(item=>item.installed)||JSON.stringify(source.snapshot)!==JSON.stringify(snapshot))throw Error('Static source changed. Run sync-source before starting.');
37
+ for(const item of source.prepared)originalPages.set(item.path,item.original);
38
+ }
39
+ const runtime=createAstroRuntime({configuration,contentBindings,sourceConfiguration,snapshot,sourceRoot:root}),origin=new URL(sourceConfiguration.websiteOrigin),port=Number(process.env.PORT||(origin.port|| (origin.protocol==='https:'?443:80)));
40
+ if(!Number.isInteger(port)||port<1||port>65535)throw Error('Invalid website port.');
41
+ const bridge=await readFile(fileURLToPath(new URL('./native-website.mjs',import.meta.url)));
42
+ const server=createServer({requestTimeout:15000,headersTimeout:10000,maxHeaderSize:8192},async(req,res)=>{
43
+ try{
44
+ if(!req.url?.startsWith('/')||req.url.startsWith('//')||/[\\\s#]|%(?:2e|2f|5c|25|3f|23)/i.test(req.url.split('?')[0])||req.url.split('?')[0].split('/').some(part=>part==='.'||part==='..')||req.headers.host!==origin.host){res.writeHead(400);res.end();return;}
45
+ const url=new URL(req.url,origin),request=new Request(url,{method:req.method,headers:new Headers(Object.entries(req.headers).flatMap(([key,value])=>value===undefined?[]:[[key,Array.isArray(value)?value.join(', '):value]]))});
46
+ let response;
47
+ if(['GET','HEAD'].includes(req.method)&&!url.search&&url.pathname==='/.well-known/universalcms-native.mjs')response=new Response(bridge,{headers:{'Content-Type':'text/javascript','Cache-Control':'no-cache'}});
48
+ else {
49
+ const context={request,locals:{}};
50
+ response=await runtime.onRequest(context,async()=>{
51
+ if(context.locals.universalcms)return new Response(renderStaticPage(context.locals.universalcms,snapshot),{headers:{'Content-Type':'text/html; charset=utf-8'}});
52
+ const original=originalPages.get(url.pathname==='/'?'/':url.pathname.replace(/\/$/,''));
53
+ if(['GET','HEAD'].includes(req.method)&&!url.search&&original!==undefined)return new Response(original,{headers:{'Content-Type':'text/html; charset=utf-8','Cache-Control':'no-store'}});
54
+ const asset=url.pathname.slice(1);
55
+ if(['GET','HEAD'].includes(req.method)&&!url.search&&snapshot.assets?.includes(asset)){
56
+ let filename=root;for(const part of asset.split('/')){filename=path.join(filename,part);const info=await lstat(filename);if(info.isSymbolicLink()||await realpath(filename)!==filename)throw Error('Invalid asset');}
57
+ const info=await lstat(filename);if(!info.isFile()||info.nlink!==1||info.size>16*1024*1024||!types[path.extname(filename)])throw Error('Invalid asset');
58
+ return new Response(await readFile(filename),{headers:{'Content-Type':types[path.extname(filename)],'Cache-Control':'no-cache'}});
59
+ }
60
+ return new Response('Not found',{status:404});
61
+ });
62
+ }
63
+ res.writeHead(response.status,Object.fromEntries(response.headers));res.end(req.method==='HEAD'?undefined:Buffer.from(await response.arrayBuffer()));
64
+ }catch{if(!res.headersSent)res.writeHead(503,{'Cache-Control':'no-store'});res.end('Website temporarily unavailable.');}
65
+ });
66
+ server.listen(port,'0.0.0.0');return server;
67
+ }
@@ -0,0 +1,136 @@
1
+ /** Bounded literal HTML adapter with a private Node host. */
2
+ import path from 'node:path';
3
+ import { lstat, readdir, realpath } from 'node:fs/promises';
4
+ import { parse } from './vendor/astro-compiler/node/index.js';
5
+ import { projectRoot, readProjectFile, SetupError } from './core.mjs';
6
+ import { extractAstroPage } from './astro-source.mjs';
7
+ import { CONTENT_BINDINGS_FILE, contentDigest, readContentRegistry, readFrozenContent, validateContentRegistry } from './content-registry.mjs';
8
+ import { WEBSITE_SECTIONS } from './website-http.mjs';
9
+ import { isExcludedPublicationPath } from './publication.mjs';
10
+ import {readNativeChromeReview,reviewedNativeChrome,nativeChromeObservation} from './native-chrome-review.mjs';
11
+ const json = value => JSON.stringify(value,null,2)+'\n';
12
+ const fail = reason => { throw new SetupError('Static HTML setup needs review: '+reason+'. Existing files were preserved.'); };
13
+ const extensions = new Set(['.css','.png','.jpg','.jpeg','.webp','.gif','.svg','.ico','.woff','.woff2','.ttf','.otf','.pdf']);
14
+ const ignored = new Set(['node_modules','.git','.universalcms']);
15
+ async function read(root,name) {
16
+ const value=await readProjectFile(root,name);
17
+ if(value!==null){const info=await lstat(path.join(root,name));if(info.nlink!==1||info.size>128*1024)fail('linked or oversized source');}
18
+ return value;
19
+ }
20
+ async function inventory(root) {
21
+ const pages=[],assets=[];let nodes=0;
22
+ async function walk(directory,depth=0){
23
+ if(depth>12)fail('directory depth exceeds limit');
24
+ for(const item of await readdir(path.join(root,directory),{withFileTypes:true})){
25
+ if(++nodes>5000)fail('file count exceeds limit');
26
+ if(depth===0&&(ignored.has(item.name)||item.name.startsWith('.')))continue;
27
+ const name=directory?directory+'/'+item.name:item.name;
28
+ if(item.isSymbolicLink()||item.name.startsWith('.')||/[\\%?#:\u0000-\u0020]/.test(item.name)||await realpath(path.join(root,name))!==path.join(root,name))fail('linked or unsafe public paths');
29
+ const stem=(item.isDirectory()?item.name:path.basename(item.name,path.extname(item.name))).toLowerCase();
30
+ if(/^(?:private|internal)(?:-|$)/.test(stem)||!['assets','static'].includes(stem)&&/^[a-z0-9_.@-]+$/.test(stem)&&isExcludedPublicationPath('/'+stem))fail('private or reserved public paths');
31
+ if(item.isDirectory()){await walk(name,depth+1);continue;}
32
+ const stat=await lstat(path.join(root,name));if(!stat.isFile()||stat.nlink!==1)fail('linked public files');
33
+ if(item.name==='index.html'&&(depth===0||directory.split('/').every(part=>/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(part)))){
34
+ const pathname=directory?'/'+directory:'/';if(isExcludedPublicationPath(pathname))fail('private or reserved page routes');
35
+ pages.push({path:pathname,kind:'page',slug:directory||'home',sourceFile:name});
36
+ }
37
+ else if(extensions.has(path.extname(item.name))){if(stat.size>16*1024*1024)fail('asset size limit');assets.push(name);}
38
+ else if(depth===0&&['package.json','package-lock.json','pnpm-lock.yaml','yarn.lock','README.md','AGENTS.md'].includes(item.name))continue;
39
+ else fail('automatic HTML hosting accepts directory index.html pages and ordinary assets; scripts, other routes and application files need a native adapter');
40
+ }
41
+ }
42
+ await walk('');
43
+ if(!pages.some(row=>row.path==='/')||pages.length>1000)fail('a root index.html and bounded page inventory are required');
44
+ return {pages:pages.sort((a,b)=>a.path.localeCompare(b.path)),assets:assets.sort()};
45
+ }
46
+ export async function inspectStaticProject(directory){
47
+ const root=await projectRoot(directory),packageText=await read(root,'package.json');let manifest;
48
+ try{manifest=JSON.parse(packageText);}catch{fail('valid package.json required');}
49
+ if(manifest.workspaces||manifest.type&&manifest.type!=='module'||Object.keys({...manifest.devDependencies,...manifest.dependencies}).some(name=>name!=='@universalcms/connect'))fail('this folder is not a literal HTML project');
50
+ const scripts={...manifest.scripts};
51
+ for(const name of ['dev','start','preview']){
52
+ const command='node .universalcms/static.serve.mjs';if(scripts[name]!==undefined&&scripts[name]!==command)fail('custom startup command');scripts[name]=command;
53
+ }
54
+ const build='universalcms-connect sync-source';if(scripts.build!==undefined&&scripts.build!==build)fail('custom build command');scripts.build=build;
55
+ for(const name of ['dev','start','preview']){const hook='pre'+name;if(scripts[hook]!==undefined&&scripts[hook]!==build)fail('custom lifecycle hook');scripts[hook]=build;}
56
+ return {root,manifest,packageText,nextPackage:{...manifest,type:'module',scripts},...await inventory(root)};
57
+ }
58
+ async function template(source,slug,locale,websiteOrigin,allowNativeChrome=false){
59
+ if(/\{|\}|---|universalcms:block|<(?:script|template|slot)\b/i.test(source.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi,'')))fail('dynamic HTML or reserved markers');
60
+ const extracted=await extractAstroPage({source,slug,locale,websiteOrigin,allowNativeChrome});
61
+ if(extracted.imports.length||!/<html\b/.test(source)||!/<head\b/.test(source)||!/<body\b/.test(source))fail('complete literal HTML document required');
62
+ const {ast}=await parse(source,{position:true}),offsets=new Map([[0,0]]);let bytes=0,chars=0,cursor=0,bodyStart,bodyEnd;const slots=[],edits=[];
63
+ for(const character of source){bytes+=Buffer.byteLength(character);chars+=character.length;offsets.set(bytes,chars);}
64
+ const startOf=node=>{let start=offsets.get(node.position?.start?.offset);if(source[start]!=='<'&&source[start-1]==='<')start--;if(!Number.isInteger(start)||!source.startsWith('<'+node.name,start))fail('ambiguous source position');return start;};
65
+ const openEndOf=start=>{let quote=null;for(let i=start+1;i<source.length;i++){const c=source[i];if(quote){if(c===quote)quote=null;}else if(c==='"'||c==="'")quote=c;else if(c==='>')return i+1;}fail('incomplete opening tag');};
66
+ const visit=node=>{
67
+ if(node.type!=='element'){for(const child of node.children??[])visit(child);return;}
68
+ if(['head','style'].includes(node.name)||extracted.inventory.nativeChrome&&['header','footer'].includes(node.name))return;
69
+ if(node.name==='body'){if(bodyStart!==undefined)fail('ambiguous body');bodyStart=openEndOf(startOf(node));bodyEnd=offsets.get(node.position?.end?.offset)-7;if(!source.startsWith('</body>',bodyEnd))fail('incomplete body');}
70
+ if(!/^(?:h[1-6]|p|ul|ol|img)$/.test(node.name)){for(const child of node.children??[])visit(child);return;}
71
+ const from=startOf(node),openEnd=openEndOf(from);
72
+ const to=node.name==='img'?openEnd:offsets.get(node.position?.end?.offset),block=extracted.entry.document.blocks[cursor++];
73
+ if(!block||!Number.isInteger(to)||to<=from)fail('ambiguous editorial range');
74
+ let opening=source.slice(from,openEnd),closing=node.name==='img'?'':`</${node.name}>`;
75
+ if(block.type==='image'){
76
+ const ranges=node.attributes.filter(attr=>['src','alt'].includes(attr.name)).map(attr=>{
77
+ const at=offsets.get(attr.position?.start?.offset),prefix=new RegExp(`^${attr.name}\\s*=\\s*`).exec(source.slice(at));
78
+ if(!Number.isInteger(at)||!prefix||attr.kind!=='quoted'||!['"',"'"].includes(attr.raw?.[0])||source.slice(at+prefix[0].length,at+prefix[0].length+attr.raw.length)!==attr.raw)fail('ambiguous image attribute');
79
+ return {start:at-from,end:at-from+prefix[0].length+attr.raw.length};
80
+ });
81
+ for(const range of ranges.sort((a,b)=>b.start-a.start))opening=opening.slice(0,range.start)+opening.slice(range.end);
82
+ }
83
+ else if(block.type!=='list'&&node.children.some(child=>!['text','comment'].includes(child.type)))fail('formatted text needs explicit native bindings');
84
+ let itemOpening='<li>';
85
+ if(block.type==='list') {const item=node.children.find(child=>child.type==='element'),start=startOf(item);itemOpening=source.slice(start,openEndOf(start));}
86
+ slots.push({id:block.id,type:block.type,opening,closing,...(block.type==='list'?{itemOpening}:{})});
87
+ edits.push({from,to,text:`<!--universalcms:block:${block.id}-->`});
88
+ };
89
+ visit(ast);if(cursor!==extracted.entry.document.blocks.length||!Number.isInteger(bodyStart)||!Number.isInteger(bodyEnd))fail('incomplete native bindings');
90
+ let body=source.slice(bodyStart,bodyEnd);for(const edit of edits.sort((a,b)=>b.from-a.from)){if(edit.from<bodyStart||edit.to>bodyEnd)fail('editorial range outside body');body=body.slice(0,edit.from-bodyStart)+edit.text+body.slice(edit.to-bodyStart);}
91
+ const prefix=source.slice(0,bodyStart),suffix=source.slice(bodyEnd),native=prefix+body+suffix;
92
+ return {entry:extracted.entry,inventory:extracted.inventory,native,prefix,body,suffix,slots};
93
+ }
94
+ export async function inspectStaticSource(directory,{locale='en',websiteOrigin}={}){
95
+ const project=await inspectStaticProject(directory),registry=await readContentRegistry(project.root),entries=[],inventories={},templates={},prepared=[],sourceFiles=[];
96
+ const chromeReview=await readNativeChromeReview(project.root,'static');
97
+ const routes=project.pages.map(row=>({...row,locale}));
98
+ for(const row of routes){
99
+ const installed=registry?.routes.find(item=>item.path===row.path),current=await read(project.root,row.sourceFile),frozen=installed?await readFrozenContent(project.root,installed):null;
100
+ const original=frozen?frozen.texts.original:current,extracted=await template(original,row.slug,locale,websiteOrigin,reviewedNativeChrome(chromeReview,row.sourceFile,original));
101
+ if(installed&&(current!==extracted.native||json(extracted.inventory)!==frozen.texts.inventory))fail('bound source changed');
102
+ entries.push(extracted.entry);inventories[row.path]=extracted.inventory;templates[row.path]={prefix:extracted.prefix,body:extracted.body,suffix:extracted.suffix,slots:extracted.slots};prepared.push({...row,installed,current,original,extracted});sourceFiles.push({path:row.sourceFile,sha256:contentDigest(current)});
103
+ }
104
+ if(registry&&registry.routes.length!==routes.length)fail('previously managed route removed');
105
+ if(chromeReview)sourceFiles.push({path:chromeReview.path,sha256:chromeReview.sha256});
106
+ return {root:project.root,framework:'static',chromeReview,entries,routes:routes.map(({sourceFile,...row})=>row),snapshot:{version:1,entries,inventories,templates,assets:project.assets},sourceFiles,diagnostics:{mappedPages:entries.length,mappedPosts:0},prepared};
107
+ }
108
+ const serve=`import { fileURLToPath } from 'node:url';\nimport { startStaticWebsite } from '@universalcms/connect/static-runtime';\nawait startStaticWebsite({root:fileURLToPath(new URL('../',import.meta.url))});\n`;
109
+ export async function planStaticIntegration(root,{publication=false}={}){
110
+ const project=await inspectStaticProject(root),name='.universalcms/static.serve.mjs',before=await read(root,name);
111
+ if(before!==null&&before!==serve)fail('custom static host');
112
+ const pkg=json(project.nextPackage);
113
+ return {root:project.root,framework:'static',files:[{path:name,content:serve,state:before===null?'create':'unchanged'},{path:'package.json',content:pkg,state:pkg===project.packageText?'unchanged':'update',...(pkg===project.packageText?{}:{previous:project.packageText})}]};
114
+ }
115
+ export async function planStaticContent(root,config){
116
+ const source=await inspectStaticSource(root,{locale:config.routes[0]?.locale,websiteOrigin:config.websiteOrigin});if(json(source.routes)!==json(config.routes))fail('route mappings differ');
117
+ const files=await nativeChromeObservation(root,source.chromeReview),rows=[];
118
+ for(const item of source.prepared){
119
+ if(item.installed){const frozen=await readFrozenContent(root,item.installed);for(const [prefix,content]of Object.entries(frozen.texts))files.push({path:item.installed[prefix+'File'],content,state:'unchanged'});rows.push(item.installed);continue;}
120
+ const token=contentDigest(item.path).slice(0,24),nativeFile=`.universalcms/content/${token}.native.txt`,originalFile=`.universalcms/content/${token}.original.txt`,inventoryFile=`.universalcms/content/${token}.json`,inventoryText=json(item.extracted.inventory);
121
+ for(const name of [nativeFile,originalFile,inventoryFile])if(await read(root,name)!==null)fail('unowned frozen content file');
122
+ const row={path:item.path,kind:item.kind,slug:item.slug,locale:item.locale,sourceFile:item.sourceFile,nativeFile,originalFile,inventoryFile,sourceHash:contentDigest(item.extracted.native),nativeHash:contentDigest(item.extracted.native),originalHash:contentDigest(item.original),inventoryHash:contentDigest(inventoryText)};
123
+ files.push({path:nativeFile,content:item.extracted.native,state:'create'},{path:originalFile,content:item.original,state:'create'},{path:inventoryFile,content:inventoryText,state:'create'},{path:item.sourceFile,content:item.extracted.native,state:'update',previous:item.current});rows.push(row);
124
+ }
125
+ const content=json(validateContentRegistry({version:1,routes:rows})),before=await read(root,CONTENT_BINDINGS_FILE);if(before!==null&&before!==content)fail('content registry changed');
126
+ files.push({path:CONTENT_BINDINGS_FILE,content,state:before===null?'create':'unchanged'});return {root,framework:'static',files};
127
+ }
128
+ export async function validateInstalledStatic(root,config){
129
+ const plan=await planStaticIntegration(root,{publication:true}),source=await inspectStaticSource(root,{locale:config.routes[0]?.locale,websiteOrigin:config.websiteOrigin});
130
+ if(plan.files.some(file=>file.state!=='unchanged')||json(source.routes)!==json(config.routes)||await read(root,'.universalcms/source.generated.json')!==json(source.snapshot))fail('runtime or source inventory is incomplete');
131
+ const content=await planStaticContent(root,config);if(content.files.some(file=>file.state!=='unchanged'))fail('native content bindings incomplete');return {...plan,config,files:[...plan.files,...content.files]};
132
+ }
133
+ export async function planStaticSettings(root,{config,source}){
134
+ const plan=await validateInstalledStatic(root,config),name='.universalcms/website.integration.json',content=json({version:1,framework:'static',siteId:config.siteId,environmentId:config.environmentId,apiOrigin:config.apiOrigin,websiteOrigin:config.websiteOrigin,adminOrigin:source.adminOrigin,sections:WEBSITE_SECTIONS,files:plan.files.filter(file=>file.path!=='package.json').map(file=>({path:file.path,sha256:contentDigest(file.content)}))}),before=await read(root,name);
135
+ if(before!==null&&before!==content)fail('settings ownership changed');plan.files.push({path:name,content,state:before===null?'create':'unchanged'});return {...plan,sections:[...WEBSITE_SECTIONS]};
136
+ }
@@ -0,0 +1,27 @@
1
+ import type { WebsiteResult, WebsiteSnapshot } from './website-runtime.mjs';
2
+ export type NativePublicContext = {
3
+ pathname: string;
4
+ scope: string;
5
+ result: WebsiteResult;
6
+ settings: WebsiteSnapshot;
7
+ publicInventory?: unknown;
8
+ bindings: Record<string, { text?: string; url?: string; alt?: string }>;
9
+ };
10
+ export type NativeRuntimeOptions = {
11
+ configuration?: unknown;
12
+ sourceConfiguration: unknown;
13
+ contentBindings?: unknown;
14
+ snapshot: unknown;
15
+ sourceRoot: string;
16
+ renderWebsite(value: unknown, childrenHtml?: string): string;
17
+ env?: Record<string, string | undefined> | (() => Record<string, string | undefined> | Promise<Record<string, string | undefined>>);
18
+ fetcher?: typeof fetch;
19
+ };
20
+ export function nativePublicContext(value: unknown): NativePublicContext | null;
21
+ export function renderNativeResponse(response: Response, context: NativePublicContext | null, renderWebsite: NativeRuntimeOptions['renderWebsite']): Promise<Response>;
22
+ export function createSvelteRuntime(options: NativeRuntimeOptions): {
23
+ handle<Event extends { request: Request; isDataRequest?: boolean; locals: object }>(input: {
24
+ event: Event;
25
+ resolve(event: Event): Promise<Response>;
26
+ }): Promise<Response>;
27
+ };
@@ -0,0 +1,40 @@
1
+ /** SvelteKit/Angular bridge to the reviewed request-local private runtime. */
2
+ import { createAstroRuntime, astroBound } from './astro-runtime.mjs';
3
+ const publicContext = cms => {
4
+ if(!cms)return null;
5
+ const bindings={};
6
+ for(const block of cms.inventory?.baseline.blocks??[]){const value=astroBound(cms,block.id,block.type);if(value)bindings[block.id]=value;}
7
+ return {pathname:cms.pathname,scope:cms.scope,result:cms.result,settings:cms.settings,publicInventory:cms.publicInventory,bindings};
8
+ };
9
+ export function createSvelteRuntime(options) {
10
+ const runtime=createAstroRuntime(options);
11
+ return {async handle({event,resolve}) {
12
+ // Supported native navigation performs a full document request. Svelte's
13
+ // alternate data transport must never return frozen source or bypass checks.
14
+ if(event.isDataRequest||/\/(?:\.universalcms|\.env(?:[./]|$))/.test(new URL(event.request.url).pathname))return new Response('Not found',{status:404,headers:{'Cache-Control':'no-store'}});
15
+ const context={request:event.request,locals:{}};
16
+ return runtime.onRequest(context,async()=>{
17
+ event.locals.universalcms=publicContext(context.locals.universalcms);
18
+ const response=await resolve(event);
19
+ return renderNativeResponse(response,event.locals.universalcms,options.renderWebsite);
20
+ });
21
+ }};
22
+ }
23
+ export async function renderNativeResponse(response,cms,renderWebsite) {
24
+ if(!cms||response.status!==200||!response.headers.get('content-type')?.includes('text/html'))return response;
25
+ if(typeof renderWebsite!=='function'||!response.body)throw Error('Native website renderer unavailable');
26
+ const reader=response.body.getReader(),chunks=[];let size=0,timer;
27
+ const deadline=new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('Native response timed out')),10000);});
28
+ try {for(;;){const item=await Promise.race([reader.read(),deadline]);if(item.done)break;if((size+=item.value.byteLength)>8*1024*1024)throw Error('Native response exceeded budget');chunks.push(item.value);}}
29
+ finally{clearTimeout(timer);void reader.cancel().catch(()=>{});reader.releaseLock();}
30
+ const html=new TextDecoder('utf-8',{fatal:true}).decode(Buffer.concat(chunks));
31
+ const markers=[...html.matchAll(/<universalcms-website\b[^>]*>([\s\S]*?)<\/universalcms-website>/g)];
32
+ if(markers.length!==1)throw Error('Native website host unavailable');
33
+ const payload={pathname:cms.pathname,scope:cms.scope,initialResult:cms.result,initialSettings:cms.settings,...(cms.publicInventory?{inventory:cms.publicInventory}:{})};
34
+ // Framework load/TransferState owns the public payload. Extra light-DOM nodes
35
+ // would confuse native hydration; the consumed declarative shadow root does not.
36
+ const host=renderWebsite(payload,cms.publicInventory?markers[0][1]:'').replace(/^<universalcms-website>/,markers[0][0].slice(0,markers[0][0].indexOf('>')+1)).replace(/<script type="application\/json" data-universalcms-payload>[\s\S]*?<\/script>/,'');
37
+ const headers=new Headers(response.headers);headers.delete('Content-Length');headers.delete('ETag');
38
+ return new Response(html.slice(0,markers[0].index)+host+html.slice(markers[0].index+markers[0][0].length),{status:200,headers});
39
+ }
40
+ export { publicContext as nativePublicContext };