@universalcms/connect 0.1.0 → 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 +694 -37
- 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 +418 -15
- 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 +32 -51
- 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 +70 -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 +167 -3
- 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/project-analysis.mjs +56 -4
- 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-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 +237 -38
- package/package.json +43 -7
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
Bundled license information:
|
|
2
|
+
|
|
3
|
+
react/cjs/react.production.js:
|
|
4
|
+
/**
|
|
5
|
+
* @license React
|
|
6
|
+
* react.production.js
|
|
7
|
+
*
|
|
8
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
9
|
+
*
|
|
10
|
+
* This source code is licensed under the MIT license found in the
|
|
11
|
+
* LICENSE file in the root directory of this source tree.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
react-dom/cjs/react-dom.production.js:
|
|
15
|
+
/**
|
|
16
|
+
* @license React
|
|
17
|
+
* react-dom.production.js
|
|
18
|
+
*
|
|
19
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
20
|
+
*
|
|
21
|
+
* This source code is licensed under the MIT license found in the
|
|
22
|
+
* LICENSE file in the root directory of this source tree.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
react/cjs/react-jsx-runtime.production.js:
|
|
26
|
+
/**
|
|
27
|
+
* @license React
|
|
28
|
+
* react-jsx-runtime.production.js
|
|
29
|
+
*
|
|
30
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
31
|
+
*
|
|
32
|
+
* This source code is licensed under the MIT license found in the
|
|
33
|
+
* LICENSE file in the root directory of this source tree.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
scheduler/cjs/scheduler.production.js:
|
|
37
|
+
/**
|
|
38
|
+
* @license React
|
|
39
|
+
* scheduler.production.js
|
|
40
|
+
*
|
|
41
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
42
|
+
*
|
|
43
|
+
* This source code is licensed under the MIT license found in the
|
|
44
|
+
* LICENSE file in the root directory of this source tree.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
react-dom/cjs/react-dom-client.production.js:
|
|
48
|
+
/**
|
|
49
|
+
* @license React
|
|
50
|
+
* react-dom-client.production.js
|
|
51
|
+
*
|
|
52
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
53
|
+
*
|
|
54
|
+
* This source code is licensed under the MIT license found in the
|
|
55
|
+
* LICENSE file in the root directory of this source tree.
|
|
56
|
+
*/
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const PUBLICATION_STATUS_PATH: string;
|
|
2
|
+
export const PUBLICATION_SCRIPT_PATH: string;
|
|
3
|
+
export const PUBLICATION_DOCUMENT_PATH: '/api/universalcms/document';
|
|
4
|
+
export const publicationResponseHeaders: Readonly<Record<string, string>>;
|
|
5
|
+
export function nextPublicationPath(pathname: string, indexIsManaged?: boolean): string;
|
|
6
|
+
export function createNextPublicationProxy(options: {
|
|
7
|
+
configuration: unknown;
|
|
8
|
+
sourceConfiguration?: unknown;
|
|
9
|
+
contentBindings?: unknown;
|
|
10
|
+
env?: Record<string, string | undefined>;
|
|
11
|
+
fetcher?: typeof fetch;
|
|
12
|
+
}): (request: Request) => Promise<{ response?: Response; requestHeaders: Headers; headers: Readonly<Record<string, string>> }>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { createPublishedDocumentReader, isExcludedPublicationPath, normalizePublicationPath, validatePublicationConfig, publicationErrorHtml, publicationRevision, publicPublishedEntry } from './publication.mjs';
|
|
2
|
+
import { createPublicationBrowserScript, PUBLICATION_DOCUMENT_PATH } from './publication-browser.mjs';
|
|
3
|
+
import { matchesNativeRoutes } from './native-routes.mjs';
|
|
4
|
+
import { publicationVerification, verificationRequest, verificationBody, publicationRouteHeaders } from './publication-verification.mjs';
|
|
5
|
+
import { createWebsiteRequestHandler, WEBSITE_SETTINGS_PATH, WEBSITE_PAGE_PATH } from './website-http.mjs';
|
|
6
|
+
|
|
7
|
+
export const PUBLICATION_STATUS_PATH = '/api/universalcms/publication';
|
|
8
|
+
export const PUBLICATION_SCRIPT_PATH = '/.well-known/universalcms-publication-client.js';
|
|
9
|
+
export { PUBLICATION_DOCUMENT_PATH };
|
|
10
|
+
export const publicationResponseHeaders = Object.freeze({
|
|
11
|
+
'Cache-Control': 'private, no-store, max-age=0',
|
|
12
|
+
'CDN-Cache-Control': 'no-store',
|
|
13
|
+
'Vercel-CDN-Cache-Control': 'no-store',
|
|
14
|
+
'X-Content-Type-Options': 'nosniff',
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/** Resolve transport aliases before matching the fixed public route registry. */
|
|
18
|
+
export function nextPublicationPath(pathname, indexIsManaged = false) {
|
|
19
|
+
if (typeof pathname !== 'string') throw Error('Invalid publication path.');
|
|
20
|
+
let path = pathname;
|
|
21
|
+
const segment = path.match(/^(\/.*)\.segments\/[^?#]*\.segment\.rsc$/);
|
|
22
|
+
if (segment) path = segment[1];
|
|
23
|
+
else if (path.endsWith('.rsc')) path = path.slice(0, -4);
|
|
24
|
+
if (path === '/index' && path !== pathname && !indexIsManaged) path = '/';
|
|
25
|
+
return normalizePublicationPath(path || '/');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function cleanHeaders(headers) {
|
|
29
|
+
const copy = new Headers(headers);
|
|
30
|
+
for (const name of [...copy.keys()]) if (/^(?:x-universalcms-|x-cms-publication|x-middleware-subrequest$)/i.test(name)) copy.delete(name);
|
|
31
|
+
return copy;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function reply(request, body, status, contentType, extra = {}) {
|
|
35
|
+
return new Response(request.method === 'HEAD' ? null : body, {
|
|
36
|
+
status, headers: { ...publicationResponseHeaders, 'Content-Type': contentType, ...extra },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Next Proxy calls this before HTML, RSC and cached/prefetched page responses. */
|
|
41
|
+
export function createNextPublicationProxy({ configuration, sourceConfiguration, contentBindings, env = process.env, fetcher = globalThis.fetch } = {}) {
|
|
42
|
+
const config = validatePublicationConfig(configuration);
|
|
43
|
+
const verification = publicationVerification(config, sourceConfiguration, contentBindings);
|
|
44
|
+
const read = createPublishedDocumentReader({ configuration: config, env, fetcher });
|
|
45
|
+
const script = createPublicationBrowserScript(config);
|
|
46
|
+
const website = createWebsiteRequestHandler({ configuration: config, sourceConfiguration, contentBindings, env, fetcher });
|
|
47
|
+
return async function handle(request) {
|
|
48
|
+
const requestHeaders = cleanHeaders(request.headers);
|
|
49
|
+
const pass = headers => ({ requestHeaders, headers });
|
|
50
|
+
let url;
|
|
51
|
+
try { url = new URL(request.url); } catch {
|
|
52
|
+
return { ...pass({}), response: reply(request, publicationErrorHtml(503), 503, 'text/html; charset=utf-8') };
|
|
53
|
+
}
|
|
54
|
+
if (url.pathname === WEBSITE_SETTINGS_PATH || url.pathname === WEBSITE_PAGE_PATH) {
|
|
55
|
+
const result = await website(request);
|
|
56
|
+
if (result instanceof Response) {
|
|
57
|
+
if ([301, 302, 307, 308].includes(result.status) && result.headers.get('location')?.startsWith('/')) {
|
|
58
|
+
result.headers.set('Location', new URL(result.headers.get('location'), config.websiteOrigin).href);
|
|
59
|
+
}
|
|
60
|
+
return { response: result, requestHeaders: new Headers(), headers: {} };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (url.pathname === PUBLICATION_SCRIPT_PATH || url.pathname === PUBLICATION_STATUS_PATH || url.pathname === PUBLICATION_DOCUMENT_PATH) {
|
|
64
|
+
if (url.pathname === PUBLICATION_STATUS_PATH) {
|
|
65
|
+
const probe = verificationRequest({ configuration: config, verification, url, method: request.method, headers: request.headers });
|
|
66
|
+
if (probe) {
|
|
67
|
+
if (probe.status) return { ...pass({}), response: reply(request, probe.status === 204 ? null : '{"error":"Publication verification unavailable."}', probe.status, 'application/json', probe.headers) };
|
|
68
|
+
const result = await read(probe.pathname);
|
|
69
|
+
const probeHeaders = { ...probe.headers, ...(result.reason ? { 'X-UniversalCMS-Delivery-Status': result.reason } : {}) };
|
|
70
|
+
return { ...pass({}), response: reply(request, JSON.stringify(verificationBody(verification, probe.pathname, result)), result.state === 'unavailable' ? 503 : 200, 'application/json', probeHeaders) };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (!['GET', 'HEAD'].includes(request.method)) return { ...pass({}), response: reply(request, '{"error":"Method not allowed."}', 405, 'application/json', { Allow: 'GET, HEAD' }) };
|
|
74
|
+
const origin = request.headers.get('origin');
|
|
75
|
+
if (request.headers.get('sec-fetch-site') === 'cross-site' || origin && origin !== config.websiteOrigin) {
|
|
76
|
+
return { ...pass({}), response: reply(request, '{"error":"Request not allowed."}', 403, 'application/json') };
|
|
77
|
+
}
|
|
78
|
+
if (url.pathname === PUBLICATION_SCRIPT_PATH) return { ...pass({}), response: reply(request, script, 200, 'text/javascript; charset=utf-8') };
|
|
79
|
+
if ([...url.searchParams.keys()].some(key => key !== 'path') || url.searchParams.getAll('path').length !== 1) {
|
|
80
|
+
return { ...pass({}), response: reply(request, '{"error":"Invalid publication request."}', 400, 'application/json') };
|
|
81
|
+
}
|
|
82
|
+
const result = await read(url.searchParams.get('path'));
|
|
83
|
+
const state = result.state === 'unmanaged' ? 'missing' : result.state;
|
|
84
|
+
const documentEndpoint = url.pathname === PUBLICATION_DOCUMENT_PATH;
|
|
85
|
+
// Already-open 0.5.0 clients require exactly { state }. New clients opt in;
|
|
86
|
+
// the dedicated document endpoint always identifies its publication.
|
|
87
|
+
const includeRevision = documentEndpoint || request.headers.get('x-universalcms-include-revision') === '1';
|
|
88
|
+
const body = { state, ...(result.entry && includeRevision ? { revision: publicationRevision(result.entry), ...(documentEndpoint ? { entry: publicPublishedEntry(result.entry) } : {}) } : {}) };
|
|
89
|
+
const extraHeaders = result.reason ? { 'X-UniversalCMS-Delivery-Status': result.reason } : {};
|
|
90
|
+
return { ...pass({}), response: reply(request, JSON.stringify(body), state === 'missing' ? 404 : result.status, 'application/json', extraHeaders) };
|
|
91
|
+
}
|
|
92
|
+
let pathname;
|
|
93
|
+
try { pathname = nextPublicationPath(url.pathname, config.routes.some(route => route.path === '/index')); } catch {
|
|
94
|
+
return { ...pass({}), response: reply(request, publicationErrorHtml(404), 404, 'text/html; charset=utf-8', { 'X-Robots-Tag': 'noindex' }) };
|
|
95
|
+
}
|
|
96
|
+
const result = await read(pathname);
|
|
97
|
+
if (result.state === 'unmanaged' && (isExcludedPublicationPath(pathname) || matchesNativeRoutes(verification, pathname))) return pass({});
|
|
98
|
+
const routeHeaders = result.state === 'unmanaged' ? {} : publicationRouteHeaders(config, pathname, result, verification, request.headers.get('origin'), request.headers.has('authorization') || request.headers.has('cookie'));
|
|
99
|
+
if (result.state === 'published') return pass({ ...publicationResponseHeaders, ...routeHeaders });
|
|
100
|
+
// A new public source route is not published merely because it exists in
|
|
101
|
+
// the Next tree. Rerun setup to add its reviewed identity to the registry.
|
|
102
|
+
const status = result.state === 'missing' || result.state === 'unmanaged' ? 404 : 503;
|
|
103
|
+
return { ...pass({}), response: reply(request, publicationErrorHtml(status), status, 'text/html; charset=utf-8', {
|
|
104
|
+
...routeHeaders, 'X-Robots-Tag': 'noindex', ...(status === 503 ? { 'Retry-After': '30' } : {}),
|
|
105
|
+
}) };
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/** Explicit, source-only review of native Next layout settings slots. */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { lstat } from 'node:fs/promises';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import ts from './vendor/typescript.cjs';
|
|
6
|
+
import { projectRoot, readProjectFile, SetupError } from './core.mjs';
|
|
7
|
+
import { configureNextContent } from './content-next-setup.mjs';
|
|
8
|
+
import { isNativeReviewFilePath } from './native-routes.mjs';
|
|
9
|
+
import { setupReviewError, sanitizeSetupPaths } from './preflight-recovery.mjs';
|
|
10
|
+
export const SETTINGS_LAYOUT_FILE = '.universalcms/settings.layout.json';
|
|
11
|
+
const PROFILE = 'reviewed-next-settings-layout-v1';
|
|
12
|
+
const digest = text => createHash('sha256').update(text).digest('hex');
|
|
13
|
+
const json = value => JSON.stringify(value, null, 2) + '\n';
|
|
14
|
+
const script = '<script id="universalcms-publication-client" src="/.well-known/universalcms-publication-client.js"></script>';
|
|
15
|
+
const installedFiles = ['.universalcms/content.bindings.json','.universalcms/publication.config.json','.universalcms/website.integration.json','.universalcms/reviewed.integration.json'];
|
|
16
|
+
const configurationFiles = ['package.json','tsconfig.json','jsconfig.json','next.config.js','next.config.mjs','next.config.cjs','next.config.ts'];
|
|
17
|
+
// Next 16 prepares these compiler/build fields itself. Record the exact expected
|
|
18
|
+
// object at review time rather than ignoring later tsconfig changes. Resolver
|
|
19
|
+
// fields, application options and every other value remain part of the hash.
|
|
20
|
+
function typescriptConfigurationHash(content, prepare = false) {
|
|
21
|
+
if (content === null) return null;
|
|
22
|
+
const parsed = ts.parseConfigFileTextToJson('tsconfig.json', content);
|
|
23
|
+
if (parsed.error || !parsed.config || typeof parsed.config !== 'object' || Array.isArray(parsed.config)) return null;
|
|
24
|
+
const config = parsed.config;
|
|
25
|
+
if (prepare) {
|
|
26
|
+
const options = config.compilerOptions;
|
|
27
|
+
if (!options || typeof options !== 'object' || Array.isArray(options) || !Array.isArray(config.include)) return null;
|
|
28
|
+
if (!Object.hasOwn(options, 'incremental')) options.incremental = true;
|
|
29
|
+
options.jsx = 'react-jsx';
|
|
30
|
+
for (const pattern of ['.next/types/**/*.ts', '.next/dev/types/**/*.ts']) if (!config.include.includes(pattern)) config.include.push(pattern);
|
|
31
|
+
}
|
|
32
|
+
const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
|
|
33
|
+
return digest(JSON.stringify(canonical(config)));
|
|
34
|
+
}
|
|
35
|
+
function preparedConfigurationHash(file) {
|
|
36
|
+
if (file.path === 'tsconfig.json') return typescriptConfigurationHash(file.content, true) ?? (file.content === null ? null : digest(file.content));
|
|
37
|
+
if (file.path.startsWith('next.config.')) return file.content === null ? (file.path === 'next.config.mjs' ? digest('export default { htmlLimitedBots: /.*/ };\n') : null) : digest(configureNextContent(file.content, file.path));
|
|
38
|
+
return file.content === null ? null : digest(file.content);
|
|
39
|
+
}
|
|
40
|
+
const fail = (message, files = []) => { const error = setupReviewError('native-settings-layout', files); const safe = sanitizeSetupPaths(files).paths; error.message = message + (safe.length ? ` Review paths: ${safe.join(', ')}.` : ''); throw error; };
|
|
41
|
+
const relative = (from, to) => { const value = path.posix.relative(path.posix.dirname(from), to); return value.startsWith('.') ? value : './' + value; };
|
|
42
|
+
const parse = (name, source) => { const ast = ts.createSourceFile(name, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); if (ast.parseDiagnostics.length) fail('Correct the layout module syntax before review', [name]); return ast; };
|
|
43
|
+
async function ordinary(root, name) { const content = await readProjectFile(root,name); if (content !== null && (await lstat(path.join(root,name))).nlink !== 1) fail('Layout review requires ordinary single-link files', [name]); return content; }
|
|
44
|
+
function bodyOf(ast, filename) {
|
|
45
|
+
const bodies=[];
|
|
46
|
+
const visit=node=>{if(ts.isJsxElement(node)&&node.openingElement.tagName.getText(ast)==='body')bodies.push(node);ts.forEachChild(node,visit);};visit(ast);
|
|
47
|
+
if (bodies.length !== 1 || ast.statements.some(node=>ts.isExpressionStatement(node)&&ts.isStringLiteral(node.expression)&&node.expression.text==='use client')) fail('A reviewed layout needs one server-rendered literal body', [filename]);
|
|
48
|
+
return bodies[0];
|
|
49
|
+
}
|
|
50
|
+
function slotsOf(ast, filename) {
|
|
51
|
+
const slots=[];
|
|
52
|
+
function visit(node) {
|
|
53
|
+
if(ts.isJsxSelfClosingElement(node)&&['header','footer'].includes(node.tagName.getText(ast)))fail('Use explicit native header and footer elements before reviewing settings slots',[filename]);
|
|
54
|
+
if(ts.isJsxElement(node)&&['header','footer'].includes(node.openingElement.tagName.getText(ast))){
|
|
55
|
+
// Conditional, looped, or callback-owned chrome is not one stable slot.
|
|
56
|
+
for(let parent=node.parent;parent&&!ts.isSourceFile(parent);parent=parent.parent){
|
|
57
|
+
if(ts.isConditionalExpression(parent)||ts.isIfStatement(parent)||ts.isIterationStatement(parent,false)||ts.isCallExpression(parent)||ts.isBinaryExpression(parent))fail('Conditional or repeated header/footer elements need explicit framework integration',[filename]);
|
|
58
|
+
}
|
|
59
|
+
slots.push({kind:node.openingElement.tagName.getText(ast),node});
|
|
60
|
+
}
|
|
61
|
+
ts.forEachChild(node,visit);
|
|
62
|
+
}
|
|
63
|
+
visit(ast);return slots;
|
|
64
|
+
}
|
|
65
|
+
function defaultView(ast, filename, exportedName = 'default') {
|
|
66
|
+
const declarations = new Map();
|
|
67
|
+
for (const statement of ast.statements) {
|
|
68
|
+
if (ts.isFunctionDeclaration(statement) && statement.name) declarations.set(statement.name.text, statement);
|
|
69
|
+
if (ts.isVariableStatement(statement)) for (const declaration of statement.declarationList.declarations) if (ts.isIdentifier(declaration.name) && declaration.initializer) declarations.set(declaration.name.text, declaration.initializer);
|
|
70
|
+
}
|
|
71
|
+
let component;
|
|
72
|
+
if(exportedName==='default'){
|
|
73
|
+
component = ast.statements.find(node => ts.isFunctionDeclaration(node) && node.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword));
|
|
74
|
+
if (!component) { const exported = ast.statements.find(ts.isExportAssignment); component = exported?.expression; if (component && ts.isIdentifier(component)) component = declarations.get(component.text); }
|
|
75
|
+
}else{
|
|
76
|
+
const declared=declarations.get(exportedName);
|
|
77
|
+
const statement=ast.statements.find(node=>node===declared||ts.isVariableStatement(node)&&node.declarationList.declarations.some(item=>item.initializer===declared));
|
|
78
|
+
if(statement?.modifiers?.some(modifier=>modifier.kind===ts.SyntaxKind.ExportKeyword))component=declared;
|
|
79
|
+
}
|
|
80
|
+
if (!component || !(ts.isFunctionDeclaration(component) || ts.isArrowFunction(component) || ts.isFunctionExpression(component)) || !component.body) fail('Settings slots need an ordinary exported component with one unconditional JSX return', [filename]);
|
|
81
|
+
let expression = component.body;
|
|
82
|
+
if (ts.isBlock(expression)) {
|
|
83
|
+
const returns = [];
|
|
84
|
+
const collect = node => { if (node !== expression && ts.isFunctionLike(node)) return; if (ts.isReturnStatement(node)) returns.push(node); ts.forEachChild(node, collect); };
|
|
85
|
+
collect(expression);
|
|
86
|
+
if (returns.length !== 1 || returns[0].parent !== expression) fail('Settings slots need one unconditional component return', [filename]);
|
|
87
|
+
expression = returns[0].expression;
|
|
88
|
+
}
|
|
89
|
+
while (expression && (ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression))) expression = expression.expression;
|
|
90
|
+
if (!expression || !(ts.isJsxElement(expression) || ts.isJsxFragment(expression) || ts.isJsxSelfClosingElement(expression))) fail('Settings slots need an ordinary JSX component return', [filename]);
|
|
91
|
+
return expression;
|
|
92
|
+
}
|
|
93
|
+
// Review one actual root render, not merely an unused import containing a
|
|
94
|
+
// header. The supported imported slot is a default component mounted once in
|
|
95
|
+
// the root body; its native handlers and children remain untouched.
|
|
96
|
+
function validateSlotMounts(rootLayout, files, slots, app) {
|
|
97
|
+
const rootAst = parse(rootLayout, files.get(rootLayout)), rootView = defaultView(rootAst, rootLayout);
|
|
98
|
+
const rootBody = bodyOf(rootAst, rootLayout);
|
|
99
|
+
if (!(ts.isJsxElement(rootView) && rootView.openingElement.tagName.getText(rootAst) === 'html' && rootView.children.includes(rootBody))) fail('Settings layout must return the native html document with its direct body', [rootLayout]);
|
|
100
|
+
let childrenSlots=0;
|
|
101
|
+
const scanChildren=node=>{if(ts.isJsxExpression(node)&&node.expression&&(ts.isIdentifier(node.expression)&&node.expression.text==='children'||ts.isPropertyAccessExpression(node.expression)&&node.expression.name.text==='children'))childrenSlots++;ts.forEachChild(node,scanChildren);};scanChildren(rootBody);
|
|
102
|
+
if(childrenSlots!==1)fail('The reviewed root body needs one explicit native children slot so original and CMS pages retain their route content',[rootLayout]);
|
|
103
|
+
const imports = new Map();
|
|
104
|
+
for (const statement of rootAst.statements) if (ts.isImportDeclaration(statement) && statement.importClause && ts.isStringLiteral(statement.moduleSpecifier)) {
|
|
105
|
+
const specifier=statement.moduleSpecifier.text;
|
|
106
|
+
const base=specifier.startsWith('@/')?(app==='src/app'?'src/':'')+specifier.slice(2):specifier.startsWith('.')?path.posix.normalize(path.posix.join(path.posix.dirname(rootLayout),specifier)):null;
|
|
107
|
+
if(base){const choices=[base,...['.ts','.tsx','.js','.jsx','.mjs','.cjs','/index.ts','/index.tsx','/index.js','/index.jsx'].map(suffix=>base+suffix)];const filename=choices.find(name=>files.has(name));if(filename){if(statement.importClause.name)imports.set(statement.importClause.name.text,{filename,exportedName:'default'});if(statement.importClause.namedBindings&&ts.isNamedImports(statement.importClause.namedBindings))for(const item of statement.importClause.namedBindings.elements)if(!item.isTypeOnly)imports.set(item.name.text,{filename,exportedName:item.propertyName?.text??item.name.text});}}
|
|
108
|
+
}
|
|
109
|
+
for (const slot of slots) {
|
|
110
|
+
const ast=slot.path===rootLayout?rootAst:parse(slot.path,files.get(slot.path));
|
|
111
|
+
const actual=slotsOf(ast,slot.path).find(item=>item.kind===slot.kind).node;
|
|
112
|
+
const contains=view=>{let within=false;const search=node=>{if(node===actual)within=true;ts.forEachChild(node,search);};search(view);return within;};
|
|
113
|
+
if(slot.path===rootLayout){if(!contains(rootView))fail('A semantic settings slot is outside its exported component render',[slot.path]);continue;}
|
|
114
|
+
const names=[...imports].filter(([,value])=>value.filename===slot.path).filter(([,value])=>{try{return contains(defaultView(ast,slot.path,value.exportedName));}catch{return false;}}).map(([name])=>name);let mounts=0;
|
|
115
|
+
if(!names.length)fail('A semantic settings slot is outside its exported component render',[slot.path]);
|
|
116
|
+
const scan=node=>{if((ts.isJsxElement(node)||ts.isJsxSelfClosingElement(node))&&names.includes((ts.isJsxElement(node)?node.openingElement:node).tagName.getText(rootAst))){mounts++;for(let parent=node.parent;parent&&parent!==rootBody;parent=parent.parent)if(ts.isJsxExpression(parent)||ts.isCallExpression(parent)||ts.isConditionalExpression(parent))fail('A settings component is conditional or repeated in the root layout',[rootLayout]);}ts.forEachChild(node,scan);};scan(rootBody);
|
|
117
|
+
if(mounts!==1)fail('Import and mount each reviewed header/footer component exactly once in the root body',[slot.path,rootLayout]);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const slotFragment = kind => kind==='header' ? '<__UniversalCmsBranding/><__UniversalCmsNavigation location="header"/><__UniversalCmsLanguages/>' : '<__UniversalCmsNavigation location="footer"/><__UniversalCmsSocialLinks/><__UniversalCmsExperience/>';
|
|
121
|
+
function slotImport(kinds){return `\nimport { ${[...(kinds.includes('header')?['CmsBranding as __UniversalCmsBranding','CmsLanguages as __UniversalCmsLanguages']:[]),'CmsNavigation as __UniversalCmsNavigation',...(kinds.includes('footer')?['CmsSocialLinks as __UniversalCmsSocialLinks','CmsExperience as __UniversalCmsExperience']:[])].join(', ')} } from '@universalcms/connect/react-website';\n`;}
|
|
122
|
+
const layoutImport=(review)=>`\nimport __UniversalCmsLayout from ${JSON.stringify(relative(review.rootLayout,review.serverFile).replace(/\.[jt]sx?$/,''))};\n`;
|
|
123
|
+
function stripGenerated(text, filename, review) {
|
|
124
|
+
if(!text.includes('__UniversalCms'))return text;
|
|
125
|
+
const kinds=review.slots.filter(slot=>slot.path===filename).map(slot=>slot.kind);
|
|
126
|
+
const exact=[...(kinds.length?[slotImport(kinds),...kinds.map(slotFragment)]:[]),...(filename===review.rootLayout?[layoutImport(review),'<__UniversalCmsLayout>','</__UniversalCmsLayout>']:[])];
|
|
127
|
+
if(!exact.length||exact.some(value=>text.split(value).length!==2))fail('A reviewed settings layout adapter was customized; preserve it and review the exact changes',[filename]);
|
|
128
|
+
for(const value of exact)text=text.replace(value,'');
|
|
129
|
+
if(text.includes('__UniversalCms'))fail('A reviewed settings layout adapter contains unknown generated bindings',[filename]);
|
|
130
|
+
return text;
|
|
131
|
+
}
|
|
132
|
+
function originalText(text, file, review) {
|
|
133
|
+
text=stripGenerated(text,file.path,review);
|
|
134
|
+
const candidates=[text];
|
|
135
|
+
if(file.path===review.rootLayout&&text.split(script).length===2)candidates.push(text.replace(script,''),text.replace('<head>'+script+'</head>',''));
|
|
136
|
+
const original=candidates.find(value=>digest(value)===file.sha256);
|
|
137
|
+
if(original===undefined)fail('A reviewed layout dependency changed. Run layout review again before setup; existing files were preserved',[file.path]);
|
|
138
|
+
return original;
|
|
139
|
+
}
|
|
140
|
+
async function discover(root, overrides=new Map()) {
|
|
141
|
+
const read=async name=>overrides.has(name)?overrides.get(name):ordinary(root,name);
|
|
142
|
+
const configs=[];for(const name of configurationFiles)configs.push({path:name,content:await ordinary(root,name),state:'unchanged'});
|
|
143
|
+
for(const file of configs)if(file.content===null)file.state='absent';
|
|
144
|
+
let pkg;try{pkg=JSON.parse(configs[0].content);}catch{fail('Layout review needs a valid package.json');}
|
|
145
|
+
if(!/^(?:\^|~)?16\.\d+\.\d+$/.test(pkg.dependencies?.next??pkg.devDependencies?.next??'')||pkg.imports||pkg.workspaces)fail('Layout review supports one ordinary Next.js 16 App Router project');
|
|
146
|
+
const roots=[];for(const app of ['app','src/app'])for(const ext of ['tsx','jsx','ts','js'])if(await read(`${app}/layout.${ext}`)!==null)roots.push(`${app}/layout.${ext}`);
|
|
147
|
+
if(roots.length!==1)fail('Layout review needs one unambiguous App Router root layout',roots);
|
|
148
|
+
const rootLayout=roots[0],app=path.posix.dirname(rootLayout),serverFile=`${app}/universalcms.layout.server.tsx`;
|
|
149
|
+
let alias=false,count=0;
|
|
150
|
+
for(const file of configs.filter(file=>['tsconfig.json','jsconfig.json'].includes(file.path)&&file.content!==null)){
|
|
151
|
+
const parsed=ts.parseConfigFileTextToJson(file.path,file.content),options=parsed.config?.compilerOptions??{},expected=app==='src/app'?'./src/*':'./*';
|
|
152
|
+
if(++count>1||parsed.error||parsed.config.extends||parsed.config.references||options.baseUrl!==undefined||options.rootDirs||options.moduleSuffixes||options.paths&&(Object.keys(options.paths).join()!=='@/*'||JSON.stringify(options.paths['@/*'])!==JSON.stringify([expected])))fail('Custom module resolution requires an explicit layout adapter',[file.path]);
|
|
153
|
+
alias=!!options.paths;
|
|
154
|
+
}
|
|
155
|
+
if(configs.some(file=>file.path.startsWith('next.config.')&&/\b(?:webpack|resolveAlias|alias)\b/.test(file.content??'')))fail('Custom module aliases require an explicit layout adapter');
|
|
156
|
+
const seen=new Map(),slots=[];let bytes=0;
|
|
157
|
+
async function visit(filename){
|
|
158
|
+
if(seen.has(filename))return;
|
|
159
|
+
if(!isNativeReviewFilePath(filename))fail('A layout dependency is outside ordinary public application source',[filename]);
|
|
160
|
+
const text=await read(filename);if(text===null)fail('A layout dependency is missing',[filename]);
|
|
161
|
+
bytes+=Buffer.byteLength(text);if(seen.size>=200||bytes>8*1024*1024)fail('Layout review exceeded its bounded source budget');
|
|
162
|
+
seen.set(filename,text);
|
|
163
|
+
if(!/\.[cm]?[jt]sx?$/.test(filename))return;
|
|
164
|
+
if(text.includes('__UniversalCms'))fail('Reserved generated settings names already exist',[filename]);
|
|
165
|
+
const ast=parse(filename,text);if(filename===rootLayout)bodyOf(ast,filename);
|
|
166
|
+
slots.push(...slotsOf(ast,filename).map(({kind})=>({path:filename,kind,mode:'append'})));
|
|
167
|
+
const imports=[];
|
|
168
|
+
function scan(node){
|
|
169
|
+
if((ts.isImportDeclaration(node)||ts.isExportDeclaration(node))&&node.moduleSpecifier&&ts.isStringLiteral(node.moduleSpecifier))imports.push(node.moduleSpecifier.text);
|
|
170
|
+
if(ts.isCallExpression(node)&&(node.expression.kind===ts.SyntaxKind.ImportKeyword||ts.isIdentifier(node.expression)&&node.expression.text==='require'))fail('Dynamic module loading needs an explicit layout adapter',[filename]);
|
|
171
|
+
ts.forEachChild(node,scan);
|
|
172
|
+
}scan(ast);
|
|
173
|
+
for(const specifier of imports){
|
|
174
|
+
if(specifier.startsWith('#')||specifier.startsWith('/')||specifier.includes('\\')||specifier.startsWith('@/')&&!alias)fail('Unknown module aliases need an explicit layout adapter',[filename]);
|
|
175
|
+
if(!specifier.startsWith('.')&&!specifier.startsWith('@/')){
|
|
176
|
+
const packageName=specifier.startsWith('@')?specifier.split('/').slice(0,2).join('/'):specifier.split('/')[0];
|
|
177
|
+
if(!specifier.startsWith('node:')&&!Object.hasOwn(pkg.dependencies??{},packageName)&&!Object.hasOwn(pkg.devDependencies??{},packageName))fail('A layout import is not a declared dependency',[filename]);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const base=specifier.startsWith('@/')?(app==='src/app'?'src/':'')+specifier.slice(2):path.posix.normalize(path.posix.join(path.posix.dirname(filename),specifier));
|
|
181
|
+
const candidates=(/\.(?:[cm]?[jt]sx?|json|css)$/.test(base)?[base]:[base,...['.ts','.tsx','.js','.jsx','.mjs','.cjs','.mts','.cts','.json','/index.ts','/index.tsx','/index.js','/index.jsx'].map(suffix=>base+suffix)]).filter(isNativeReviewFilePath),found=[];
|
|
182
|
+
for(const candidate of candidates){try{if(await read(candidate)!==null)found.push(candidate);}catch(error){if(candidate!==base||candidates.length===1||error.message!=='A setup path has an incompatible file type.')throw error;}}
|
|
183
|
+
|
|
184
|
+
if(found.length!==1)fail('A local layout dependency is missing or ambiguous',[filename]);
|
|
185
|
+
await visit(found[0]);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
await visit(rootLayout);
|
|
189
|
+
if(slots.length!==2||slots.filter(slot=>slot.kind==='header').length!==1||slots.filter(slot=>slot.kind==='footer').length!==1)fail('Layout review needs exactly one native header and one native footer. Keep mobile controls inside that header; ambiguous layouts need explicit integration',[...seen.keys()].filter(name=>/\.[jt]sx?$/.test(name)));
|
|
190
|
+
const safeSlots=sanitizeSetupPaths(slots.map(slot=>slot.path)).paths;
|
|
191
|
+
if(slots.some(slot=>!safeSlots.includes(slot.path)))fail('Settings slot paths must be safe public source paths');
|
|
192
|
+
validateSlotMounts(rootLayout,seen,slots,app);
|
|
193
|
+
const files=[...seen].sort(([a],[b])=>a.localeCompare(b)).map(([path,content])=>({path,content,state:'unchanged'}));
|
|
194
|
+
return {root,app,rootLayout,serverFile,slots:slots.sort((a,b)=>a.kind.localeCompare(b.kind)),files,configs};
|
|
195
|
+
}
|
|
196
|
+
export async function planSettingsLayoutReview(directory){
|
|
197
|
+
const root=await projectRoot(directory);
|
|
198
|
+
for(const name of installedFiles)if(await ordinary(root,name)!==null)fail('An integration is already installed. Review its adapter update without reassigning native layout ownership');
|
|
199
|
+
const found=await discover(root);
|
|
200
|
+
if(await ordinary(root,found.serverFile)!==null)fail('The generated settings server module path is occupied',[found.serverFile]);
|
|
201
|
+
const value={version:1,profile:PROFILE,app:found.app,rootLayout:found.rootLayout,serverFile:found.serverFile,slots:found.slots,files:found.files.map(file=>({path:file.path,sha256:digest(file.content)})),configuration:found.configs.filter(file=>file.path!=='package.json').map(file=>({path:file.path,sha256:file.content===null?null:digest(file.content),preparedSha256:preparedConfigurationHash(file)}))};
|
|
202
|
+
const old=await ordinary(root,SETTINGS_LAYOUT_FILE),content=json(value);
|
|
203
|
+
return {root,framework:'next',profile:PROFILE,slots:found.slots,files:[...found.files,...found.configs,...installedFiles.map(path=>({path,content:null,state:'absent'})),{path:found.serverFile,content:null,state:'absent'},{path:SETTINGS_LAYOUT_FILE,content,state:old===null?'create':old===content?'unchanged':'update',...(old!==null&&old!==content?{previous:old}:{})}]};
|
|
204
|
+
}
|
|
205
|
+
export async function inspectSettingsLayoutReview(directory){
|
|
206
|
+
const root=await projectRoot(directory),text=await ordinary(root,SETTINGS_LAYOUT_FILE);if(text===null)return null;
|
|
207
|
+
let value;try{value=JSON.parse(text);}catch{fail('Invalid settings layout review',[SETTINGS_LAYOUT_FILE]);}
|
|
208
|
+
if(!value||Object.keys(value).sort().join()!=='app,configuration,files,profile,rootLayout,serverFile,slots,version'||value.version!==1||value.profile!==PROFILE||!['app','src/app'].includes(value.app)||!['tsx','jsx','ts','js'].some(ext=>value.rootLayout===`${value.app}/layout.${ext}`)||value.serverFile!==`${value.app}/universalcms.layout.server.tsx`||!Array.isArray(value.files)||!value.files.length||value.files.length>200||new Set(value.files.map(file=>file?.path)).size!==value.files.length||value.files.some(file=>!file||Object.keys(file).sort().join()!=='path,sha256'||!isNativeReviewFilePath(file.path)||!/^[a-f0-9]{64}$/.test(file.sha256))||!Array.isArray(value.slots)||value.slots.length!==2||value.slots.some(slot=>!slot||Object.keys(slot).sort().join()!=='kind,mode,path'||!['header','footer'].includes(slot.kind)||slot.mode!=='append'||!value.files.some(file=>file.path===slot.path))||!Array.isArray(value.configuration)||value.configuration.length!==configurationFiles.length-1)fail('Invalid bounded settings layout review',[SETTINGS_LAYOUT_FILE]);
|
|
209
|
+
for(let i=0;i<value.configuration.length;i++){const row=value.configuration[i];if(!row||Object.keys(row).sort().join()!=='path,preparedSha256,sha256'||row.path!==configurationFiles[i+1]||row.sha256!==null&&!/^[a-f0-9]{64}$/.test(row.sha256)||row.preparedSha256!==null&&!/^[a-f0-9]{64}$/.test(row.preparedSha256))fail('Invalid settings layout configuration review',[SETTINGS_LAYOUT_FILE]);const content=await ordinary(root,row.path);if(![row.sha256,row.preparedSha256].includes(content===null?null:digest(content))&&!(row.path==='tsconfig.json'&&row.preparedSha256!==null&&typescriptConfigurationHash(content)===row.preparedSha256))fail('Module resolution changed after layout review',[row.path]);}
|
|
210
|
+
const originals=new Map(),observations=[];
|
|
211
|
+
for(const file of value.files){const content=await ordinary(root,file.path);if(content===null)fail('A reviewed layout dependency is missing',[file.path]);originals.set(file.path,originalText(content,file,value));observations.push({path:file.path,content,state:'unchanged'});}
|
|
212
|
+
const found=await discover(root,originals);
|
|
213
|
+
if(found.rootLayout!==value.rootLayout||JSON.stringify(found.slots)!==JSON.stringify(value.slots)||JSON.stringify(found.files.map(file=>({path:file.path,sha256:digest(file.content)})))!==JSON.stringify(value.files))fail('Layout review no longer matches its source dependency closure',[SETTINGS_LAYOUT_FILE]);
|
|
214
|
+
return {...value,root,originals,observations:[...observations,...found.configs,{path:SETTINGS_LAYOUT_FILE,content:text,state:'unchanged'}]};
|
|
215
|
+
}
|
|
216
|
+
function serverTemplate(review,config,native){return `// Generated settings owner for the reviewed native root layout. No private value enters props.\nimport type { ReactNode } from 'react';\nimport { connection } from 'next/server';\nimport { headers } from 'next/headers';\nimport { WebsiteProvider } from '@universalcms/connect/react-website';\nimport { createWebsiteRuntime } from '@universalcms/connect/website-runtime';\nimport '@universalcms/connect/website.css';\nimport configuration from ${JSON.stringify(relative(review.serverFile,'.universalcms/publication.config.json'))};\nconst runtime = createWebsiteRuntime({ configuration: configuration as Parameters<typeof createWebsiteRuntime>[0]['configuration']${native ? ', nativePaths: '+JSON.stringify(native.nativePaths)+', nativePatterns: '+JSON.stringify(native.nativePatterns) : ''} });\nexport default async function UniversalCmsLayout({children}: {children: ReactNode}) {\n await connection();\n const current = (await headers()).get('x-universalcms-layout-path');\n let pathname: string | null = null;\n try { if (current) pathname = decodeURIComponent(current); } catch {}\n const [snapshot, page] = await Promise.all([runtime.settings(), pathname ? runtime.page(pathname) : Promise.resolve(null)]);\n return <WebsiteProvider scope=${JSON.stringify(config.siteId+':'+config.environmentId)} initialSettings={snapshot} entry={page?.state === 'published' ? page.entry : undefined}>{children}</WebsiteProvider>;\n}\n`;}
|
|
217
|
+
function transform(text,filename,review){
|
|
218
|
+
const ast=parse(filename,text),edits=[],kinds=review.slots.filter(slot=>slot.path===filename).map(slot=>slot.kind);
|
|
219
|
+
let insertion=0;for(const statement of ast.statements){if(ts.isExpressionStatement(statement)&&ts.isStringLiteral(statement.expression))insertion=statement.end;else break;}
|
|
220
|
+
let imports='';if(kinds.length){imports+=slotImport(kinds);for(const {kind,node} of slotsOf(ast,filename))edits.push({start:node.closingElement.pos,end:node.closingElement.pos,text:slotFragment(kind)});}
|
|
221
|
+
if(filename===review.rootLayout){imports+=layoutImport(review);const body=bodyOf(ast,filename);edits.push({start:body.openingElement.end,end:body.openingElement.end,text:'<__UniversalCmsLayout>'},{start:body.closingElement.pos,end:body.closingElement.pos,text:'</__UniversalCmsLayout>'});}
|
|
222
|
+
if(imports)edits.push({start:insertion,end:insertion,text:imports});
|
|
223
|
+
for(const edit of edits.sort((a,b)=>b.start-a.start))text=text.slice(0,edit.start)+edit.text+text.slice(edit.end);
|
|
224
|
+
return text;
|
|
225
|
+
}
|
|
226
|
+
export async function planNextSettingsLayout(directory,config,native=null){
|
|
227
|
+
const review=await inspectSettingsLayoutReview(directory);if(!review)return null;
|
|
228
|
+
const paths=new Set([review.rootLayout,...review.slots.map(slot=>slot.path)]),files=[];
|
|
229
|
+
for(const filename of paths){const current=await ordinary(review.root,filename),base=stripGenerated(current,filename,review),content=transform(base,filename,review);files.push({path:filename,content,state:current===content?'unchanged':'update',...(current!==content?{previous:current}:{})});}
|
|
230
|
+
const content=serverTemplate(review,config,native),old=await ordinary(review.root,review.serverFile);if(old!==null&&old!==content)fail('The native settings server adapter was customized',[review.serverFile]);
|
|
231
|
+
files.push({path:review.serverFile,content,state:old===null?'create':'unchanged'});
|
|
232
|
+
return {review,files,observations:review.observations};
|
|
233
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Bounded public static-page discovery. No delivery credentials are accepted. */
|
|
2
|
+
export declare function createNextSourceHandler(options: {
|
|
3
|
+
sourceRoot: string;
|
|
4
|
+
configuration: unknown;
|
|
5
|
+
snapshot: unknown;
|
|
6
|
+
/** Optional reviewed public documents retained alongside automatic pages. */
|
|
7
|
+
baseManifest?: () => unknown | Promise<unknown>;
|
|
8
|
+
}): (request: Request) => Promise<Response>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createSourceManifestProvider } from './vendor/source-manifest-provider.mjs';
|
|
2
|
+
import { validateSourceConfiguration } from './source-config.mjs';
|
|
3
|
+
import { REVIEWED_ICECREAM_PROFILE, reviewedNativePaths } from './content-registry-validation.mjs';
|
|
4
|
+
|
|
5
|
+
const plain = value => value && typeof value === 'object' && !Array.isArray(value);
|
|
6
|
+
function diagnosticsFor(snapshot) {
|
|
7
|
+
if (!plain(snapshot) || Object.keys(snapshot).sort().join() !== 'diagnostics,entries,version' || snapshot.version !== 1 || !Array.isArray(snapshot.entries) || snapshot.entries.length > 1000) throw Error('Invalid source snapshot');
|
|
8
|
+
const value = snapshot.diagnostics;
|
|
9
|
+
const keys = plain(value) && Object.hasOwn(value, 'nativeOwnership')
|
|
10
|
+
? 'automaticPages,discoveredPages,nativeOwnership,skippedPages,unsupportedRoutes,warnings'
|
|
11
|
+
: 'automaticPages,discoveredPages,skippedPages,unsupportedRoutes,warnings';
|
|
12
|
+
if (!plain(value) || Object.keys(value).sort().join() !== keys ||
|
|
13
|
+
!['automaticPages', 'discoveredPages', 'skippedPages', 'unsupportedRoutes'].every(key => Number.isSafeInteger(value[key]) && value[key] >= 0 && value[key] <= 1000) ||
|
|
14
|
+
value.automaticPages !== snapshot.entries.length || !Array.isArray(value.warnings) || value.warnings.length > 1000 ||
|
|
15
|
+
value.warnings.some(item => !plain(item) || Object.keys(item).sort().join() !== 'codes,slug' || typeof item.slug !== 'string' || item.slug.length > 160 || !/^[a-z0-9]+(?:[-/][a-z0-9]+)*$/.test(item.slug) || !Array.isArray(item.codes) || item.codes.length > 30 || item.codes.some(code => typeof code !== 'string' || !/^[a-z-]{1,80}$/.test(code)))) throw Error('Invalid source diagnostics');
|
|
16
|
+
if (Object.hasOwn(value, 'nativeOwnership')) {
|
|
17
|
+
const native = value.nativeOwnership;
|
|
18
|
+
if (!plain(native) || Object.keys(native).sort().join() !== 'paths,profile' || native.profile !== REVIEWED_ICECREAM_PROFILE ||
|
|
19
|
+
!Array.isArray(native.paths) || JSON.stringify(native.paths) !== JSON.stringify(reviewedNativePaths(native.profile))) throw Error('Invalid native source diagnostics');
|
|
20
|
+
}
|
|
21
|
+
return { warnings: value.warnings.length, unsupported: value.unsupportedRoutes };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Next 16 can construct an internal localhost Request.url while retaining the
|
|
25
|
+
* original 127.0.0.1/[::1] Host header. Normalize only that observed loopback
|
|
26
|
+
* alias, never an arbitrary forwarded or hosted origin. The generic provider
|
|
27
|
+
* still enforces exact Host, CMS Origin, scope and credential rejection. */
|
|
28
|
+
function nextSourceRequest(request, source) {
|
|
29
|
+
// Read framework getters outside error handling so Next runtime signals pass
|
|
30
|
+
// through unchanged instead of turning into ordinary source errors.
|
|
31
|
+
const method = request.method, headers = request.headers, raw = request.url;
|
|
32
|
+
let url = raw;
|
|
33
|
+
if (headers.get('host') === source.host && ['127.0.0.1', '[::1]'].includes(source.hostname)) {
|
|
34
|
+
let parsed;
|
|
35
|
+
try { parsed = new URL(raw); } catch { /* The provider rejects malformed URLs. */ }
|
|
36
|
+
if (parsed && parsed.hostname === 'localhost' && parsed.protocol === source.protocol && parsed.port === source.port && !parsed.username && !parsed.password) {
|
|
37
|
+
parsed.hostname = source.hostname;
|
|
38
|
+
url = parsed.href;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { method, url, headers };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function respond(provider, request) {
|
|
45
|
+
let status = 404, headers = { 'Cache-Control': 'no-store' }, body;
|
|
46
|
+
provider({ method: request.method, url: request.url, headers: Object.fromEntries(request.headers.entries()) }, {
|
|
47
|
+
writeHead(code, values) { status = code; headers = values; }, end(value) { body = value; },
|
|
48
|
+
});
|
|
49
|
+
return new Response(request.method === 'HEAD' || status === 204 ? null : body, { status, headers });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Dev reads route sources afresh; production uses the snapshot refreshed by prebuild. */
|
|
53
|
+
export function createNextSourceHandler({ sourceRoot, configuration, snapshot, baseManifest = () => ({ version: 1, entries: [] }) }) {
|
|
54
|
+
const config = validateSourceConfiguration(configuration), source = new URL(config.websiteOrigin);
|
|
55
|
+
const binding = { siteId: config.siteId, environmentId: config.environmentId, sourceOrigin: config.websiteOrigin, adminOrigin: config.adminOrigin, publicContent: true };
|
|
56
|
+
const probe = createSourceManifestProvider({ ...binding, manifestJson: '{"version":1,"entries":[]}' });
|
|
57
|
+
let pending;
|
|
58
|
+
async function read() {
|
|
59
|
+
const base = await baseManifest();
|
|
60
|
+
createSourceManifestProvider({ ...binding, manifestJson: JSON.stringify(base) });
|
|
61
|
+
const baseIds = new Set(base.entries.map(entry => `${entry.kind}:${entry.locale}:${entry.slug}`));
|
|
62
|
+
if (config.skipSlugs.some(slug => !baseIds.has(`page:${config.locale}:${slug}`))) throw Error('Missing reviewed source document');
|
|
63
|
+
// Keep the compiler/inspection modules out of the production route bundle.
|
|
64
|
+
const generated = process.env.NODE_ENV === 'production' ? snapshot : await (await import('./source-collect.mjs')).collectSource({ root: sourceRoot, locale: config.locale, skipSlugs: config.skipSlugs, excludedRoutes: config.excludedRoutes });
|
|
65
|
+
const { warnings, unsupported } = diagnosticsFor(generated);
|
|
66
|
+
const entries = [...base.entries, ...generated.entries.filter(entry => !baseIds.has(`${entry.kind}:${entry.locale}:${entry.slug}`))];
|
|
67
|
+
const provider = createSourceManifestProvider({ ...binding, manifestJson: JSON.stringify({ version: 1, entries }) });
|
|
68
|
+
return { provider, warnings, unsupported };
|
|
69
|
+
}
|
|
70
|
+
return async function handle(request) {
|
|
71
|
+
// Request getters remain outside the catch so Next runtime signals propagate.
|
|
72
|
+
const incoming = nextSourceRequest(request, source);
|
|
73
|
+
const gate = respond(probe, incoming);
|
|
74
|
+
if (gate.status !== 200 || !['GET', 'HEAD'].includes(request.method)) return gate;
|
|
75
|
+
if (config.exportClosed) return new Response(request.method === 'HEAD' ? null : JSON.stringify({ version: 1, siteId: config.siteId, environmentId: config.environmentId, sourceExport: 'closed' }), {
|
|
76
|
+
status: 410, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'Access-Control-Allow-Origin': config.adminOrigin, 'Vary': 'Origin', 'X-Content-Type-Options': 'nosniff' },
|
|
77
|
+
});
|
|
78
|
+
try {
|
|
79
|
+
// Collapse concurrent scans only; never cache completed source inventories.
|
|
80
|
+
pending ??= read().finally(() => { pending = undefined; });
|
|
81
|
+
const result = await pending;
|
|
82
|
+
const response = respond(result.provider, incoming);
|
|
83
|
+
response.headers.set('Access-Control-Expose-Headers', 'ETag, X-UniversalCMS-Source-Warnings, X-UniversalCMS-Source-Omitted');
|
|
84
|
+
response.headers.set('X-UniversalCMS-Source-Warnings', String(result.warnings));
|
|
85
|
+
response.headers.set('X-UniversalCMS-Source-Omitted', String(result.unsupported));
|
|
86
|
+
return response;
|
|
87
|
+
} catch {
|
|
88
|
+
return new Response(request.method === 'HEAD' ? null : JSON.stringify({ error: 'Automatic source export could not be prepared. Run universalcms-connect sync-source in the website project and review its diagnostics.' }), {
|
|
89
|
+
status: 503, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'Access-Control-Allow-Origin': config.adminOrigin, 'Vary': 'Origin', 'X-Content-Type-Options': 'nosniff' },
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|