@universalcms/connect 0.0.0-stage → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +779 -2
- package/dist/angular-runtime.d.mts +4 -0
- package/dist/angular-runtime.mjs +7 -0
- package/dist/angular-setup.mjs +124 -0
- package/dist/astro-runtime.d.mts +6 -0
- package/dist/astro-runtime.mjs +255 -0
- package/dist/astro-setup.mjs +215 -0
- package/dist/astro-source.mjs +451 -0
- package/dist/astro-website.d.mts +5 -0
- package/dist/astro-website.mjs +42 -0
- package/dist/cli.mjs +552 -0
- package/dist/connector-runtime.mjs +130 -0
- package/dist/content-next-setup.mjs +200 -0
- package/dist/content-registry-validation.mjs +40 -0
- package/dist/content-registry.mjs +44 -0
- package/dist/content-setup.mjs +13 -0
- package/dist/content-storefront-setup.mjs +155 -0
- package/dist/content-vite-setup.mjs +107 -0
- package/dist/core.mjs +205 -0
- package/dist/framework-ownership.mjs +68 -0
- package/dist/framework.mjs +54 -0
- package/dist/guided.mjs +335 -0
- package/dist/native-adapters.mjs +31 -0
- package/dist/native-chrome-model.d.mts +3 -0
- package/dist/native-chrome-model.mjs +14 -0
- package/dist/native-chrome-review.mjs +49 -0
- package/dist/native-chrome-source.mjs +69 -0
- package/dist/native-route-review.mjs +152 -0
- package/dist/native-routes.mjs +27 -0
- package/dist/native-website-model.d.mts +18 -0
- package/dist/native-website-model.mjs +67 -0
- package/dist/native-website-server.d.mts +1 -0
- package/dist/native-website-server.mjs +35222 -0
- package/dist/native-website-server.mjs.LEGAL.txt +56 -0
- package/dist/native-website.d.mts +3 -0
- package/dist/native-website.mjs +120 -0
- package/dist/native-website.mjs.LEGAL.txt +56 -0
- package/dist/next-publication.d.mts +12 -0
- package/dist/next-publication.mjs +107 -0
- package/dist/next-settings-layout.mjs +233 -0
- package/dist/next-source.d.mts +8 -0
- package/dist/next-source.mjs +93 -0
- package/dist/page-discovery.mjs +236 -0
- package/dist/page-mapping-review.mjs +160 -0
- package/dist/preflight-recovery.mjs +122 -0
- package/dist/preflight.mjs +138 -0
- package/dist/project-files.mjs +56 -0
- package/dist/public-bindings.d.mts +3 -0
- package/dist/public-bindings.mjs +28 -0
- package/dist/publication-browser.d.mts +5 -0
- package/dist/publication-browser.mjs +258 -0
- package/dist/publication-check.mjs +141 -0
- package/dist/publication-document.d.mts +2 -0
- package/dist/publication-document.mjs +59 -0
- package/dist/publication-next-setup.mjs +134 -0
- package/dist/publication-setup.mjs +138 -0
- package/dist/publication-verification.mjs +85 -0
- package/dist/publication.css +6 -0
- package/dist/publication.d.mts +31 -0
- package/dist/publication.mjs +320 -0
- package/dist/react-publication.d.mts +22 -0
- package/dist/react-publication.mjs +301 -0
- package/dist/react-website.d.mts +15 -0
- package/dist/react-website.mjs +735 -0
- package/dist/reviewed-commerce-ownership.mjs +26 -0
- package/dist/reviewed-commerce-settings-setup.mjs +243 -0
- package/dist/reviewed-native-ownership.mjs +118 -0
- package/dist/reviewed-storefront-source.mjs +227 -0
- package/dist/runtime-dependencies.mjs +59 -0
- package/dist/setup-environment.mjs +307 -0
- package/dist/source-bindings.mjs +77 -0
- package/dist/source-check.mjs +149 -0
- package/dist/source-collect.mjs +80 -0
- package/dist/source-config.mjs +20 -0
- package/dist/source-extract.mjs +514 -0
- package/dist/source-finalize.mjs +52 -0
- package/dist/source-routes.mjs +65 -0
- package/dist/source-setup.mjs +490 -0
- package/dist/staged-setup.mjs +287 -0
- package/dist/static-runtime.mjs +67 -0
- package/dist/static-setup.mjs +136 -0
- package/dist/svelte-runtime.d.mts +27 -0
- package/dist/svelte-runtime.mjs +40 -0
- package/dist/svelte-setup.mjs +135 -0
- package/dist/svelte-template.mjs +48 -0
- package/dist/templates.mjs +248 -0
- package/dist/vendor/analyze-project.mjs +82 -0
- package/dist/vendor/astro-compiler/LICENSE +53 -0
- package/dist/vendor/astro-compiler/astro.wasm +0 -0
- package/dist/vendor/astro-compiler/chunk-W5DTLHV4.js +1 -0
- package/dist/vendor/astro-compiler/node/index.js +1 -0
- package/dist/vendor/check-delivery.mjs +158 -0
- package/dist/vendor/dependency-diagnostics.mjs +72 -0
- package/dist/vendor/inspect-installed-dependencies.mjs +125 -0
- package/dist/vendor/project-analysis.mjs +498 -0
- package/dist/vendor/react-LICENSE.txt +21 -0
- package/dist/vendor/react-document.mjs +124 -0
- package/dist/vendor/react-dom-LICENSE.txt +21 -0
- package/dist/vendor/react-form.mjs +63 -0
- package/dist/vendor/react-safety.mjs +47 -0
- package/dist/vendor/source-manifest-provider.mjs +104 -0
- package/dist/vendor/typescript-LICENSE.txt +55 -0
- package/dist/vendor/typescript.cjs +200276 -0
- package/dist/vite-handler.mjs +54 -0
- package/dist/vite-publication-server.d.mts +3 -0
- package/dist/vite-publication-server.mjs +82 -0
- package/dist/vite-publication.d.mts +15 -0
- package/dist/vite-publication.mjs +241 -0
- package/dist/vite-source-discovery.mjs +315 -0
- package/dist/vite-source.d.mts +30 -0
- package/dist/vite-source.mjs +141 -0
- package/dist/vue-runtime.d.mts +10 -0
- package/dist/vue-runtime.mjs +50 -0
- package/dist/vue-server.d.mts +2 -0
- package/dist/vue-server.mjs +58 -0
- package/dist/vue-setup.mjs +127 -0
- package/dist/vue-source.mjs +168 -0
- package/dist/website-check.mjs +41 -0
- package/dist/website-http.mjs +149 -0
- package/dist/website-model.d.mts +20 -0
- package/dist/website-model.mjs +155 -0
- package/dist/website-next.d.mts +2 -0
- package/dist/website-next.mjs +29 -0
- package/dist/website-node.d.mts +3 -0
- package/dist/website-node.mjs +50 -0
- package/dist/website-runtime.d.mts +13 -0
- package/dist/website-runtime.mjs +396 -0
- package/dist/website-schema.mjs +22175 -0
- package/dist/website-setup.mjs +256 -0
- package/dist/website.css +49 -0
- package/dist/wizard.mjs +298 -0
- package/package.json +60 -4
package/dist/guided.mjs
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
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
|
+
|
|
23
|
+
export const PAIRING_WAIT_MS = 10 * 60 * 1000;
|
|
24
|
+
export const WEBSITE_WAIT_MS = 15 * 60 * 1000;
|
|
25
|
+
const codePattern = /^cms_pair\.[0-9a-f-]{36}\.[0-9a-f-]{36}\.[A-Za-z0-9_-]{43}$/;
|
|
26
|
+
const serverKey = /^cms_server\.[0-9a-f-]{36}\.[0-9a-f-]{36}\.[A-Za-z0-9_-]{43}$/;
|
|
27
|
+
const fail = message => { throw new SetupError(message); };
|
|
28
|
+
const yes = answer => ['', 'y', 'yes'].includes(answer.trim().toLowerCase());
|
|
29
|
+
// Prompts from the reviewed setup that the single plain-language review already covers.
|
|
30
|
+
const covered = new Map([
|
|
31
|
+
['Install header/footer and website settings? [Y/n] ', 'y'],
|
|
32
|
+
['Save a server key now? [Y/n] ', 'y'],
|
|
33
|
+
['Save this server key? [Y/n] ', 'y'],
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
export function cmsOrigin(value) {
|
|
37
|
+
if (typeof value !== 'string' || value.length > 2048) return null;
|
|
38
|
+
try {
|
|
39
|
+
const url = new URL(value.includes('://') ? value : `https://${value}`);
|
|
40
|
+
if (url.username || url.password || url.search || url.hash || url.pathname !== '/') return null;
|
|
41
|
+
return url.protocol === 'https:' || url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) ? url.origin : null;
|
|
42
|
+
} catch { return null; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function connectorVersion() {
|
|
46
|
+
const manifest = JSON.parse(await readFile(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'));
|
|
47
|
+
return manifest.version;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function manifestOf(root) {
|
|
51
|
+
try {
|
|
52
|
+
const value = JSON.parse(await readProjectFile(root, 'package.json'));
|
|
53
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) return value;
|
|
54
|
+
} catch { /* Fixed guidance follows. */ }
|
|
55
|
+
return fail('Run this command inside your website folder — the folder that contains package.json.');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function packageManager(root, manifest) {
|
|
59
|
+
const declared = typeof manifest.packageManager === 'string' && /^(npm|pnpm|yarn|bun)@/.exec(manifest.packageManager);
|
|
60
|
+
if (declared) return declared[1];
|
|
61
|
+
for (const [file, manager] of [['pnpm-lock.yaml', 'pnpm'], ['yarn.lock', 'yarn'], ['bun.lock', 'bun'], ['bun.lockb', 'bun']]) {
|
|
62
|
+
try { if ((await lstat(path.join(root, file))).isFile()) return manager; } catch { /* Not this manager. */ }
|
|
63
|
+
}
|
|
64
|
+
return 'npm';
|
|
65
|
+
}
|
|
66
|
+
export function installArguments(manager, spec) { return manager === 'npm' ? ['install', '--save-prod', spec] : ['add', spec]; }
|
|
67
|
+
|
|
68
|
+
/** Best guess for the address the website runs on locally; the person confirms it in the browser. */
|
|
69
|
+
export function suggestedOrigin(manifest, framework) {
|
|
70
|
+
const dev = typeof manifest.scripts?.dev === 'string' ? manifest.scripts.dev : '';
|
|
71
|
+
const port = /(?:--port|-p)[ =](\d{2,5})\b/.exec(dev)?.[1];
|
|
72
|
+
const defaults = { 'Next.js': 3000, Nuxt: 3000, React: 5173, Vue: 5173, SvelteKit: 5173, Astro: 4321, Angular: 4200 };
|
|
73
|
+
const chosen = port ? Number(port) : defaults[framework];
|
|
74
|
+
return chosen && chosen <= 65535 ? `http://localhost:${chosen}` : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function installedConnector(root) {
|
|
78
|
+
try { return JSON.parse(await readFile(path.join(root, 'node_modules', '@universalcms', 'connect', 'package.json'), 'utf8')).version ?? null; }
|
|
79
|
+
catch { return null; }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The website imports the connector at runtime, so it must be a matching production dependency. */
|
|
83
|
+
async function ensureConnector(root, manifest, io) {
|
|
84
|
+
const version = await connectorVersion();
|
|
85
|
+
if (typeof manifest.dependencies?.['@universalcms/connect'] === 'string' && await installedConnector(root) === version) return;
|
|
86
|
+
const override = io.env.UNIVERSALCMS_CONNECT_PACKAGE;
|
|
87
|
+
if (override !== undefined && !/^[^\s'"`;&|<>$]{1,2048}$/.test(override)) fail('UNIVERSALCMS_CONNECT_PACKAGE must be a package spec, file path or URL without spaces.');
|
|
88
|
+
const spec = override ?? `@universalcms/connect@${version}`;
|
|
89
|
+
const manager = await packageManager(root, manifest);
|
|
90
|
+
const args = installArguments(manager, spec);
|
|
91
|
+
io.write(`\nYour website needs the Universal CMS package. I'll run:\n ${manager} ${args.join(' ')}\n`);
|
|
92
|
+
if (!yes(await io.ask('Add it now? [Y/n] '))) fail(`Nothing was changed. When you're ready, run the same command again.`);
|
|
93
|
+
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.`);
|
|
94
|
+
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.`);
|
|
95
|
+
io.write('✔ Universal CMS package added\n');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A website that already renders its own header and footer needs a saved
|
|
100
|
+
* layout review before setup. Present that same review in plain language;
|
|
101
|
+
* when it does not apply, setup reports its own reason.
|
|
102
|
+
*/
|
|
103
|
+
async function reviewLayout(root, manifest, framework, io) {
|
|
104
|
+
const { nativeFramework } = await import('./native-adapters.mjs');
|
|
105
|
+
let plan, replan;
|
|
106
|
+
try {
|
|
107
|
+
if (nativeFramework(manifest)) {
|
|
108
|
+
const { planNativeChromeReview } = await import('./native-chrome-review.mjs');
|
|
109
|
+
replan = () => planNativeChromeReview(root);
|
|
110
|
+
} else if (framework === 'Next.js') {
|
|
111
|
+
const { planSettingsLayoutReview } = await import('./next-settings-layout.mjs');
|
|
112
|
+
replan = () => planSettingsLayoutReview(root);
|
|
113
|
+
} else return;
|
|
114
|
+
plan = await replan();
|
|
115
|
+
} catch { return; }
|
|
116
|
+
const changes = plan.files.filter(file => ['create', 'update'].includes(file.state));
|
|
117
|
+
if (!changes.length) return;
|
|
118
|
+
const sources = [...new Set((plan.slots ?? plan.pages ?? []).map(item => item.path))];
|
|
119
|
+
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`);
|
|
120
|
+
if (!yes(await io.ask('Okay? [Y/n] '))) fail('Nothing was changed. Run the same command again when you are ready.');
|
|
121
|
+
if (JSON.stringify(await replan()) !== JSON.stringify(plan)) fail('Your website files changed while reviewing. Nothing was saved; run the same command again.');
|
|
122
|
+
await applyPlan(plan);
|
|
123
|
+
io.write('✔ Header and footer reviewed\n');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function pkce() {
|
|
127
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
128
|
+
return { verifier, challenge: createHash('sha256').update(verifier).digest('base64url'), state: randomBytes(32).toString('base64url') };
|
|
129
|
+
}
|
|
130
|
+
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>`;
|
|
131
|
+
|
|
132
|
+
/** Listen on loopback for the admin to hand back a pairing code bound to this run's state. */
|
|
133
|
+
export async function waitForBrowserApproval({ cms, request, io, timeoutMs = PAIRING_WAIT_MS }) {
|
|
134
|
+
const { verifier, challenge, state } = pkce();
|
|
135
|
+
let settle;
|
|
136
|
+
const result = new Promise((resolve, reject) => { settle = { resolve, reject }; });
|
|
137
|
+
const server = createServer((req, res) => {
|
|
138
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
139
|
+
if (req.method !== 'GET' || url.pathname !== '/callback') { res.writeHead(404).end(); return; }
|
|
140
|
+
const code = url.searchParams.get('code') ?? '', returned = Buffer.from(url.searchParams.get('state') ?? ''), expected = Buffer.from(state);
|
|
141
|
+
const valid = returned.length === expected.length && timingSafeEqual(returned, expected) && codePattern.test(code);
|
|
142
|
+
res.writeHead(valid ? 200 : 400, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' });
|
|
143
|
+
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.'));
|
|
144
|
+
if (valid) settle.resolve(code);
|
|
145
|
+
});
|
|
146
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
|
147
|
+
const timer = setTimeout(() => settle.reject(new SetupError('No approval arrived within 10 minutes. Run the same command again to start over.')), timeoutMs);
|
|
148
|
+
try {
|
|
149
|
+
const { port } = server.address();
|
|
150
|
+
const query = new URLSearchParams({ callback: `http://127.0.0.1:${port}/callback`, state, challenge, ...Object.fromEntries(Object.entries(request).filter(([, value]) => value)) });
|
|
151
|
+
const link = `${cms}/connect?${query}`;
|
|
152
|
+
io.write('\nOpening your browser to approve this website…\n');
|
|
153
|
+
if (!await io.openUrl(link)) io.write('Your browser did not open. Open this link to continue:\n');
|
|
154
|
+
io.write(` ${link}\n\nWaiting for approval… (no browser on this computer? In the CMS open Developer setup → “Connect without a browser”)\n`);
|
|
155
|
+
return { code: await result, verifier };
|
|
156
|
+
} finally { clearTimeout(timer); server.close(); }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function redeem(cms, code, verifier, fetcher) {
|
|
160
|
+
let response;
|
|
161
|
+
try {
|
|
162
|
+
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) });
|
|
163
|
+
} catch { fail(`The CMS at ${cms} could not be reached. Check the address and your internet connection, then try again.`); }
|
|
164
|
+
if (response.status === 401) fail('This approval expired or was already used. Run the same command again to start over.');
|
|
165
|
+
let body;
|
|
166
|
+
try { body = await response.json(); } catch { /* Fixed guidance follows. */ }
|
|
167
|
+
if (!response.ok || !body?.data) fail(`The CMS could not finish the connection (HTTP ${response.status}). Try again in a minute.`);
|
|
168
|
+
const data = body.data;
|
|
169
|
+
const connection = validateConnection({ version: 1, apiOrigin: data.apiOrigin, siteId: data.siteId, environmentId: data.environmentId, locale: data.locale, websiteOrigin: data.websiteOrigin, adminOrigin: data.adminOrigin });
|
|
170
|
+
if (typeof data.serverKey !== 'string' || !serverKey.test(data.serverKey)) fail('The CMS returned an unexpected response. No key was saved.');
|
|
171
|
+
return { connection, siteName: typeof data.siteName === 'string' ? data.siteName.slice(0, 100) : '', serverKey: data.serverKey };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function describeRoute(route) { return route.path === '/' ? '/ (home page)' : route.path; }
|
|
175
|
+
|
|
176
|
+
/** Adapts the reviewed setup's terminal prompts: one review for the person, the key from pairing. */
|
|
177
|
+
function setupIo(io, key, details) {
|
|
178
|
+
let pending = key;
|
|
179
|
+
const log = [];
|
|
180
|
+
return {
|
|
181
|
+
log,
|
|
182
|
+
write: text => { log.push(text); if (details) io.write(text); },
|
|
183
|
+
ask: async prompt => covered.has(prompt) ? covered.get(prompt) : (io.write(log.splice(0).join('')), io.ask(prompt)),
|
|
184
|
+
hidden: async () => {
|
|
185
|
+
if (!pending) fail('The website key from the CMS was not accepted. Run the same command again.');
|
|
186
|
+
const value = pending; pending = undefined; return value;
|
|
187
|
+
},
|
|
188
|
+
review: async ({ framework, routes, files }) => {
|
|
189
|
+
const added = files.filter(file => file.state === 'create').length, changed = files.filter(file => ['update', 'append', 'replace'].includes(file.state)).length;
|
|
190
|
+
io.write(`\nReady to connect your ${framework} website:\n`);
|
|
191
|
+
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`);
|
|
192
|
+
io.write(' • Header, footer and site settings can be managed in the CMS\n');
|
|
193
|
+
io.write(` • ${added} file${added === 1 ? '' : 's'} will be added${changed ? ` and ${changed} updated` : ''} — your design stays the same\n`);
|
|
194
|
+
io.write(' • The website key is saved privately in .env.local (never committed to Git)\n');
|
|
195
|
+
if (routes.length) io.write(' ! After setup, these pages show “not found” until you publish them in the CMS.\n');
|
|
196
|
+
if (details) for (const file of files) io.write(` ${file.state}: ${file.path}\n`);
|
|
197
|
+
else io.write(' (run with --details to list every file)\n');
|
|
198
|
+
return yes(await io.ask('\nGo ahead? [Y/n] '));
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function restartHint(root, manifest) {
|
|
204
|
+
const manager = await packageManager(root, manifest);
|
|
205
|
+
return typeof manifest.scripts?.dev === 'string' ? `${manager} run dev` : null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Poll until the running website serves the installed integration. */
|
|
209
|
+
async function waitForWebsite(root, connection, manifest, io, reason) {
|
|
210
|
+
const values = { websiteOrigin: connection.websiteOrigin, adminOrigin: connection.adminOrigin, siteId: connection.siteId, environmentId: connection.environmentId };
|
|
211
|
+
let outcome = await io.checkPublication(root, values);
|
|
212
|
+
if (outcome.checked) return true;
|
|
213
|
+
const local = /^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|$)/.test(connection.websiteOrigin);
|
|
214
|
+
const command = await restartHint(root, manifest);
|
|
215
|
+
io.write(`\n→ ${reason}\n`);
|
|
216
|
+
io.write(local
|
|
217
|
+
? ` 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`
|
|
218
|
+
: ` Deploy your website the way you normally do. It must be reachable at ${connection.websiteOrigin}\n`);
|
|
219
|
+
io.write(` Waiting for ${connection.websiteOrigin} … (Ctrl+C stops waiting; run this command again later to continue)\n`);
|
|
220
|
+
const deadline = io.now() + WEBSITE_WAIT_MS;
|
|
221
|
+
while (io.now() < deadline) {
|
|
222
|
+
await io.sleep(3000);
|
|
223
|
+
outcome = await io.checkPublication(root, values);
|
|
224
|
+
if (outcome.checked) return true;
|
|
225
|
+
}
|
|
226
|
+
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`);
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** After setup: confirm the running site, guide the content import, close the bootstrap export. */
|
|
231
|
+
async function goLive(root, connection, manifest, io) {
|
|
232
|
+
if (!await waitForWebsite(root, connection, manifest, io, 'Restart your website so the changes take effect:')) return { connected: false };
|
|
233
|
+
io.write('✔ Your website is connected to the CMS\n');
|
|
234
|
+
const pages = `${connection.adminOrigin}/sites/${connection.siteId}`, setup = `${pages}#setup?environmentId=${connection.environmentId}&step=import`;
|
|
235
|
+
if (!(await readSourceSettings(root)).exportClosed) {
|
|
236
|
+
io.write('\nNext, copy your existing page text into the CMS:\n');
|
|
237
|
+
io.write(` In the CMS page that just opened, click “Connect & fetch content”\n ${setup}\n`);
|
|
238
|
+
await io.openUrl(setup);
|
|
239
|
+
// Closing the export before the import ran would strand the original content,
|
|
240
|
+
// so this needs an explicit yes; anything else leaves it open for a rerun.
|
|
241
|
+
const answer = (await io.ask('\nDid the CMS show “Website content fetched”? [y/N] ')).trim().toLowerCase();
|
|
242
|
+
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 }; }
|
|
243
|
+
await finalizeSource(root, { apply: true });
|
|
244
|
+
io.write('✔ Import finished; the temporary content export is closed\n');
|
|
245
|
+
if (!await waitForWebsite(root, connection, manifest, io, 'Restart your website one more time:')) return { connected: true, imported: true };
|
|
246
|
+
}
|
|
247
|
+
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`);
|
|
248
|
+
return { connected: true, imported: true, done: true };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* @param {string} directory website folder
|
|
253
|
+
* @param {{ cms?: string, code?: string, details?: boolean }} options
|
|
254
|
+
* @param {{ ask, write, openUrl, run, fetch, env, sleep, now, checkPublication }} io
|
|
255
|
+
*/
|
|
256
|
+
export async function runGuided(directory, options, io) {
|
|
257
|
+
const root = await projectRoot(directory);
|
|
258
|
+
const manifest = await manifestOf(root);
|
|
259
|
+
io.write('\nUniversal CMS · connect your website\n');
|
|
260
|
+
const analysis = await analyze(root).catch(() => ({}));
|
|
261
|
+
const framework = typeof analysis.framework === 'string' && analysis.framework !== 'Unknown' ? analysis.framework : null;
|
|
262
|
+
io.write(`✔ Found ${framework ? `a ${framework} website` : 'a website'} in ${path.basename(root)}\n`);
|
|
263
|
+
|
|
264
|
+
const saved = await readConnection(root);
|
|
265
|
+
const environment = await readSetupEnvironment(root);
|
|
266
|
+
const installed = await readProjectFile(root, '.universalcms/website.integration.json') !== null;
|
|
267
|
+
if (saved && installed && environment.keyConfigured && !options.code) {
|
|
268
|
+
io.write(`✔ Already set up for ${saved.websiteOrigin}\n`);
|
|
269
|
+
return goLive(root, saved, await manifestOf(root), io);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const cms = cmsOrigin(options.cms ?? io.env.UNIVERSALCMS_URL ?? saved?.adminOrigin ?? '') ?? await (async () => {
|
|
273
|
+
while (true) {
|
|
274
|
+
const value = cmsOrigin((await io.ask('Your Universal CMS address (for example https://cms.example.com): ')).trim());
|
|
275
|
+
if (value) return value;
|
|
276
|
+
io.write('Enter the address you use to sign in to the CMS.\n');
|
|
277
|
+
}
|
|
278
|
+
})();
|
|
279
|
+
if (options.code !== undefined && !codePattern.test(options.code)) fail('That connection code is not valid. Copy the whole command from the CMS again.');
|
|
280
|
+
|
|
281
|
+
await ensureConnector(root, manifest, io);
|
|
282
|
+
if (!installed) await reviewLayout(root, manifest, framework, io);
|
|
283
|
+
const approval = options.code
|
|
284
|
+
? { code: options.code, verifier: undefined }
|
|
285
|
+
: await waitForBrowserApproval({ cms, io, request: { origin: saved?.websiteOrigin ?? suggestedOrigin(manifest, framework) ?? '', project: path.basename(root).slice(0, 80), framework: framework ?? '' } });
|
|
286
|
+
const paired = await redeem(cms, approval.code, approval.verifier, io.fetch);
|
|
287
|
+
io.write(`✔ Approved: ${paired.siteName || 'your website'} (${paired.connection.websiteOrigin})\n`);
|
|
288
|
+
if (saved && (saved.siteId !== paired.connection.siteId || saved.environmentId !== paired.connection.environmentId)) {
|
|
289
|
+
fail('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.');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const adapter = setupIo(io, paired.serverKey, options.details === true);
|
|
293
|
+
try {
|
|
294
|
+
if (saved && installed) {
|
|
295
|
+
io.write('Saving the new website key…\n');
|
|
296
|
+
if (!(await runServerKey(root, adapter, { validateKey: true })).saved) fail('The website key was not saved.');
|
|
297
|
+
} else {
|
|
298
|
+
const result = await runSetup(root, { originalRoutesOnly: true, ...Object.fromEntries(['apiOrigin', 'siteId', 'environmentId', 'locale', 'websiteOrigin', 'adminOrigin'].map(name => [name, paired.connection[name]])) }, adapter);
|
|
299
|
+
if (!result.saved) { io.write('Cancelled. Nothing was changed. The approval expires on its own in 10 minutes.\n'); return { connected: false }; }
|
|
300
|
+
if (result.settingsInstalled === false) {
|
|
301
|
+
io.write(adapter.log.splice(0).join(''));
|
|
302
|
+
fail('Your pages were connected, but the header/footer part could not be finished automatically. Ask your developer to review the messages above.');
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
} catch (error) {
|
|
306
|
+
// The reviewed setup explains blockers in technical detail; show it on failure only.
|
|
307
|
+
const technical = adapter.log.splice(0).join('').trim();
|
|
308
|
+
if (technical && !options.details) io.write(`\nDetails for your developer:\n${technical}\n\n`);
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
io.write('✔ Website files and private key saved\n');
|
|
312
|
+
return goLive(root, paired.connection, await manifestOf(root), io);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Process-backed effects for the CLI. */
|
|
316
|
+
export function processIo({ ask, write }) {
|
|
317
|
+
return {
|
|
318
|
+
ask, write, env: process.env, fetch: globalThis.fetch, checkPublication,
|
|
319
|
+
sleep: ms => new Promise(resolve => setTimeout(resolve, ms)), now: () => Date.now(),
|
|
320
|
+
openUrl: url => new Promise(resolve => {
|
|
321
|
+
// Fixed launcher per platform with the URL as a single argument; never a shell string.
|
|
322
|
+
const [command, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]] : ['xdg-open', [url]];
|
|
323
|
+
try {
|
|
324
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true });
|
|
325
|
+
child.once('error', () => resolve(false)); child.once('spawn', () => { child.unref(); resolve(true); });
|
|
326
|
+
} catch { resolve(false); }
|
|
327
|
+
}),
|
|
328
|
+
run: (command, args, cwd) => new Promise(resolve => {
|
|
329
|
+
// Windows package managers are .cmd shims, which Node only starts through a shell.
|
|
330
|
+
// The spec is validated above; arguments contain no shell metacharacters.
|
|
331
|
+
const child = spawn(process.platform === 'win32' ? `${command}.cmd` : command, args, { cwd, stdio: 'inherit', shell: process.platform === 'win32' });
|
|
332
|
+
child.once('error', () => resolve(1)); child.once('exit', code => resolve(code ?? 1));
|
|
333
|
+
}),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
@@ -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,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
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Explicit literal layout review; no key, network, scope or publication effects. */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {lstat,readdir,realpath} from 'node:fs/promises';
|
|
4
|
+
import {projectRoot,readProjectFile,applyPlan,SetupError} from './core.mjs';
|
|
5
|
+
import {setupReviewError} from './preflight-recovery.mjs';
|
|
6
|
+
import {contentDigest} from './content-registry.mjs';
|
|
7
|
+
import {nativeFramework,inspectNativeProject} from './native-adapters.mjs';
|
|
8
|
+
import {inspectNativeChromeTemplate} from './native-chrome-source.mjs';
|
|
9
|
+
export const NATIVE_CHROME_FILE='.universalcms/native.chrome.json';
|
|
10
|
+
const installedFiles=['.universalcms/content.bindings.json','.universalcms/publication.config.json','.universalcms/website.integration.json'];
|
|
11
|
+
const json=value=>JSON.stringify(value,null,2)+'\n';
|
|
12
|
+
const fail=message=>{throw new SetupError(message);};
|
|
13
|
+
async function ordinary(root,name){const text=await readProjectFile(root,name);if(text!==null&&(await lstat(path.join(root,name))).nlink!==1)fail('Native layout review requires ordinary files without links.');return text;}
|
|
14
|
+
const safePath=value=>typeof value==='string'&&/^(?:src\/|app\/pages\/|(?:[a-z0-9-]+\/)*index\.html$)/.test(value)&&/^[A-Za-z0-9_+./-]+$/.test(value)&&!value.split('/').some(part=>!part||part==='.'||part==='..'||part.startsWith('.'))&&value.length<300;
|
|
15
|
+
export async function readNativeChromeReview(root,framework){
|
|
16
|
+
const text=await ordinary(root,NATIVE_CHROME_FILE);if(text===null)return null;
|
|
17
|
+
let value;try{value=JSON.parse(text);}catch{fail('Native layout review manifest is invalid.');}
|
|
18
|
+
if(!value||typeof value!=='object'||Object.keys(value).some(key=>!['version','framework','files'].includes(key))||value.version!==1||value.framework!==framework||!Array.isArray(value.files)||!value.files.length||value.files.length>1000||value.files.some(row=>!row||Object.keys(row).some(key=>!['path','sha256'].includes(key))||!safePath(row.path)||!/^[a-f0-9]{64}$/.test(row.sha256))||new Set(value.files.map(row=>row.path)).size!==value.files.length)fail('Native layout review manifest does not match this framework.');
|
|
19
|
+
return {path:NATIVE_CHROME_FILE,content:text,state:'unchanged',sha256:contentDigest(text),value};
|
|
20
|
+
}
|
|
21
|
+
export function reviewedNativeChrome(review,filename,source){
|
|
22
|
+
const row=review?.value.files.find(row=>row.path===filename);
|
|
23
|
+
if(!row){if(/<(?:header|footer)(?:\s|>)/.test(source))throw setupReviewError('native-chrome-layout',[filename]);return false;}
|
|
24
|
+
if(contentDigest(source)!==row.sha256)fail('Reviewed native layout changed. Preserve the integration and review its source before setup.');
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
export async function nativeChromeObservation(root,review){if(!review)return[];if(await ordinary(root,NATIVE_CHROME_FILE)!==review.content)fail('Native layout review changed during planning. No integration files were saved.');return[{path:review.path,content:review.content,state:'unchanged'}];}
|
|
28
|
+
async function candidateFiles(root,framework,project){
|
|
29
|
+
if(framework==='vue')return['src/App.vue'];
|
|
30
|
+
if(framework==='angular')return[project.review.template];
|
|
31
|
+
if(framework==='static')return project.pages.map(row=>row.sourceFile);
|
|
32
|
+
const base=framework==='nuxt'?'app/pages':'src/routes',files=[];let count=0;
|
|
33
|
+
async function walk(directory,depth=0){if(depth>12||++count>1200)fail('Native layout route inventory exceeds its budget.');const target=path.join(root,directory),stat=await lstat(target);if(!stat.isDirectory()||stat.isSymbolicLink()||await realpath(target)!==target)fail('Native layout review refuses linked source directories.');for(const item of(await readdir(target,{withFileTypes:true})).sort((a,b)=>a.name.localeCompare(b.name))){if(++count>1200||item.isSymbolicLink())fail('Native layout review refuses linked or oversized routes.');const filename=directory+'/'+item.name;if(item.isDirectory()){if(!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(item.name))fail('Custom route shapes need their existing reviewed adapter.');await walk(filename,depth+1);}else if(item.isFile()&&(framework==='nuxt'?/^[a-z0-9]+(?:-[a-z0-9]+)*\.vue$/.test(item.name):item.name==='+page.svelte'))files.push(filename);else fail('Custom route components or server files need their existing reviewed adapter.');}}
|
|
34
|
+
await walk(base);return files;
|
|
35
|
+
}
|
|
36
|
+
function templateRegion(source,framework){if(!['vue','nuxt'].includes(framework))return {template:source,start:0,end:source.length};const match=/^\s*(?:<script\s+setup(?:\s+lang="(?:ts|js)")?\s*>\s*<\/script>\s*)?<template\s*>([\s\S]*?)<\/template>\s*((?:<style(?:\s+scoped)?\s*>[\s\S]*?<\/style>\s*)*)$/.exec(source);if(!match)fail('Vue layout review requires one literal template, optional empty script setup and ordinary styles.');const start=source.indexOf('>',source.indexOf('<template'))+1;return {template:match[1],start,end:start+match[1].length};}
|
|
37
|
+
export async function planNativeChromeReview(directory){
|
|
38
|
+
const root=await projectRoot(directory);
|
|
39
|
+
for(const name of installedFiles)if(await readProjectFile(root,name)!==null)fail('An integration is already installed. Review its existing adapter update; review-layout never rewrites installed native content.');
|
|
40
|
+
const packageText=await ordinary(root,'package.json');let manifest;try{manifest=JSON.parse(packageText);}catch{fail('A valid website package.json is required.');}
|
|
41
|
+
const framework=nativeFramework(manifest);if(!framework)fail('This layout review supports Vue, Nuxt, Angular SSR, SvelteKit and literal HTML. Use the framework-specific review for other websites.');
|
|
42
|
+
const project=await inspectNativeProject(root,framework,manifest),paths=await candidateFiles(root,framework,project),files=[{path:'package.json',content:packageText,state:'unchanged'},...installedFiles.map(path=>({path,content:null,state:'absent'}))],reviewed=[],pages=[];
|
|
43
|
+
for(const filename of paths){const source=await ordinary(root,filename);if(typeof source!=='string')fail('A native source disappeared during layout review.');const region=templateRegion(source,framework),checked=await inspectNativeChromeTemplate(region.template,{mark:true});const content=source.slice(0,region.start)+checked.source+source.slice(region.end);files.push({path:filename,content,state:content===source?'unchanged':'update',...(content===source?{}:{previous:source})});if(checked.descriptor){reviewed.push({path:filename,sha256:contentDigest(content)});pages.push({path:filename,slots:checked.descriptor.slots,change:checked.changed?'insert-slots':'verify-slots'});}}
|
|
44
|
+
if(!reviewed.length)fail('No unambiguous literal header and footer were found. Preserve the native layout and review its component composition.');
|
|
45
|
+
const before=await ordinary(root,NATIVE_CHROME_FILE),content=json({version:1,framework,files:reviewed});if(before!==null&&before!==content)fail('An existing layout declaration differs. Preserve it and review the changed source explicitly.');
|
|
46
|
+
files.push({path:NATIVE_CHROME_FILE,content,state:before===null?'create':'unchanged'});
|
|
47
|
+
return {root,framework,files,pages,review:{preserves:['native-children','native-links','classes','private-credentials','connection-scope','published-content'],adds:'Empty CMS settings slots inside the existing native header and footer; current grants control their contents.',complete:false}};
|
|
48
|
+
}
|
|
49
|
+
export async function runNativeChromeReview(directory,io){const plan=await planNativeChromeReview(directory);io.write('Review native layout changes. Existing native links, labels, controls and CSS classes remain website-owned. CMS settings occupy new empty slots; this does not publish or install the website integration.\n');for(const page of plan.pages)io.write(` ${page.change}: ${page.path} (${page.slots.length} settings slots)\n`);if(!['y','yes'].includes((await io.ask('Save this native header/footer integration review? [y/N] ')).trim().toLowerCase()))return{saved:false};if(json(await planNativeChromeReview(directory))!==json(plan))fail('Native layout changed during review. No declaration was saved.');await applyPlan(plan);io.write('Native layout review saved. Rerun the scoped preflight and setup, then verify the running website before connecting.\n');return{saved:true,pages:plan.pages.map(row=>row.path)};}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** Bounded inert literal chrome review. Never evaluates a website module. */
|
|
2
|
+
import { parse } from './vendor/astro-compiler/node/index.js';
|
|
3
|
+
import { SetupError } from './core.mjs';
|
|
4
|
+
import { publicHref } from './website-model.mjs';
|
|
5
|
+
import { nativeChromeDescriptor, NATIVE_CHROME_SLOTS } from './native-chrome-model.mjs';
|
|
6
|
+
const fail = reason => { throw new SetupError(`Native layout needs review: ${reason} Existing files were preserved.`); };
|
|
7
|
+
const tags = new Set(['header','footer','nav','div','span','a','ul','ol','li','details','summary','small','strong','em','b','i','p','br']);
|
|
8
|
+
const decode = value => value.replace(/&(?:amp|quot|apos|lt|gt);/g, value => ({'&':'&','"':'"',''':"'",'<':'<','>':'>'}[value]));
|
|
9
|
+
const empty = nodes => (nodes ?? []).every(node => node.type === 'text' && !node.value.trim());
|
|
10
|
+
export async function inspectNativeChromeTemplate(source, { mark = false } = {}) {
|
|
11
|
+
if (typeof source !== 'string' || Buffer.byteLength(source) > 128*1024 || source.includes('\0')) fail('template exceeds the literal source budget');
|
|
12
|
+
const tree = await parse(source, {position:true});
|
|
13
|
+
if (tree.diagnostics.some(row => row.severity === 1)) fail('invalid native template');
|
|
14
|
+
const offsets = new Map([[0,0]]); let bytes=0,characters=0,count=0;
|
|
15
|
+
for (const character of source) { bytes+=Buffer.byteLength(character); characters+=character.length; offsets.set(bytes,characters); }
|
|
16
|
+
const roots=[], markers=[];
|
|
17
|
+
function collect(node, depth=0) {
|
|
18
|
+
if (++count > 30000 || depth > 80) fail('template exceeds the AST budget');
|
|
19
|
+
if (node.type === 'element' && ['header','footer'].includes(node.name)) roots.push(node);
|
|
20
|
+
for (const attribute of node.attributes ?? []) if (/^data-universalcms/i.test(attribute.name)) markers.push({node,attribute});
|
|
21
|
+
for (const child of node.children ?? []) collect(child,depth+1);
|
|
22
|
+
}
|
|
23
|
+
collect(tree.ast);
|
|
24
|
+
if (!roots.length && !markers.length) return {source, descriptor:undefined, changed:false};
|
|
25
|
+
if (roots.length !== 2 || roots.filter(node => node.name === 'header').length !== 1 || roots.filter(node => node.name === 'footer').length !== 1) fail('exactly one literal header and footer are required');
|
|
26
|
+
const slots = new Map(), exclude={header:[],footer:[]}, edits=[], owned=new Set();
|
|
27
|
+
function range(node) {
|
|
28
|
+
let start=offsets.get(node.position?.start?.offset), end=offsets.get(node.position?.end?.offset);
|
|
29
|
+
if (source[start]!=='<' && source[start-1]==='<') start--;
|
|
30
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || !source.startsWith('<'+node.name,start) || !source.slice(start,end).endsWith('</'+node.name+'>')) fail('explicit closing tags and exact source positions are required');
|
|
31
|
+
let quote=null,openEnd;
|
|
32
|
+
for(let i=start+1;i<end;i++){const char=source[i];if(quote){if(char===quote)quote=null;}else if(char==='"'||char==="'")quote=char;else if(char==='>'){openEnd=i+1;break;}}
|
|
33
|
+
if(!openEnd)fail('opening tag is incomplete');
|
|
34
|
+
return {start,end,openEnd,closeStart:end-node.name.length-3};
|
|
35
|
+
}
|
|
36
|
+
for (const root of roots) {
|
|
37
|
+
const wanted=root.name==='header'?NATIVE_CHROME_SLOTS.slice(0,4):NATIVE_CHROME_SLOTS.slice(4);
|
|
38
|
+
function visit(node) {
|
|
39
|
+
if (node.type === 'text') { if (/[{}]/.test(node.value)) fail('computed chrome text needs a reviewed adapter'); return; }
|
|
40
|
+
if (node.type === 'comment') return;
|
|
41
|
+
if (node.type !== 'element' || !tags.has(node.name) || node !== root && ['header','footer'].includes(node.name)) fail('chrome must contain literal native elements without scripts, forms, images or components');
|
|
42
|
+
if(node.name!=='br')range(node);
|
|
43
|
+
owned.add(node); const names=new Set(); let slot;
|
|
44
|
+
for(const attribute of node.attributes ?? []) {
|
|
45
|
+
const name=attribute.name;
|
|
46
|
+
if(names.has(name) || !['quoted','empty'].includes(attribute.kind) || /^(?:on|v-|[:@#*[(])/i.test(name)) fail('dynamic or duplicate chrome attributes need a reviewed adapter');
|
|
47
|
+
names.add(name);
|
|
48
|
+
if(name==='data-universalcms-chrome') { if(node!==root || attribute.value!==root.name)fail('chrome markers must identify their exact native header/footer'); continue; }
|
|
49
|
+
if(name==='data-universalcms-slot') { if(node.name!=='div' || !wanted.includes(attribute.value) || slots.has(attribute.value) || !empty(node.children))fail('each settings slot must be one empty div in its matching header/footer');slot=attribute.value;slots.set(slot,node);continue; }
|
|
50
|
+
if(!/^(?:class|id|title|role|aria-[a-z-]+|data-[a-z-]+|href|target|rel|open)$/.test(name) || /^data-(?:cms|universalcms)/.test(name))fail('unsupported chrome attributes need a reviewed adapter');
|
|
51
|
+
if(name==='href') { const href=decode(attribute.value??''); if(node.name!=='a'||!publicHref(href,{mail:true}) || /&/.test(href.replace(/&(?:amp;)?/g,'')))fail('native links need safe literal URLs'); if(!exclude[root.name].includes(href))exclude[root.name].push(href); }
|
|
52
|
+
if(name==='target' && !['_self','_blank'].includes(attribute.value))fail('unsupported native link target');
|
|
53
|
+
if(name==='open' && node.name!=='details')fail('only native details controls may use open');
|
|
54
|
+
}
|
|
55
|
+
if(node.name==='a'&&!names.has('href'))fail('native links require a literal href');
|
|
56
|
+
if(slot && (node.attributes??[]).some(attribute=>!['data-universalcms-slot','class','id'].includes(attribute.name)))fail('settings slots may only have marker, class and id attributes');
|
|
57
|
+
for(const child of node.children??[])visit(child);
|
|
58
|
+
}
|
|
59
|
+
visit(root);
|
|
60
|
+
const region=range(root),marked=(root.attributes??[]).some(attribute=>attribute.name==='data-universalcms-chrome');
|
|
61
|
+
if(mark && !markers.length) {
|
|
62
|
+
edits.push({start:region.openEnd-1,end:region.openEnd-1,text:` data-universalcms-chrome="${root.name}"`});
|
|
63
|
+
edits.push({start:region.closeStart,end:region.closeStart,text:wanted.map(name=>`<div data-universalcms-slot="${name}"></div>`).join('')});
|
|
64
|
+
} else if(!marked || wanted.some(name=>!slots.has(name))) fail('run review-layout to review and add the complete explicit settings slots');
|
|
65
|
+
}
|
|
66
|
+
if(markers.some(({node})=>!owned.has(node)))fail('settings markers outside the inspected chrome require review');
|
|
67
|
+
if(edits.length){let output=source;for(const edit of edits.sort((a,b)=>b.start-a.start))output=output.slice(0,edit.start)+edit.text+output.slice(edit.end);const checked=await inspectNativeChromeTemplate(output);return {...checked,changed:true};}
|
|
68
|
+
return {source,descriptor:nativeChromeDescriptor({version:1,slots:[...NATIVE_CHROME_SLOTS],exclude}),changed:false};
|
|
69
|
+
}
|