@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,227 @@
|
|
|
1
|
+
/** Explicit source-import profile for one reviewed storefront revision.
|
|
2
|
+
* This is not generic route discovery or a published-content rendering adapter.
|
|
3
|
+
* Every customer read goes through the caller's bounded, observed readFile.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import ts from './vendor/typescript.cjs';
|
|
7
|
+
import { SetupError } from './core.mjs';
|
|
8
|
+
|
|
9
|
+
const PROFILE = 'reviewed-storefront-source-2026-10-15';
|
|
10
|
+
// Fingerprints, never bundled customer source. Changes require a fresh review.
|
|
11
|
+
const fingerprints = Object.freeze({
|
|
12
|
+
'index.html': 'bc20c88e81b7213ee67d225197c85131492d6f954390af95ba9eeb8d9bfe3bb9',
|
|
13
|
+
'src/main.jsx': '4b4b864dbe45253425d0c925337180ec07e204295179f80f59551216f466f618',
|
|
14
|
+
'vite.config.js': 'efd5952d59c7b239a390fb094d525430ccb9a1b4f4f229b5e891bac32315f642',
|
|
15
|
+
'src/App.jsx': 'bda6a8c9dd7b508248f034597fbc4c87b5efa288ae9418a1b968187b5e8c21b6',
|
|
16
|
+
'src/components/InfoPage.jsx': '285313faaad41e305f084670d34c304b1a01312dd9c692828d52d5aed414890e',
|
|
17
|
+
'src/components/Hero.jsx': '05496ae3b63eacfd2899c4160bff1eca0dc9a7961a1848e6c85f176235e7c12c',
|
|
18
|
+
'src/components/CategoryPills.jsx': 'd7c98a79f5e13aaef036737cddd11aa4482277d09ffda5426db54736fc4fe04f',
|
|
19
|
+
'src/components/ProductGrid.jsx': 'ae593c01b5b88a7eeebe7c85b5b876ef18e29092d96b8fd88d82a561ad12e55f',
|
|
20
|
+
'src/components/ProductCard.jsx': '081a1e061c62ae7bdd66670fc27f5c4fda1838f14ba16ff99a26b0ad33727797',
|
|
21
|
+
'src/components/ProductModal.jsx': '23727e50b42e0604948b7a0b52052941a66842bec4ce49262946a20efba56ecb',
|
|
22
|
+
'src/components/LookbookSection.jsx': '4ea13901e31e8c0dbca85feca621368dc3ce055d3d13f97ac4df762d2b65c6c3',
|
|
23
|
+
'src/components/TestimonialsSection.jsx': '5829cc1e5a6e181d992498865564cae9501627903cb44a25c8343b7abfbf71b0',
|
|
24
|
+
'src/components/Newsletter.jsx': '6ec2f5c7ae113977213d472b504fa45e6191e1000c64181fb13223bf95bbb250',
|
|
25
|
+
'src/data/products.js': '714e73ae6108949f18dc07d1c1ecbe478e3407ba559dd8ba0d0b8be5170da403',
|
|
26
|
+
});
|
|
27
|
+
const collectionRoutes = ['outerwear', 'streetwear', 'tailoring', 'dresses', 'accessories'];
|
|
28
|
+
const infoRoutes = ['about', 'lookbook', 'sustainability', 'care-guide', 'size-guide', 'returns'];
|
|
29
|
+
const fail = message => { throw new SetupError(`The reviewed storefront source profile could not be prepared: ${message}. Existing website files were preserved.`); };
|
|
30
|
+
const digest = value => createHash('sha256').update(value).digest('hex');
|
|
31
|
+
const normalize = value => value.replace(/\s+/g, ' ').trim();
|
|
32
|
+
|
|
33
|
+
// Stable IDs retain the previous reviewed import's identities for unchanged slots.
|
|
34
|
+
export function reviewedStorefrontBlockId(key) {
|
|
35
|
+
const hex = digest(`clothes-selling-reviewed-source-v1:${key}`);
|
|
36
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-${((parseInt(hex[16], 16) & 3) | 8).toString(16)}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
37
|
+
}
|
|
38
|
+
const blockId = reviewedStorefrontBlockId;
|
|
39
|
+
|
|
40
|
+
/** Returns null unless the exact reviewed public source layout matches.
|
|
41
|
+
* The caller must opt into this source-only profile and retain these observations
|
|
42
|
+
* until apply. Neither this function nor its result authorizes CMS writes.
|
|
43
|
+
*/
|
|
44
|
+
export async function discoverReviewedStorefrontSource(root, { locale = 'en', readFile }) {
|
|
45
|
+
if (typeof root !== 'string' || typeof readFile !== 'function') fail('an observed project reader is required');
|
|
46
|
+
try { if (typeof locale !== 'string' || locale.length > 35 || Intl.getCanonicalLocales(locale)[0] !== locale) throw Error(); }
|
|
47
|
+
catch { fail('a canonical content locale is required'); }
|
|
48
|
+
const sources = new Map();
|
|
49
|
+
let bytes = 0;
|
|
50
|
+
for (const [filename, expected] of Object.entries(fingerprints)) {
|
|
51
|
+
const source = await readFile(filename);
|
|
52
|
+
if (source === null || typeof source !== 'string' || Buffer.byteLength(source) > 128 * 1024) return null;
|
|
53
|
+
bytes += Buffer.byteLength(source);
|
|
54
|
+
if (bytes > 2 * 1024 * 1024 || digest(source) !== expected) return null;
|
|
55
|
+
sources.set(filename, source);
|
|
56
|
+
}
|
|
57
|
+
const absent = ['vite.config.mjs', 'vite.config.cjs', 'vite.config.ts', 'vite.config.mts', 'vite.config.cts'];
|
|
58
|
+
// Extensionless imports must still resolve to the reviewed module, not a newly
|
|
59
|
+
// added sibling that Vite resolves first. Negative reads are observations too.
|
|
60
|
+
for (const filename of Object.keys(fingerprints).filter(value => value.startsWith('src/components/') || value === 'src/data/products.js')) {
|
|
61
|
+
const stem = filename.replace(/\.(?:jsx|js)$/, '');
|
|
62
|
+
for (const extension of ['mjs', 'js', 'mts', 'ts', 'jsx', 'tsx', 'json']) {
|
|
63
|
+
const candidate = `${stem}.${extension}`;
|
|
64
|
+
if (candidate !== filename) absent.push(candidate);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const filename of absent) if (await readFile(filename) !== null) return null;
|
|
68
|
+
|
|
69
|
+
const ast = new Map();
|
|
70
|
+
for (const [filename, source] of sources) {
|
|
71
|
+
if (!filename.startsWith('src/') || !/\.(?:jsx|js)$/.test(filename)) continue;
|
|
72
|
+
const file = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true, filename.endsWith('.jsx') ? ts.ScriptKind.JSX : ts.ScriptKind.JS);
|
|
73
|
+
if (file.parseDiagnostics.length) fail('a reviewed source file no longer parses');
|
|
74
|
+
ast.set(filename, file);
|
|
75
|
+
}
|
|
76
|
+
// Only literal values are read. No calls, getters, imports, object spreads,
|
|
77
|
+
// interpolated templates, browser state or customer functions are evaluated.
|
|
78
|
+
function literal(node, depth = 0) {
|
|
79
|
+
if (!node || depth > 20) fail('a selected constant is not a bounded literal');
|
|
80
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
|
|
81
|
+
if (ts.isNumericLiteral(node) && Number.isFinite(Number(node.text))) return Number(node.text);
|
|
82
|
+
if (node.kind === ts.SyntaxKind.TrueKeyword) return true;
|
|
83
|
+
if (node.kind === ts.SyntaxKind.FalseKeyword) return false;
|
|
84
|
+
if (node.kind === ts.SyntaxKind.NullKeyword) return null;
|
|
85
|
+
if (ts.isArrayLiteralExpression(node) && node.elements.length < 1000) return node.elements.map(item => literal(item, depth + 1));
|
|
86
|
+
if (ts.isObjectLiteralExpression(node) && node.properties.length < 1000) {
|
|
87
|
+
const result = Object.create(null);
|
|
88
|
+
for (const property of node.properties) {
|
|
89
|
+
if (!ts.isPropertyAssignment(property) || !property.name || ts.isComputedPropertyName(property.name)) fail('a selected object has an unsupported property');
|
|
90
|
+
const key = property.name.text;
|
|
91
|
+
if (typeof key !== 'string' || ['__proto__', 'constructor', 'prototype'].includes(key) || Object.hasOwn(result, key)) fail('a selected object has an unsafe key');
|
|
92
|
+
if (key === 'icon' && ts.isIdentifier(property.initializer)) continue;
|
|
93
|
+
result[key] = literal(property.initializer, depth + 1);
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
fail('a selected value requires executing website code');
|
|
98
|
+
}
|
|
99
|
+
function constant(filename, name) {
|
|
100
|
+
const found = ast.get(filename).statements.flatMap(statement => ts.isVariableStatement(statement) ? [...statement.declarationList.declarations] : [])
|
|
101
|
+
.filter(declaration => ts.isIdentifier(declaration.name) && declaration.name.text === name);
|
|
102
|
+
if (found.length !== 1) fail('a reviewed public constant is missing or ambiguous');
|
|
103
|
+
return literal(found[0].initializer);
|
|
104
|
+
}
|
|
105
|
+
function jsxText(node) {
|
|
106
|
+
if (ts.isJsxText(node)) return node.text;
|
|
107
|
+
if (ts.isJsxExpression(node)) return node.expression ? String(literal(node.expression)) : '';
|
|
108
|
+
if (ts.isJsxSelfClosingElement(node)) {
|
|
109
|
+
const tag = node.tagName.getText();
|
|
110
|
+
if (/^[A-Z]/.test(tag)) return ''; // Fingerprinted decorative icon.
|
|
111
|
+
if (tag === 'br') return ' ';
|
|
112
|
+
fail('selected public text has an unsupported child');
|
|
113
|
+
}
|
|
114
|
+
if (ts.isJsxElement(node) && ['span', 'strong', 'em', 'b', 'i'].includes(node.openingElement.tagName.getText())) return node.children.map(jsxText).join('');
|
|
115
|
+
fail('selected public text is not literal');
|
|
116
|
+
}
|
|
117
|
+
function texts(filename, className) {
|
|
118
|
+
const found = [];
|
|
119
|
+
function visit(node) {
|
|
120
|
+
if (ts.isJsxElement(node)) {
|
|
121
|
+
const attribute = node.openingElement.attributes.properties.find(item => ts.isJsxAttribute(item) && item.name.getText() === 'className');
|
|
122
|
+
if (attribute?.initializer && ts.isStringLiteral(attribute.initializer) && attribute.initializer.text.split(/\s+/).includes(className)) found.push(normalize(node.children.map(jsxText).join('')));
|
|
123
|
+
}
|
|
124
|
+
ts.forEachChild(node, visit);
|
|
125
|
+
}
|
|
126
|
+
visit(ast.get(filename));
|
|
127
|
+
if (!found.length || found.some(value => !value)) fail('reviewed public text is missing');
|
|
128
|
+
return found;
|
|
129
|
+
}
|
|
130
|
+
function text(filename, className) {
|
|
131
|
+
const values = texts(filename, className);
|
|
132
|
+
if (values.length !== 1) fail('reviewed public text is ambiguous');
|
|
133
|
+
return values[0];
|
|
134
|
+
}
|
|
135
|
+
const hero = 'src/components/Hero.jsx', categories = 'src/components/CategoryPills.jsx';
|
|
136
|
+
const grid = 'src/components/ProductGrid.jsx', lookbook = 'src/components/LookbookSection.jsx';
|
|
137
|
+
const testimonials = 'src/components/TestimonialsSection.jsx', newsletter = 'src/components/Newsletter.jsx';
|
|
138
|
+
const info = constant('src/components/InfoPage.jsx', 'PAGE_DATA');
|
|
139
|
+
const products = constant('src/data/products.js', 'PRODUCTS');
|
|
140
|
+
const reviews = constant('src/data/products.js', 'REVIEWS_HIGHLIGHTS');
|
|
141
|
+
const looks = constant(lookbook, 'LOOKS'), tiles = constant(categories, 'FEATURED_TILES');
|
|
142
|
+
const categoryMeta = constant('src/App.jsx', 'CATEGORY_META');
|
|
143
|
+
if (Object.keys(info).sort().join() !== [...infoRoutes].sort().join() || Object.keys(categoryMeta).sort().join() !== [...collectionRoutes].sort().join() || products.length !== 12 || looks.length !== 3 || reviews.length !== 3 || tiles.length !== 4) fail('the reviewed public inventory changed');
|
|
144
|
+
const entries = [], keys = {};
|
|
145
|
+
const document = (title, excerpt, blocks) => ({ schemaVersion: 1, title, excerpt, blocks, taxonomy: { categories: [], tags: [] }, fields: {} });
|
|
146
|
+
function builder(slug) {
|
|
147
|
+
const blocks = [];
|
|
148
|
+
keys[slug] = [];
|
|
149
|
+
return { blocks, add(key, type, value, data) { keys[slug].push(key); blocks.push({ id: blockId(`${slug}:${key}`), version: 1, type, text: value, ...(data ? { data } : {}) }); } };
|
|
150
|
+
}
|
|
151
|
+
const heroTitle = text(hero, 'hero-title'), heroSubtitle = text(hero, 'hero-subtitle');
|
|
152
|
+
for (const slug of ['home', ...collectionRoutes]) {
|
|
153
|
+
const b = builder(slug), home = slug === 'home';
|
|
154
|
+
const title = home ? heroTitle : categoryMeta[slug].title, excerpt = home ? heroSubtitle : categoryMeta[slug].excerpt;
|
|
155
|
+
if (home) {
|
|
156
|
+
b.add('hero-title', 'heading', heroTitle, { level: 2 });
|
|
157
|
+
b.add('hero-badge', 'paragraph', text(hero, 'hero-badge'));
|
|
158
|
+
b.add('hero-description', 'paragraph', heroSubtitle);
|
|
159
|
+
const values = texts(hero, 'stat-value'), labels = texts(hero, 'stat-label');
|
|
160
|
+
values.forEach((value, index) => { b.add(`hero-feature-${index}`, 'heading', value, { level: 3 }); b.add(`hero-feature-copy-${index}`, 'paragraph', labels[index]); });
|
|
161
|
+
} else {
|
|
162
|
+
b.add('category-badge', 'paragraph', 'Atelier Collection');
|
|
163
|
+
b.add('category-title', 'heading', title, { level: 2 });
|
|
164
|
+
b.add('category-description', 'paragraph', excerpt);
|
|
165
|
+
}
|
|
166
|
+
b.add('capsule-title', 'heading', text(categories, 'section-main-heading'), { level: 2 });
|
|
167
|
+
for (const tile of tiles) {
|
|
168
|
+
b.add(`capsule-${tile.id}`, 'heading', tile.name, { level: 3 });
|
|
169
|
+
b.add(`capsule-copy-${tile.id}`, 'paragraph', `${tile.tagline}\n${tile.count}`);
|
|
170
|
+
b.add(`capsule-image-${tile.id}`, 'image', '', { url: tile.image, alt: tile.name, align: 'full' });
|
|
171
|
+
}
|
|
172
|
+
b.add('collection-kicker', 'paragraph', text(grid, 'collection-kicker'));
|
|
173
|
+
b.add('collection-title', 'heading', home ? 'Complete Collection' : `${slug.toUpperCase()} COLLECTION`, { level: 2 });
|
|
174
|
+
b.add('collection-description', 'paragraph', text(grid, 'collection-subtext'));
|
|
175
|
+
for (const product of home ? products : products.filter(item => item.category === slug)) {
|
|
176
|
+
b.add(`product-${product.id}-title`, 'heading', product.name, { level: 3 });
|
|
177
|
+
b.add(`product-${product.id}-images`, 'gallery', '', { columns: 2, images: product.images.map((url, index) => ({ id: blockId(`${slug}:product:${product.id}:image:${index}`), url, alt: product.name })) });
|
|
178
|
+
b.add(`product-${product.id}-description`, 'paragraph', product.description);
|
|
179
|
+
b.add(`product-${product.id}-fabric`, 'paragraph', `Fabrication: ${product.fabric}`);
|
|
180
|
+
b.add(`product-${product.id}-fit`, 'paragraph', `Fit Profile: ${product.fit}`);
|
|
181
|
+
b.add(`product-${product.id}-attributes`, 'list', [product.badge, `$${product.price.toFixed(0)}`, ...(product.onSale && product.originalPrice ? [`$${product.originalPrice.toFixed(0)}`] : []), ...product.colors.map(color => color.name), ...product.sizes].join('\n'));
|
|
182
|
+
}
|
|
183
|
+
if (home) {
|
|
184
|
+
b.add('lookbook-title', 'heading', text(lookbook, 'lookbook-main-title'), { level: 2 });
|
|
185
|
+
b.add('lookbook-caption', 'paragraph', text(lookbook, 'lookbook-caption'));
|
|
186
|
+
for (const look of looks) {
|
|
187
|
+
b.add(`look-${look.id}-title`, 'heading', look.title, { level: 3 });
|
|
188
|
+
b.add(`look-${look.id}-image`, 'image', '', { url: look.image, alt: look.title, align: 'full' });
|
|
189
|
+
b.add(`look-${look.id}-description`, 'paragraph', `${look.subtitle}\n${look.location}`);
|
|
190
|
+
b.add(`look-${look.id}-pieces`, 'list', look.pieces.join('\n'));
|
|
191
|
+
}
|
|
192
|
+
b.add('press-title', 'heading', text(testimonials, 'section-title-large'), { level: 2 });
|
|
193
|
+
b.add('press-publications', 'paragraph', texts(testimonials, 'press-logo-text').join(' · '));
|
|
194
|
+
reviews.forEach((review, index) => { b.add(`review-${index}-quote`, 'quote', review.quote); b.add(`review-${index}-byline`, 'paragraph', `${review.author}\n${review.role}\n${review.item}`); });
|
|
195
|
+
b.add('newsletter-title', 'heading', text(newsletter, 'newsletter-heading'), { level: 2 });
|
|
196
|
+
b.add('newsletter-description', 'paragraph', text(newsletter, 'newsletter-description'));
|
|
197
|
+
}
|
|
198
|
+
entries.push({ kind: 'page', slug, locale, document: document(title, excerpt, b.blocks) });
|
|
199
|
+
}
|
|
200
|
+
for (const slug of infoRoutes) {
|
|
201
|
+
const page = info[slug], b = builder(slug);
|
|
202
|
+
b.add('badge', 'paragraph', page.badge);
|
|
203
|
+
b.add('title', 'heading', page.title, { level: 2 });
|
|
204
|
+
b.add('subtitle', 'paragraph', page.subtitle);
|
|
205
|
+
page.sections.forEach((section, index) => { b.add(`section-${index}-title`, 'heading', section.title, { level: 2 }); b.add(`section-${index}-copy`, 'paragraph', section.content); });
|
|
206
|
+
entries.push({ kind: 'page', slug, locale, document: document(page.title, page.subtitle, b.blocks) });
|
|
207
|
+
}
|
|
208
|
+
const manifest = { version: 1, entries };
|
|
209
|
+
if (Buffer.byteLength(JSON.stringify(manifest)) > 240 * 1024 || entries.some(entry => entry.document.blocks.length > 1000)) fail('the manifest exceeded its bounded import budget');
|
|
210
|
+
return { manifest, native: { sources, keys }, diagnostics: {
|
|
211
|
+
profile: PROFILE, sourceOnly: true, publicationRendering: false,
|
|
212
|
+
pages: entries.length, posts: 0, publicProducts: products.length, publicEditorialLooks: looks.length,
|
|
213
|
+
routes: entries.map(entry => ({ path: entry.slug === 'home' ? '/' : `/${entry.slug}`, kind: entry.kind, slug: entry.slug, locale })),
|
|
214
|
+
aliases: [{ path: '/home', canonicalPath: '/', slug: 'home' }],
|
|
215
|
+
sourceHashes: Object.entries(fingerprints).map(([filename, sha256]) => ({ path: filename, sha256 })),
|
|
216
|
+
omissions: [
|
|
217
|
+
{ route: '/track-order', reason: 'Native order-tracking interaction; no customer or order state is exported.' },
|
|
218
|
+
{ route: '/stylist-consult', reason: 'Native consultation form; no customer input or submission is exported.' },
|
|
219
|
+
{ route: '/knitwear', reason: 'Catalog filter, not a route; its public product remains in the home document.' },
|
|
220
|
+
{ route: '*', reason: 'Unknown routes render the native 404. No additional documents are invented.' },
|
|
221
|
+
{ scope: 'private-and-interactive', reason: 'Cart, wishlist, checkout, form input/submission, newsletter success state, promo-code tables, browser storage, credentials and environment files are not read for export.' },
|
|
222
|
+
{ scope: 'presentation', reason: 'Global navigation/footer, decorative icons, interaction labels, filtering/sorting, currency changes and detailed modal help are omitted. All public catalog rows and editorial look variants are included; collection documents contain their own category banner, capsules and matching products.' },
|
|
223
|
+
{ scope: 'published-rendering', reason: 'This is a source snapshot for import only. It does not bind CMS edits to native components, install publication checks or change commerce behavior.' },
|
|
224
|
+
],
|
|
225
|
+
notes: ['No website code was executed. Source changes require a fresh profile review.', 'Product attributes are public page content, not an e-commerce data model. Existing block IDs are preserved for matching legacy reviewed slots.', 'The caller must validate the manifest and retain source observations before saving it.'],
|
|
226
|
+
} };
|
|
227
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { lstat, open, realpath } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { projectRoot, readProjectFile, SetupError } from './core.mjs';
|
|
5
|
+
import { inspectInstalledDependencies } from './vendor/inspect-installed-dependencies.mjs';
|
|
6
|
+
|
|
7
|
+
const inside = (root, target) => {
|
|
8
|
+
const relative = path.relative(root, target);
|
|
9
|
+
return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// Follow only package-manager links within this project's node_modules. Inspect
|
|
13
|
+
// metadata and asset existence; never import or execute a customer's modules.
|
|
14
|
+
async function hasRendererStyles(root) {
|
|
15
|
+
let handle;
|
|
16
|
+
try {
|
|
17
|
+
const modules = path.join(root, 'node_modules');
|
|
18
|
+
if (await realpath(modules) !== modules) return false;
|
|
19
|
+
const scope = await realpath(path.join(modules, '@universalcms'));
|
|
20
|
+
if (!inside(modules, scope)) return false;
|
|
21
|
+
const renderer = await realpath(path.join(scope, 'react'));
|
|
22
|
+
if (!inside(modules, renderer)) return false;
|
|
23
|
+
const manifestPath = path.join(renderer, 'package.json');
|
|
24
|
+
const before = await lstat(manifestPath);
|
|
25
|
+
if (!before.isFile() || before.isSymbolicLink() || before.size > 128 * 1024) return false;
|
|
26
|
+
handle = await open(manifestPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
27
|
+
const opened = await handle.stat();
|
|
28
|
+
if (opened.ino !== before.ino || opened.dev !== before.dev || opened.size > 128 * 1024) return false;
|
|
29
|
+
const buffer = Buffer.alloc(128 * 1024 + 1);
|
|
30
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
31
|
+
if (bytesRead > 128 * 1024) return false;
|
|
32
|
+
const manifest = JSON.parse(buffer.subarray(0, bytesRead).toString('utf8'));
|
|
33
|
+
if (manifest.exports?.['./styles.css'] !== './dist/styles.css') return false;
|
|
34
|
+
const stylesheet = path.join(renderer, 'dist/styles.css');
|
|
35
|
+
const stat = await lstat(stylesheet);
|
|
36
|
+
return stat.isFile() && !stat.isSymbolicLink() && await realpath(stylesheet) === stylesheet;
|
|
37
|
+
} catch { return false; }
|
|
38
|
+
finally { await handle?.close(); }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function assertRuntimeDependencies(directory, framework, adminOrigin) {
|
|
42
|
+
const root = await projectRoot(directory);
|
|
43
|
+
const diagnostics = await inspectInstalledDependencies(root, await readProjectFile(root, 'package.json'));
|
|
44
|
+
const required = ['sdk', 'react', ...(framework === 'next' ? ['next'] : [])];
|
|
45
|
+
const missing = required.filter(name => {
|
|
46
|
+
const pkg = diagnostics.packages[name];
|
|
47
|
+
return pkg.declared !== 'runtime' || pkg.installed !== 'present' || pkg.compatibility !== 'compatible';
|
|
48
|
+
});
|
|
49
|
+
if (!await hasRendererStyles(root)) missing.push('react/styles.css');
|
|
50
|
+
if (!missing.length) return;
|
|
51
|
+
const url = new URL(adminOrigin);
|
|
52
|
+
if (url.origin !== adminOrigin || url.username || url.password ||
|
|
53
|
+
!(url.protocol === 'https:' || url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) {
|
|
54
|
+
throw new SetupError('Runtime dependencies are not ready. Use the reviewed runtime package install command from Developer setup, then rerun setup.');
|
|
55
|
+
}
|
|
56
|
+
const quote = value => `'${value.replaceAll("'", "'\\''")}'`;
|
|
57
|
+
const packages = required.map(name => quote(`${url.origin}/website-packages/universalcms-${name}-0.1.0.tgz`));
|
|
58
|
+
throw new SetupError(`Runtime dependencies are not ready: ${missing.map(name => `@universalcms/${name}`).join(', ')}. Install the matching packages as production dependencies before setup or server-key. Existing files and keys are preserved.\nFor npm, run:\nnpm install --save-prod ${packages.join(' ')}\nUse your project's package manager, then rerun the command. Installed packages, compatible versions and the renderer stylesheet are checked; package.json declarations alone are insufficient.`);
|
|
59
|
+
}
|
|
@@ -135,6 +135,29 @@ export async function readSetupEnvironment(directory) {
|
|
|
135
135
|
return { defaults: defaults(input), keyConfigured: keyPattern.test(input.current.values.CMS_API_KEY ?? '') };
|
|
136
136
|
});
|
|
137
137
|
}
|
|
138
|
+
/** Check staged public authority without exposing keys or relaxing private writes. */
|
|
139
|
+
export async function assertEnvironmentBinding(directory, connection) {
|
|
140
|
+
return protect(async () => {
|
|
141
|
+
const root = await projectRoot(directory), input = await inputs(root);
|
|
142
|
+
const expected = {
|
|
143
|
+
CMS_API_BASE_URL: connection.apiOrigin, CMS_SITE_ID: connection.siteId,
|
|
144
|
+
CMS_ENVIRONMENT_ID: connection.environmentId, CMS_LOCALE: connection.locale,
|
|
145
|
+
CMS_WEBSITE_ORIGIN: connection.websiteOrigin, CMS_SOURCE_ORIGIN: connection.websiteOrigin,
|
|
146
|
+
CMS_ADMIN_ORIGIN: connection.adminOrigin,
|
|
147
|
+
};
|
|
148
|
+
for (const [name, value] of Object.entries(input.current.values)) {
|
|
149
|
+
if (name === 'CMS_API_KEY') continue;
|
|
150
|
+
const normalized = name === 'CMS_SITE_ID' || name === 'CMS_ENVIRONMENT_ID' ? id(value, true) : name === 'CMS_LOCALE' ? locale(value) : origin(value, true);
|
|
151
|
+
if (normalized !== expected[name]) fail('Existing private connection settings differ from the saved connection. Review the integration before changing its scope or origins.');
|
|
152
|
+
}
|
|
153
|
+
if (input.current.values.CMS_API_KEY) {
|
|
154
|
+
if (!keyPattern.test(input.current.values.CMS_API_KEY)) fail('The existing private key needs manual review before using the separate connection commands.');
|
|
155
|
+
if (!input.config && !['CMS_API_BASE_URL', 'CMS_SITE_ID', 'CMS_ENVIRONMENT_ID'].every(name => input.current.values[name])) fail('The existing private key has no complete saved connection. Review its scope and API origin before continuing.');
|
|
156
|
+
}
|
|
157
|
+
assertOverrides({ ...input.current.values, ...expected }, input);
|
|
158
|
+
return { keyConfigured: keyPattern.test(input.current.values.CMS_API_KEY ?? '') };
|
|
159
|
+
});
|
|
160
|
+
}
|
|
138
161
|
function assertOverrides(values, input) {
|
|
139
162
|
for (const file of input.files.slice(2)) {
|
|
140
163
|
const parsed = environment(file.content ?? '');
|
|
@@ -235,3 +258,50 @@ export async function verifyEnvironment(plan, { slug, fetcher } = {}) {
|
|
|
235
258
|
return verifyPublishedDelivery({ slug, locale: effective.CMS_LOCALE, env: { CMS_API_BASE_URL: effective.CMS_API_BASE_URL, CMS_SITE_ID: effective.CMS_SITE_ID, CMS_ENVIRONMENT_ID: effective.CMS_ENVIRONMENT_ID, CMS_API_KEY: effective.CMS_API_KEY }, ...(fetcher ? { fetcher } : {}) });
|
|
236
259
|
});
|
|
237
260
|
}
|
|
261
|
+
|
|
262
|
+
/** Validate the delivery key live against the CMS API before saving to .env.local. */
|
|
263
|
+
export async function assertDeliveryKeyValid({ apiOrigin: rawApiOrigin, siteId: rawSiteId, environmentId: rawEnvironmentId, apiKey, fetcher = globalThis.fetch } = {}) {
|
|
264
|
+
const targetOrigin = origin(rawApiOrigin, true);
|
|
265
|
+
const targetSiteId = id(rawSiteId, true);
|
|
266
|
+
const targetEnvironmentId = id(rawEnvironmentId, true);
|
|
267
|
+
if (typeof apiKey !== 'string' || !keyPattern.test(apiKey)) {
|
|
268
|
+
fail('Provide a valid environment-scoped server delivery key from Developer setup.');
|
|
269
|
+
}
|
|
270
|
+
const url = new URL(`/api/v1/delivery/sites/${targetSiteId}/content`, targetOrigin);
|
|
271
|
+
url.search = new URLSearchParams({ environmentId: targetEnvironmentId, limit: '1' }).toString();
|
|
272
|
+
if (url.hostname.endsWith('.test') || url.hostname.endsWith('.example')) {
|
|
273
|
+
if (typeof fetcher?.mock === 'undefined' && !process.env.CMS_TEST_LIVE_VALIDATION) {
|
|
274
|
+
return { valid: false, offline: true, error: 'RFC 2606 test domain is not network-routable' };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const controller = new AbortController();
|
|
278
|
+
const timer = setTimeout(() => controller.abort(), 4_000);
|
|
279
|
+
let response;
|
|
280
|
+
try {
|
|
281
|
+
response = await fetcher(url, {
|
|
282
|
+
method: 'GET',
|
|
283
|
+
headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' },
|
|
284
|
+
credentials: 'omit',
|
|
285
|
+
redirect: 'error',
|
|
286
|
+
cache: 'no-store',
|
|
287
|
+
referrerPolicy: 'no-referrer',
|
|
288
|
+
signal: controller.signal,
|
|
289
|
+
});
|
|
290
|
+
} catch (error) {
|
|
291
|
+
clearTimeout(timer);
|
|
292
|
+
return { valid: false, offline: true, error: error instanceof Error ? error.message : 'Network error' };
|
|
293
|
+
} finally {
|
|
294
|
+
clearTimeout(timer);
|
|
295
|
+
}
|
|
296
|
+
if (!response || !(response instanceof Response)) {
|
|
297
|
+
return { valid: false, offline: true, error: 'Invalid response' };
|
|
298
|
+
}
|
|
299
|
+
if ([401, 403].includes(response.status)) {
|
|
300
|
+
fail(`The entered server key was rejected by the CMS API (HTTP ${response.status} Unauthorized). Verify that the key was created for Site ID: ${targetSiteId} and Environment ID: ${targetEnvironmentId}.`);
|
|
301
|
+
}
|
|
302
|
+
if (response.status === 200) {
|
|
303
|
+
return { valid: true };
|
|
304
|
+
}
|
|
305
|
+
return { valid: false, status: response.status };
|
|
306
|
+
}
|
|
307
|
+
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/** Deterministic source slots, never text matching or customer code execution. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import ts from './vendor/typescript.cjs';
|
|
4
|
+
import { extractStaticPage } from './source-extract.mjs';
|
|
5
|
+
import { SetupError } from './core.mjs';
|
|
6
|
+
const fail = message => { throw new SetupError(message); };
|
|
7
|
+
const digest = value => createHash('sha256').update(value).digest('hex').slice(0, 16);
|
|
8
|
+
export function transformSourcePublication({ source, slug, locale = 'en', filename = 'page.tsx', allowNamedExports = false, publicSource = false }) {
|
|
9
|
+
if (/\b(?:CmsBound|CmsAdditionalContent|PublishedDocumentProvider)\b|@universalcms\/connect\/react-publication/.test(source)) fail('Existing content bindings need review; they are never wrapped a second time.');
|
|
10
|
+
const extracted = extractStaticPage({ source, slug, locale, filename, includeBindings: true });
|
|
11
|
+
if (extracted.hasUnboundPresentationRows) fail('This page filters out source rows in its initial presentation state. Review bindings for every public row before enabling CMS rendering; otherwise hidden native content could bypass published edits or deletions. Existing source was preserved.');
|
|
12
|
+
if (!extracted.entry || !extracted.bindings || extracted.bindings.length !== extracted.entry.document.blocks.length || extracted.bindings.some(binding => !binding) || extracted.parameterCount !== 0) fail('This page needs a reviewed content adapter. Automatic binding requires a static public component with unambiguous exported blocks and no route props.');
|
|
13
|
+
const file = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
14
|
+
if (!allowNamedExports && file.statements.some(statement => statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword) && !statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword))) fail('Custom route exports need a reviewed content adapter. Existing code was preserved.');
|
|
15
|
+
const nodes = [];
|
|
16
|
+
function walk(node) { nodes.push(node); ts.forEachChild(node, walk); } walk(file);
|
|
17
|
+
const ranges = new Map();
|
|
18
|
+
for (const [index, original] of extracted.bindings.entries()) {
|
|
19
|
+
let binding = { ...original };
|
|
20
|
+
let node = nodes.find(node => node.getStart(file) === binding.start && node.end === binding.end);
|
|
21
|
+
if (!node) node = nodes.filter(node => node.getStart(file) <= binding.start && node.end >= binding.end).sort((a, b) => (a.end - a.pos) - (b.end - b.pos))[0];
|
|
22
|
+
let candidate = node;
|
|
23
|
+
if (binding.kind === 'inline' && candidate && !ts.isJsxElement(candidate)) candidate = candidate.parent;
|
|
24
|
+
while (candidate && !ts.isJsxElement(candidate) && !ts.isFunctionLike(candidate) && !ts.isSourceFile(candidate)) candidate = candidate.parent;
|
|
25
|
+
const sole = element => extracted.bindings.filter(other => other.start >= element.getStart(file) && other.end <= element.end).every(other => other.start === original.start && other.end === original.end);
|
|
26
|
+
if (candidate && ts.isJsxElement(candidate) && sole(candidate)) { binding = { ...binding, start: candidate.getStart(file), end: candidate.end, kind: 'element' }; node = candidate; }
|
|
27
|
+
// Deleting a sole button/link label removes the control, retaining its native
|
|
28
|
+
// handlers and icons on ordinary text edits.
|
|
29
|
+
for (let parent = node?.parent; parent && !ts.isFunctionLike(parent) && !ts.isSourceFile(parent); parent = parent.parent) {
|
|
30
|
+
if (ts.isJsxElement(parent) && ['button', 'a'].includes(parent.openingElement.tagName.getText(file))) {
|
|
31
|
+
if (sole(parent)) binding = { ...binding, start: parent.getStart(file), end: parent.end, kind: 'element' };
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (binding.trace.some(trace => !trace.selector || !/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?$/.test(trace.selector) || !(trace.value === null || typeof trace.value === 'string' || typeof trace.value === 'boolean' || typeof trace.value === 'number' && Number.isFinite(trace.value)))) fail('A repeated source block needs an explicit immutable row identity. Index-based content binding is unsafe.');
|
|
36
|
+
const key = `${binding.start}:${binding.end}`, selectors = binding.trace.map(trace => trace.selector), values = binding.trace.map(trace => trace.value);
|
|
37
|
+
let group = ranges.get(key);
|
|
38
|
+
if (!group) { group = { ...binding, selectors, variants: [] }; ranges.set(key, group); }
|
|
39
|
+
if (JSON.stringify(group.selectors) !== JSON.stringify(selectors) || group.variants.some(variant => JSON.stringify(variant.values) === JSON.stringify(values))) fail('Repeated content has ambiguous row identities. Provide reviewed bindings before enabling rendering.');
|
|
40
|
+
group.variants.push({ values, id: extracted.entry.document.blocks[index].id });
|
|
41
|
+
}
|
|
42
|
+
const sorted = [...ranges.values()].sort((a, b) => a.start - b.start), bindings = {}, edits = [];
|
|
43
|
+
for (let index = 1; index < sorted.length; index++) if (sorted[index].start < sorted[index - 1].end) fail('Overlapping source content requires a reviewed renderer.');
|
|
44
|
+
for (const group of sorted) {
|
|
45
|
+
const slot = `slot-${digest(`${locale}:${slug}:${group.start}:${group.end}`)}`;
|
|
46
|
+
bindings[slot] = { variants: group.variants };
|
|
47
|
+
const native = source.slice(group.start, group.end);
|
|
48
|
+
let child = group.kind === 'element' ? native : group.kind === 'expression' ? `{${native}}` : `<>${native}</>`;
|
|
49
|
+
const element = nodes.find(node => ts.isJsxElement(node) && node.getStart(file) === group.start && node.end === group.end);
|
|
50
|
+
if(publicSource){
|
|
51
|
+
if(group.selectors.length||!element||!/^[a-z][a-z0-9-]*$/.test(element.openingElement.tagName.getText(file))||element.children.some(node=>!ts.isJsxText(node)&&!(ts.isJsxExpression(node)&&node.expression&&(ts.isStringLiteral(node.expression)||ts.isNumericLiteral(node.expression)))))fail('Public React source needs explicit review for repeated, nested or data-driven content. Literal native bindings are required before private setup.');
|
|
52
|
+
child=element.openingElement.getText(file)+'{"\\u200b"}'+element.closingElement.getText(file);
|
|
53
|
+
}
|
|
54
|
+
const key = element?.openingElement.attributes.properties.find(attribute => ts.isJsxAttribute(attribute) && attribute.name.getText(file) === 'key');
|
|
55
|
+
const replacement = `<CmsBound${key ? ` ${key.getText(file)}` : ''} slot="${slot}" selector={[${group.selectors.join(', ')}]}>${child}</CmsBound>`;
|
|
56
|
+
edits.push({ ...group, replacement });
|
|
57
|
+
}
|
|
58
|
+
const range = extracted.returnRange;
|
|
59
|
+
// Place new blocks deliberately after the native page body, rather than
|
|
60
|
+
// replacing its layout or pretending arbitrary CMS reordering is supported.
|
|
61
|
+
edits.push({ start: range.end, end: range.end, replacement: '<CmsAdditionalContent /></>' });
|
|
62
|
+
edits.push({ start: range.start, end: range.start, replacement: '<>' });
|
|
63
|
+
let output = source;
|
|
64
|
+
for (const edit of edits.sort((a, b) => b.start - a.start || b.end - a.end)) output = output.slice(0, edit.start) + edit.replacement + output.slice(edit.end);
|
|
65
|
+
const directives = file.statements.filter(statement => ts.isExpressionStatement(statement) && ts.isStringLiteral(statement.expression));
|
|
66
|
+
const insertion = directives.length ? directives.at(-1).end : 0;
|
|
67
|
+
output = output.slice(0, insertion) + '\nimport { CmsBound, CmsAdditionalContent } from "@universalcms/connect/react-publication";\nimport "@universalcms/connect/publication.css";\n' + output.slice(insertion);
|
|
68
|
+
if (ts.createSourceFile(filename, output, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX).parseDiagnostics.length) fail('The generated content binding did not parse. Existing source was preserved.');
|
|
69
|
+
const baseline = extracted.entry.document;
|
|
70
|
+
if(publicSource){
|
|
71
|
+
const copy=ts.createSourceFile(filename,output,ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX);let retained=false;
|
|
72
|
+
const originals=new Set(baseline.blocks.map(block=>block.text).filter(Boolean));
|
|
73
|
+
function inspect(node){if((ts.isStringLiteral(node)||ts.isJsxText(node)||ts.isNoSubstitutionTemplateLiteral(node))&&[...originals].some(text=>node.text.includes(text)))retained=true;ts.forEachChild(node,inspect);}inspect(copy);
|
|
74
|
+
if(retained)fail('Public React source retains editorial copy outside its literal bindings. Review the source dependencies before private setup.');
|
|
75
|
+
}
|
|
76
|
+
return { source: output, inventory: { identity: { kind: 'page', slug, locale }, baseline, titleBlockId: baseline.blocks.find(block => block.type === 'heading' && block.text === baseline.title)?.id, bindings }, entry: extracted.entry, warnings: extracted.warnings };
|
|
77
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/** Read-only website source diagnostic. Never sends CMS credentials or changes content. */
|
|
2
|
+
import { readSetupEnvironment } from './setup-environment.mjs';
|
|
3
|
+
import { readSourceSettings } from './source-setup.mjs';
|
|
4
|
+
import { createSourceManifestProvider } from './vendor/source-manifest-provider.mjs';
|
|
5
|
+
import { inspectSourceRoutes, compareSourceRoutes } from './source-routes.mjs';
|
|
6
|
+
|
|
7
|
+
export const SOURCE_CHECK_TIMEOUT_MS = 10_000;
|
|
8
|
+
export const SOURCE_CHECK_MAX_BYTES = 20 * 1024 * 1024;
|
|
9
|
+
export const SOURCE_CHECK_MESSAGES = Object.freeze({
|
|
10
|
+
checked: 'The source endpoint returned matching scope, exact-origin CORS and a valid manifest envelope, identity and taxonomy structure. Full block-schema validation, complete source inventory and access from the CMS browser still need verification. No content was imported.',
|
|
11
|
+
'configuration-error': 'Source was not checked. Supply --website-origin, --admin-origin, --site and --environment from Developer setup, or save matching public values locally. Use exact HTTPS origins or HTTP loopback origins. No delivery key is needed.',
|
|
12
|
+
'saved-settings-error': 'Saved connection values could not be read safely. Supply all four public flags explicitly to check the source without loading local settings.',
|
|
13
|
+
'missing-route': 'The website returned 404 for /api/cms-source. Installing the connector or saving a key does not create this endpoint. If full setup stopped on a review blocker, finish the reviewed source, publication, content and settings adapter first. For an import-only workflow, setup-source prepares a supported public export; it does not render published CMS edits or install website settings. After the chosen adapter is installed, stop the old server and restart or deploy the website, then run check-source with the exact public scope. Unsupported layouts require a reviewed source adapter.',
|
|
14
|
+
'request-rejected': 'The source endpoint rejected the request. Match its configured website origin, CMS admin origin, site and environment exactly, then restart or deploy. localhost and 127.0.0.1 are different origins. Do not add a delivery key or relax CORS.',
|
|
15
|
+
'http-error': 'The source endpoint returned an unsuccessful HTTP response. Check the website server and source adapter, then restart or deploy it.',
|
|
16
|
+
'cors-error': 'The source response must allow exactly the configured CMS admin origin and must not enable credentials. Verify the source adapter CORS configuration; a command-line response alone does not prove browser access.',
|
|
17
|
+
'not-json': 'The source endpoint did not return application/json. A website page or SPA fallback is not a source export. Mount /api/cms-source before the website fallback and restart or deploy.',
|
|
18
|
+
'scope-error': 'The source response does not match the selected site and environment. Configure the source adapter with the exact IDs from Developer setup and restart or deploy it.',
|
|
19
|
+
'manifest-error': 'The source manifest envelope, document shape, identity or taxonomy is invalid. Validate every exported entry against the v1 source contract. Keep complete content; do not replace it with samples or silently rename terms.',
|
|
20
|
+
'empty-source': 'The source endpoint returned no entries. This does not verify migration of an existing website. Prepare its complete public page and post inventory before using Connect & fetch content.',
|
|
21
|
+
'missing-pages': 'Some static page routes have no matching source export. For automatic Next.js discovery, review sync-source diagnostics and rebuild deployed websites. Protected routes, unsupported content and custom exporters need explicit public mappings. Existing CMS entries are preserved when reconnecting.',
|
|
22
|
+
'routes-unverified': 'The source route inventory could not be fully verified. Use an ordinary Next.js App Router project with default route filenames and paths. Dynamic, custom or ambiguous routes require an explicit public-content review; no pages were automatically exported or imported.',
|
|
23
|
+
'invalid-response': 'The source response was redirected, malformed, unsafe or exceeded the response budget. Serve the exact source endpoint directly with bounded valid JSON.',
|
|
24
|
+
'transport-error': 'The source endpoint could not be reached. Start the website at its saved origin and check the port and source route. A browser may also require local-network permission after this CLI check passes.',
|
|
25
|
+
timeout: 'The source endpoint did not finish within the verification deadline. Check that the website server and source adapter are running and responsive.',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
29
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
30
|
+
const invalid = Symbol('Invalid source response');
|
|
31
|
+
const result = (status, counts) => ({ status, checked: status === 'checked', browserVerified: false, fullDocumentValidation: false, ...(counts ? { counts } : {}), message: SOURCE_CHECK_MESSAGES[status] });
|
|
32
|
+
const positiveBound = (value, max) => Number.isSafeInteger(value) && value > 0 && value <= max;
|
|
33
|
+
function exactOrigin(value) {
|
|
34
|
+
if (typeof value !== 'string' || value.length > 2048 || /[\s\\]|cms_server\.|sk-[A-Za-z0-9_-]{12,}|AIza[A-Za-z0-9_-]{20,}/i.test(value)) return null;
|
|
35
|
+
try {
|
|
36
|
+
const url = new URL(value);
|
|
37
|
+
return !url.username && !url.password && !url.search && !url.hash && url.pathname === '/' &&
|
|
38
|
+
(url.protocol === 'https:' || (url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) ? url.origin : null;
|
|
39
|
+
} catch { return null; }
|
|
40
|
+
}
|
|
41
|
+
function untilAborted(pending, signal) {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const cancel = () => reject(invalid);
|
|
44
|
+
if (signal.aborted) { cancel(); return; }
|
|
45
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
46
|
+
pending.then(value => { signal.removeEventListener('abort', cancel); resolve(value); }, error => { signal.removeEventListener('abort', cancel); reject(error); });
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function cancelBody(response) {
|
|
50
|
+
try { if (response?.body && !response.body.locked) void response.body.cancel().catch(() => {}); }
|
|
51
|
+
catch { /* Cleanup never exposes an upstream error. */ }
|
|
52
|
+
}
|
|
53
|
+
async function boundedBody(response, maxBytes, signal, deadline) {
|
|
54
|
+
const length = response.headers.get('content-length');
|
|
55
|
+
if (length !== null && (!/^\d+$/.test(length) || Number(length) > maxBytes)) throw invalid;
|
|
56
|
+
if (!response.body) throw invalid;
|
|
57
|
+
const reader = response.body.getReader(), body = new Uint8Array(maxBytes);
|
|
58
|
+
let bytes = 0;
|
|
59
|
+
try {
|
|
60
|
+
while (true) {
|
|
61
|
+
const chunk = await untilAborted(reader.read(), signal);
|
|
62
|
+
deadline();
|
|
63
|
+
if (chunk.done) break;
|
|
64
|
+
bytes += chunk.value.byteLength;
|
|
65
|
+
if (bytes > maxBytes) throw invalid;
|
|
66
|
+
body.set(chunk.value, bytes - chunk.value.byteLength);
|
|
67
|
+
}
|
|
68
|
+
try { return new TextDecoder('utf-8', { fatal: true }).decode(body.subarray(0, bytes)); }
|
|
69
|
+
catch { throw invalid; }
|
|
70
|
+
} finally { void reader.cancel().catch(() => {}); reader.releaseLock(); }
|
|
71
|
+
}
|
|
72
|
+
function sourceManifest(raw, binding, inventory) {
|
|
73
|
+
let manifest;
|
|
74
|
+
try { manifest = JSON.parse(raw); } catch { return result('manifest-error'); }
|
|
75
|
+
if (!object(manifest) || Object.keys(manifest).length !== 4 || Object.keys(manifest).some(name => !['version', 'siteId', 'environmentId', 'entries'].includes(name))) return result('manifest-error');
|
|
76
|
+
if (manifest.siteId !== binding.siteId || manifest.environmentId !== binding.environmentId) return result('scope-error');
|
|
77
|
+
// Bound nesting and reserved keys before passing the entire inventory through
|
|
78
|
+
// the same envelope/identity/taxonomy validator used by the public provider.
|
|
79
|
+
// This deliberately does not claim full authoring block-schema validation.
|
|
80
|
+
let nodes = 0;
|
|
81
|
+
const walk = (value, depth = 0) => {
|
|
82
|
+
if (++nodes > 300_000 || depth > 20) throw invalid;
|
|
83
|
+
if (value && typeof value === 'object') for (const [name, child] of Object.entries(value)) {
|
|
84
|
+
if (['__proto__', 'prototype', 'constructor'].includes(name)) throw invalid;
|
|
85
|
+
walk(child, depth + 1);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
try {
|
|
89
|
+
walk(manifest);
|
|
90
|
+
createSourceManifestProvider({ manifestJson: JSON.stringify({ version: manifest.version, entries: manifest.entries }), sourceOrigin: binding.websiteOrigin, adminOrigin: binding.adminOrigin, siteId: binding.siteId, environmentId: binding.environmentId, publicContent: true });
|
|
91
|
+
} catch { return result('manifest-error'); }
|
|
92
|
+
const counts = { entries: manifest.entries.length, pages: manifest.entries.filter(entry => entry.kind === 'page').length, posts: manifest.entries.filter(entry => entry.kind === 'post').length };
|
|
93
|
+
if (counts.entries && inventory) {
|
|
94
|
+
const routeInventory = compareSourceRoutes(inventory, manifest.entries);
|
|
95
|
+
const status = routeInventory.missingPages ? 'missing-pages' : routeInventory.unresolvedRoutes ? 'routes-unverified' : 'checked';
|
|
96
|
+
return { ...result(status, counts), routeInventory };
|
|
97
|
+
}
|
|
98
|
+
return result(counts.entries ? 'checked' : 'empty-source', counts);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Explicit public options bypass local configuration, including unsupported scaffold frameworks. */
|
|
102
|
+
export async function checkSource(directory, options = {}, { fetcher = globalThis.fetch, timeoutMs = SOURCE_CHECK_TIMEOUT_MS, maxResponseBytes = SOURCE_CHECK_MAX_BYTES } = {}) {
|
|
103
|
+
const names = ['websiteOrigin', 'adminOrigin', 'siteId', 'environmentId'];
|
|
104
|
+
if (!object(options) || Object.keys(options).some(name => ![...names, 'checkRoutes'].includes(name)) || (options.checkRoutes !== undefined && typeof options.checkRoutes !== 'boolean') || typeof fetcher !== 'function' || !positiveBound(timeoutMs, SOURCE_CHECK_TIMEOUT_MS) || !positiveBound(maxResponseBytes, SOURCE_CHECK_MAX_BYTES)) return result('configuration-error');
|
|
105
|
+
let defaults = {};
|
|
106
|
+
if (names.some(name => options[name] === undefined)) {
|
|
107
|
+
try {
|
|
108
|
+
defaults = await readSourceSettings(directory);
|
|
109
|
+
// Source-only setup never needs private settings. Keep the legacy fallback
|
|
110
|
+
// only when a required public value is still missing.
|
|
111
|
+
if (names.some(name => options[name] === undefined && defaults[name] === undefined)) defaults = { ...(await readSetupEnvironment(directory)).defaults, ...defaults };
|
|
112
|
+
}
|
|
113
|
+
catch { return result('saved-settings-error'); }
|
|
114
|
+
}
|
|
115
|
+
const values = Object.fromEntries(names.map(name => [name, options[name] ?? defaults[name]]));
|
|
116
|
+
const websiteOrigin = exactOrigin(values.websiteOrigin), adminOrigin = exactOrigin(values.adminOrigin);
|
|
117
|
+
if (!websiteOrigin || !adminOrigin || !['siteId', 'environmentId'].every(name => typeof values[name] === 'string' && uuid.test(values[name]))) return result('configuration-error');
|
|
118
|
+
let inventory;
|
|
119
|
+
if (options.checkRoutes) {
|
|
120
|
+
try { inventory = await inspectSourceRoutes(directory); }
|
|
121
|
+
catch { return result('routes-unverified'); }
|
|
122
|
+
}
|
|
123
|
+
const binding = { websiteOrigin, adminOrigin, siteId: values.siteId.toLowerCase(), environmentId: values.environmentId.toLowerCase() };
|
|
124
|
+
const url = new URL('/api/cms-source', websiteOrigin);
|
|
125
|
+
url.search = new URLSearchParams({ siteId: binding.siteId, environmentId: binding.environmentId }).toString();
|
|
126
|
+
const controller = new AbortController();
|
|
127
|
+
let timedOut = false, response;
|
|
128
|
+
const expires = Date.now() + timeoutMs;
|
|
129
|
+
const deadline = () => { if (Date.now() >= expires) { timedOut = true; controller.abort(); throw invalid; } };
|
|
130
|
+
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
|
|
131
|
+
try {
|
|
132
|
+
response = await untilAborted(fetcher(url, {
|
|
133
|
+
method: 'GET', headers: { Accept: 'application/json', Origin: adminOrigin }, credentials: 'omit',
|
|
134
|
+
redirect: 'error', cache: 'no-store', referrerPolicy: 'no-referrer', signal: controller.signal,
|
|
135
|
+
}), controller.signal);
|
|
136
|
+
deadline();
|
|
137
|
+
if (!(response instanceof Response) || response.redirected || ['opaque', 'opaqueredirect'].includes(response.type) || (response.url && response.url !== url.href) || (response.status >= 300 && response.status < 400)) return result('invalid-response');
|
|
138
|
+
if (response.status === 404) return result('missing-route');
|
|
139
|
+
if ([401, 403].includes(response.status)) return result('request-rejected');
|
|
140
|
+
if (!response.ok) return result('http-error');
|
|
141
|
+
if (response.status !== 200) return result('invalid-response');
|
|
142
|
+
if (response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase() !== 'application/json') return result('not-json');
|
|
143
|
+
if (response.headers.get('access-control-allow-origin') !== adminOrigin || response.headers.has('access-control-allow-credentials')) return result('cors-error');
|
|
144
|
+
const outcome = sourceManifest(await boundedBody(response, maxResponseBytes, controller.signal, deadline), binding, inventory);
|
|
145
|
+
deadline();
|
|
146
|
+
return outcome;
|
|
147
|
+
} catch (error) { return result(timedOut ? 'timeout' : error === invalid ? 'invalid-response' : 'transport-error'); }
|
|
148
|
+
finally { clearTimeout(timer); controller.abort(); cancelBody(response); }
|
|
149
|
+
}
|