@universalcms/connect 0.0.0-stage → 0.1.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.
@@ -0,0 +1,54 @@
1
+ /** Public published-page projection. Supply the fixed, private SDK client. */
2
+ export function createPageHandler(cms, { locale, websiteOrigin, maxConcurrent = 16 }) {
3
+ const origin = new URL(websiteOrigin);
4
+ if (origin.origin !== websiteOrigin || (origin.protocol !== 'https:' && !(origin.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(origin.hostname)))) throw Error('Configure the website HTTPS origin.');
5
+ const canonicalLocale = value => {
6
+ if (typeof value !== 'string' || !value) return null;
7
+ try { return Intl.getCanonicalLocales(value)[0] ?? null; } catch { return null; }
8
+ };
9
+ const requestedLocale = canonicalLocale(locale);
10
+ if (!requestedLocale) throw Error('Configure a valid content locale.');
11
+ let active = 0, count = 0, windowStart = Date.now();
12
+ function publicBlock(block) {
13
+ if (block.type === 'form') { const { notifyEmail, subscribe, ...data } = block.data; return { ...block, data }; }
14
+ if (block.type === 'section') return { ...block, data: { ...block.data, blocks: block.data.blocks.map(publicBlock) } };
15
+ if (block.type === 'columns') return { ...block, data: { ...block.data, columns: block.data.columns.map(column => column.map(publicBlock)) } };
16
+ return block;
17
+ }
18
+ return async function pageHandler(req, res) {
19
+ const send = (status, value) => {
20
+ if (res.destroyed || res.writableEnded) return;
21
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer' });
22
+ res.end(JSON.stringify(value));
23
+ };
24
+ let url;
25
+ try {
26
+ if (typeof req.url !== 'string' || req.url.length > 512 || !req.url.startsWith('/') || req.url.startsWith('//') || req.url.includes('\\')) throw Error();
27
+ url = new URL(req.url, websiteOrigin);
28
+ } catch { send(400, { error: 'Invalid request.' }); return; }
29
+ if (url.pathname !== '/api/universalcms/page') { send(404, { error: 'Not found.' }); return; }
30
+ if (req.method !== 'GET') { send(405, { error: 'Method not allowed.' }); return; }
31
+ if (req.headers.origin && req.headers.origin !== websiteOrigin) { send(403, { error: 'Origin not allowed.' }); return; }
32
+ if ([...url.searchParams.keys()].some(key => key !== 'slug') || url.searchParams.getAll('slug').length !== 1) { send(400, { error: 'One page slug is required.' }); return; }
33
+ const slug = url.searchParams.get('slug');
34
+ if (!slug || slug.length > 160 || !/^[a-z0-9]+(?:[-/][a-z0-9]+)*$/.test(slug)) { send(400, { error: 'Invalid page slug.' }); return; }
35
+ const now = Date.now();
36
+ if (now - windowStart >= 60_000) { windowStart = now; count = 0; }
37
+ if (active >= maxConcurrent || ++count > 600) { send(429, { error: 'Try again later.' }); return; }
38
+ active++;
39
+ const controller = new AbortController();
40
+ const cancel = () => { if (!res.writableEnded) controller.abort(); };
41
+ res.on('close', cancel);
42
+ try {
43
+ const entry = await cms.content.getBySlug(slug, { kind: 'page', locale: requestedLocale }, { signal: controller.signal });
44
+ if (!entry) { send(404, { error: 'Page not found.' }); return; }
45
+ // Keep the response bound to the same query even if a delivery adapter
46
+ // returns a valid entry for a different slug, type or language.
47
+ if (entry.kind !== 'page' || entry.slug !== slug || canonicalLocale(entry.locale) !== requestedLocale) throw Error('Invalid delivery identity.');
48
+ // Browser receives only public renderer fields. No custom fields, form
49
+ // notification destinations, request headers, config or SDK credentials.
50
+ send(200, { entry: { id: entry.id, document: { title: entry.document.title, excerpt: entry.document.excerpt, blocks: entry.document.blocks.map(publicBlock) } } });
51
+ } catch { send(503, { error: 'Content is temporarily unavailable.' }); }
52
+ finally { active--; res.off('close', cancel); }
53
+ };
54
+ }
@@ -0,0 +1,99 @@
1
+ import { analyze, applyPlan, CONFIG_FILE, planSetup, projectRoot, readProjectFile, SetupError, validateConfig, validatePlan } from './core.mjs';
2
+ import { applyEnvironment, planEnvironment, readSetupEnvironment, validateEnvironmentPlan, verifyEnvironment } from './setup-environment.mjs';
3
+
4
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5
+ 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}$/;
6
+ function origin(value) {
7
+ try {
8
+ const url = new URL(value);
9
+ return value.length <= 2048 && !/[\s\\]/.test(value) && !url.username && !url.password && !url.search && !url.hash && url.pathname === '/' &&
10
+ (url.protocol === 'https:' || (url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) ? url.origin : null;
11
+ } catch { return null; }
12
+ }
13
+ function locale(value) { try { return value.length <= 35 ? Intl.getCanonicalLocales(value)[0] : null; } catch { return null; } }
14
+
15
+ /** Interactive effects are injected so cancellation and secret handling are testable. */
16
+ export async function runSetup(directory, options, io) {
17
+ const root = await projectRoot(directory);
18
+ const analysis = await analyze(root);
19
+ const saved = await readSetupEnvironment(root);
20
+ let prior;
21
+ const configText = await readProjectFile(root, CONFIG_FILE);
22
+ if (configText) {
23
+ try { prior = validateConfig(JSON.parse(configText)); }
24
+ catch { throw new SetupError('The saved connector configuration is invalid. Restore it before running setup.'); }
25
+ }
26
+ // Only the environment helper's sanitized defaults may reach terminal text.
27
+ const defaults = saved.defaults;
28
+ const framework = analysis.framework === 'Next.js' ? 'next' : 'vite';
29
+ const field = async (name, label, validate, fallback, message) => {
30
+ if (options[name] !== undefined) {
31
+ const value = validate(options[name]);
32
+ if (!value) throw new SetupError(message);
33
+ return value;
34
+ }
35
+ while (true) {
36
+ const answer = (await io.ask(`${label}${fallback ? ` [${fallback}]` : ''}: `)).trim();
37
+ const value = validate(answer || fallback || '');
38
+ if (value) return value;
39
+ io.write(`${message}\n`);
40
+ }
41
+ };
42
+ io.write('Connect this website without AI. Enter the connection values from Developer setup.\n');
43
+ const apiOrigin = await field('apiOrigin', 'CMS API origin', origin, defaults.apiOrigin, 'Use an HTTPS origin, or HTTP localhost for development.');
44
+ const siteId = await field('siteId', 'Website ID', value => uuid.test(value) && value.toLowerCase(), defaults.siteId, 'Enter a valid website UUID.');
45
+ const environmentId = await field('environmentId', 'Environment ID', value => uuid.test(value) && value.toLowerCase(), defaults.environmentId, 'Enter a valid environment UUID.');
46
+ const contentLocale = await field('locale', 'Content locale', locale, defaults.locale || 'en', 'Enter a valid content locale, such as en.');
47
+ const websiteOrigin = framework === 'vite' || options.websiteOrigin !== undefined
48
+ ? await field('websiteOrigin', 'Website origin', origin, defaults.websiteOrigin || 'http://localhost:5173', 'Use the exact website HTTPS origin, or HTTP localhost during development.') : undefined;
49
+ const existingServer = framework === 'vite' && analysis.runtime === 'node' && analysis.rendering === 'server';
50
+ const serverSource = existingServer ? await readProjectFile(root, 'server/index.mjs') : null;
51
+ const needsAdmin = !!serverSource?.includes('CMS_ADMIN_ORIGIN');
52
+ const adminOrigin = needsAdmin || options.adminOrigin !== undefined
53
+ ? await field('adminOrigin', 'CMS admin origin', origin, defaults.adminOrigin, 'Use the exact CMS admin HTTPS origin, or HTTP localhost during development.') : undefined;
54
+ const mount = options.mount ?? prior?.mount ?? '/cms';
55
+ const plan = await planSetup(root, { apiOrigin, siteId, environmentId, locale: contentLocale, mount }, { reconfigure: true });
56
+ let apiKey;
57
+ while (true) {
58
+ const answer = await io.hidden(saved.keyConfigured ? 'Server delivery key (hidden; Enter keeps the saved key): ' : 'Server delivery key (hidden): ');
59
+ if (!answer && saved.keyConfigured) break;
60
+ if (key.test(answer)) { apiKey = answer; break; }
61
+ io.write('Enter a valid server delivery key from Developer setup. It is not an AI provider key.\n');
62
+ }
63
+ const environment = await planEnvironment(root, { apiOrigin, siteId, environmentId, locale: contentLocale, websiteOrigin, adminOrigin, ...(apiKey ? { apiKey } : {}) });
64
+ apiKey = undefined;
65
+ let slug;
66
+ while (true) {
67
+ slug = (await io.ask('Published page slug to verify (optional; Enter skips): ')).trim();
68
+ if (!slug || (slug.length <= 160 && /^[a-z0-9]+(?:[-/][a-z0-9]+)*$/.test(slug))) break;
69
+ io.write('Enter a page slug without a leading slash, or leave it empty.\n');
70
+ }
71
+ io.write(`\nWebsite: ${siteId}\nEnvironment: ${environmentId}\nAPI: ${apiOrigin}\nLocale: ${contentLocale}\n`);
72
+ for (const file of plan.files) io.write(` ${file.state}: ${file.path}\n`);
73
+ for (const file of environment.files) io.write(` ${file.action}: ${file.path}\n`);
74
+ io.write('The delivery key is saved only in private .env.local (owner access), excluded from Git.\n');
75
+ if (existingServer) io.write('Keep the existing website server and renderer. Setup changes connection values; it does not mount a new handler in custom server code.\n');
76
+ const confirmation = (await io.ask('Save this setup? [Y/n] ')).trim().toLowerCase();
77
+ if (confirmation && confirmation !== 'y' && confirmation !== 'yes') {
78
+ io.write('Setup cancelled. No files changed.\n'); return { saved: false };
79
+ }
80
+ await validatePlan(plan);
81
+ await validateEnvironmentPlan(environment);
82
+ await applyEnvironment(environment);
83
+ let scaffold;
84
+ try { scaffold = await applyPlan(plan); }
85
+ catch { throw new SetupError('Private connection settings were saved, but generated-file setup did not finish. Existing custom edits were preserved; inspect the generated files and rerun setup.'); }
86
+ io.write(`Saved connection settings. Created ${scaffold.created.length} files; updated ${scaffold.updated.length} generated files.\n`);
87
+ if (slug) {
88
+ const verification = await verifyEnvironment(environment, { slug });
89
+ io.write(`${verification.message}\n`);
90
+ if (!verification.verified) io.write('Settings were saved, but delivery is not verified. Correct the connection or publish the requested page, then rerun setup.\n');
91
+ } else io.write('Delivery was not checked. Rerun setup with a published page slug when ready.\n');
92
+ const packages = analysis.diagnostics?.packages;
93
+ if (!packages || ['sdk', 'react', ...(framework === 'next' ? ['next'] : [])].some(name => packages[name]?.installed !== 'present' || packages[name]?.compatibility !== 'compatible')) {
94
+ io.write('Install the matching SDK and React renderer packages from Developer setup');
95
+ io.write(framework === 'next' ? ', plus the Next adapter.\n' : '.\n');
96
+ }
97
+ io.write('Restart the website using its normal command. See .universalcms/SETUP.md for rendering and deployment steps.\n');
98
+ return { saved: true };
99
+ }
package/package.json CHANGED
@@ -1,6 +1,26 @@
1
1
  {
2
2
  "name": "@universalcms/connect",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Deterministic, local setup for Universal CMS websites using Next.js or React/Vite",
5
+ "type": "module",
6
+ "bin": {
7
+ "universalcms-connect": "dist/cli.mjs"
8
+ },
9
+ "exports": {
10
+ "./node": "./dist/vite-handler.mjs"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "README.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=22.12 <27"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "scripts": {
23
+ "build": "node scripts/build.mjs",
24
+ "typecheck": "node --check src/cli.mjs && node --check src/core.mjs && node --check src/templates.mjs && node --check src/vite-handler.mjs && node --check src/wizard.mjs && node --check src/setup-environment.mjs"
25
+ }
6
26
  }