@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,155 @@
|
|
|
1
|
+
/** Bounded native adapter for the exact reviewed storefront source profile.
|
|
2
|
+
* Customer code is parsed, never executed. Original modules and every shared
|
|
3
|
+
* dependency are retained and fingerprinted before the plan can be applied.
|
|
4
|
+
*/
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import ts from './vendor/typescript.cjs';
|
|
7
|
+
import { readProjectFile, SetupError } from './core.mjs';
|
|
8
|
+
import { discoverReviewedStorefrontSource, reviewedStorefrontBlockId } from './reviewed-storefront-source.mjs';
|
|
9
|
+
import { CONTENT_BINDINGS_FILE, contentDigest, readFrozenContent, REVIEWED_STOREFRONT_PROFILE, reviewedNativePaths } from './content-registry.mjs';
|
|
10
|
+
const json = value => `${JSON.stringify(value, null, 2)}\n`;
|
|
11
|
+
const fail = message => { throw new SetupError(message); };
|
|
12
|
+
const helperFile = 'src/components/UniversalCmsStorefront.jsx';
|
|
13
|
+
const indexFile = '.universalcms/content/storefront.routes.mjs';
|
|
14
|
+
|
|
15
|
+
// Part indices describe reviewed composite blocks, not heuristics or text
|
|
16
|
+
// matching. Deleting a block removes every native presentation of that block.
|
|
17
|
+
export function storefrontHelperTemplate() {
|
|
18
|
+
return `// Generated reviewed native storefront bindings. No credential or draft fallback.
|
|
19
|
+
import React from 'react';
|
|
20
|
+
import { PublishedDocumentProvider, usePublishedDocument, CmsBound, CmsAdditionalContent } from '@universalcms/connect/react-publication';
|
|
21
|
+
import '@universalcms/connect/publication.css';
|
|
22
|
+
import inventories from '../../.universalcms/content/storefront.routes.mjs';
|
|
23
|
+
export function CmsStorefrontRoute({ slug, children }) {
|
|
24
|
+
const canonical = !slug || slug === 'home' ? 'home' : slug;
|
|
25
|
+
const route = inventories[canonical];
|
|
26
|
+
if (!route) return children;
|
|
27
|
+
return <PublishedDocumentProvider pathname={canonical === 'home' ? '/' : '/' + canonical} route={route}>{children}<CmsAdditionalContent /></PublishedDocumentProvider>;
|
|
28
|
+
}
|
|
29
|
+
export function CmsStorefrontBlock({ name, part, imageIndex, attribute, children }) {
|
|
30
|
+
const { entry, route, blocks } = usePublishedDocument();
|
|
31
|
+
const id = route.bindings[name]?.variants[0]?.id;
|
|
32
|
+
if (!id) return null;
|
|
33
|
+
const block = blocks.get(id), original = route.baseline.blocks.find(value => value.id === id);
|
|
34
|
+
if (!block || !original) return null;
|
|
35
|
+
if (imageIndex !== undefined && block.type === original.type) {
|
|
36
|
+
const item = block.type === 'image' ? block.data : block.type === 'gallery' ? block.data?.images?.[imageIndex] : undefined;
|
|
37
|
+
if (!item || !React.isValidElement(children) || children.type !== 'img') return null;
|
|
38
|
+
return React.cloneElement(children, { src: item.url, alt: item.alt ?? '', 'data-cms-block': id });
|
|
39
|
+
}
|
|
40
|
+
if (part !== undefined && block.type === original.type && React.isValidElement(children)) {
|
|
41
|
+
const text = String(block.text ?? '').split('\\n')[part] ?? '';
|
|
42
|
+
if (block.text === original.text) return React.cloneElement(children, { 'data-cms-block': id });
|
|
43
|
+
if (attribute === 'title') return React.cloneElement(children, { title: text, 'aria-label': text, 'data-cms-block': id });
|
|
44
|
+
return React.cloneElement(children, { 'data-cms-block': id }, text);
|
|
45
|
+
}
|
|
46
|
+
return <CmsBound slot={name} selector={[]}>{children}</CmsBound>;
|
|
47
|
+
}
|
|
48
|
+
`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function bindReviewedStorefrontModule(source, filename, descriptors) {
|
|
52
|
+
const file = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JSX);
|
|
53
|
+
const nodes = [];
|
|
54
|
+
const walk = node => { if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) nodes.push(node); ts.forEachChild(node, walk); }; walk(file);
|
|
55
|
+
const classes = node => {
|
|
56
|
+
const open = ts.isJsxElement(node) ? node.openingElement : node;
|
|
57
|
+
const attribute = open.attributes.properties.find(value => ts.isJsxAttribute(value) && value.name.getText(file) === 'className');
|
|
58
|
+
return attribute?.initializer && ts.isStringLiteral(attribute.initializer) ? attribute.initializer.text.split(/\s+/) : [];
|
|
59
|
+
};
|
|
60
|
+
const edits = [];
|
|
61
|
+
for (const descriptor of descriptors) {
|
|
62
|
+
let matches = nodes.filter(node => descriptor.className ? classes(node).includes(descriptor.className) : descriptor.expression && node.getText(file).includes(descriptor.expression));
|
|
63
|
+
if (descriptor.expression) matches = matches.filter(node => !nodes.some(other => other !== node && other.getStart(file) >= node.getStart(file) && other.end <= node.end && other.getText(file).includes(descriptor.expression)));
|
|
64
|
+
if (descriptor.index !== undefined) matches = matches.slice(descriptor.index, descriptor.index + 1);
|
|
65
|
+
if (matches.length !== 1) fail(`The reviewed storefront binding in ${filename} is ambiguous. Existing source was preserved.`);
|
|
66
|
+
const node = matches[0], extra = descriptor.part !== undefined ? ` part={${descriptor.part}}` : descriptor.imageIndex !== undefined ? ` imageIndex={${descriptor.imageIndex}}` : '';
|
|
67
|
+
const child = node.getText(file);
|
|
68
|
+
const attribute = descriptor.attribute === 'title' ? ' attribute="title"' : '';
|
|
69
|
+
const opening = ts.isJsxElement(node) ? node.openingElement : node;
|
|
70
|
+
const key = opening.attributes.properties.find(attribute => ts.isJsxAttribute(attribute) && attribute.name.getText(file) === 'key');
|
|
71
|
+
edits.push({ start: node.getStart(file), end: node.end, text: `<CmsStorefrontBlock${key ? ` ${key.getText(file)}` : ''} name={${descriptor.name}}${extra}${attribute}>${child}</CmsStorefrontBlock>` });
|
|
72
|
+
}
|
|
73
|
+
const ordered = edits.sort((a, b) => a.start - b.start);
|
|
74
|
+
for (let index = 1; index < ordered.length; index++) if (ordered[index].start < ordered[index - 1].end) fail('Reviewed storefront slots overlap. No integration files were saved.');
|
|
75
|
+
let output = source;
|
|
76
|
+
for (const edit of edits.sort((a, b) => b.start - a.start)) output = output.slice(0, edit.start) + edit.text + output.slice(edit.end);
|
|
77
|
+
const reference = filename === 'src/App.jsx' ? './components/UniversalCmsStorefront' : './UniversalCmsStorefront';
|
|
78
|
+
output = `import { CmsStorefrontBlock${filename === 'src/App.jsx' ? ', CmsStorefrontRoute' : ''} } from '${reference}';\n` + output;
|
|
79
|
+
if (ts.createSourceFile(filename, output, ts.ScriptTarget.Latest, true, ts.ScriptKind.JSX).parseDiagnostics.length) fail('The reviewed storefront binding did not parse. Existing source was preserved.');
|
|
80
|
+
return output;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const slot = (className, name, extra = {}) => ({ className, name: JSON.stringify(name), ...extra });
|
|
84
|
+
const dynamic = (className, name, extra = {}) => ({ className, name, ...extra });
|
|
85
|
+
function transformedModules(sources) {
|
|
86
|
+
const output = new Map();
|
|
87
|
+
const bind = (file, descriptors) => output.set(file, bindReviewedStorefrontModule(sources.get(file), file, descriptors));
|
|
88
|
+
bind('src/components/Hero.jsx', [slot('hero-title','hero-title'),slot('hero-badge','hero-badge'),slot('hero-subtitle','hero-description'), ...[0,1,2].flatMap(index => [slot('stat-value',`hero-feature-${index}`,{index}),slot('stat-label',`hero-feature-copy-${index}`,{index})])]);
|
|
89
|
+
bind('src/components/CategoryPills.jsx', [slot('section-main-heading','capsule-title'),dynamic('tile-title',"'capsule-' + tile.id"),dynamic('tile-count',"'capsule-copy-' + tile.id",{part:1}),dynamic('tile-tagline',"'capsule-copy-' + tile.id",{part:0}),dynamic('tile-bg-image',"'capsule-image-' + tile.id",{imageIndex:0})]);
|
|
90
|
+
bind('src/components/ProductGrid.jsx',[slot('collection-kicker','collection-kicker'),slot('collection-heading','collection-title'),slot('collection-subtext','collection-description')]);
|
|
91
|
+
bind('src/components/ProductCard.jsx', [dynamic('product-title',"'product-' + product.id + '-title'"), dynamic('main-img',"'product-' + product.id + '-images'",{imageIndex:'isHovered && product.images[1] ? 1 : 0'}),dynamic('current-price',"'product-' + product.id + '-attributes'",{part:1}),dynamic('original-price',"'product-' + product.id + '-attributes'",{part:2}),dynamic('color-label-preview',"'product-' + product.id + '-attributes'",{part:'(product.onSale && product.originalPrice ? 3 : 2) + product.colors.findIndex(color => color.name === selectedColor.name)'}),{expression:'{product.badge}',name:"'product-' + product.id + '-attributes'",part:0},{expression:'title={c.name}',name:"'product-' + product.id + '-attributes'",part:'(product.onSale && product.originalPrice ? 3 : 2) + product.colors.indexOf(c)',attribute:'title'},{expression:'{s}',name:"'product-' + product.id + '-attributes'",part:'(product.onSale && product.originalPrice ? 3 : 2) + product.colors.length + product.sizes.indexOf(s)'}]);
|
|
92
|
+
bind('src/components/ProductModal.jsx', [dynamic('modal-product-title',"'product-' + product.id + '-title'"),dynamic('gallery-main-img',"'product-' + product.id + '-images'",{imageIndex:'activeImageIndex'}),dynamic('modal-description',"'product-' + product.id + '-description'"),dynamic('modal-badge',"'product-' + product.id + '-attributes'",{part:0}),dynamic('modal-current-price',"'product-' + product.id + '-attributes'",{part:1}),dynamic('modal-original-price',"'product-' + product.id + '-attributes'",{part:2}),{expression:'{product.fabric}',name:"'product-' + product.id + '-fabric'"},{expression:'{product.fit}',name:"'product-' + product.id + '-fit'"},{expression:'src={img}',name:"'product-' + product.id + '-images'",imageIndex:'idx'},dynamic('option-value-highlight',"'product-' + product.id + '-attributes'",{part:'(product.onSale && product.originalPrice ? 3 : 2) + product.colors.findIndex(color => color.name === selectedColor.name)'}),{expression:'{c.name}',name:"'product-' + product.id + '-attributes'",part:'(product.onSale && product.originalPrice ? 3 : 2) + product.colors.indexOf(c)'},{expression:'{s}',name:"'product-' + product.id + '-attributes'",part:'(product.onSale && product.originalPrice ? 3 : 2) + product.colors.length + product.sizes.indexOf(s)'}]);
|
|
93
|
+
bind('src/components/LookbookSection.jsx',[slot('lookbook-main-title','lookbook-title'),slot('lookbook-caption','lookbook-caption'),dynamic('lookbook-featured-image',"'look-' + activeLook.id + '-image'",{imageIndex:0}),dynamic('lookbook-hero-title',"'look-' + activeLook.id + '-title'"),dynamic('lookbook-loc-pill',"'look-' + activeLook.id + '-description'",{part:1}),dynamic('lookbook-hero-subtitle',"'look-' + activeLook.id + '-description'",{part:0}),dynamic('piece-pill-btn',"'look-' + activeLook.id + '-pieces'",{part:'idx'}),dynamic('thumb-title',"'look-' + look.id + '-description'",{part:0}),{expression:'src={look.image}',name:"'look-' + look.id + '-image'",imageIndex:0}]);
|
|
94
|
+
bind('src/components/TestimonialsSection.jsx',[slot('section-title-large','press-title'),slot('press-logos-strip','press-publications'),dynamic('testimonial-quote',"'review-' + idx + '-quote'"),dynamic('testimonial-author',"'review-' + idx + '-byline'",{part:0}),dynamic('testimonial-role',"'review-' + idx + '-byline'",{part:1}),dynamic('testimonial-item-tag',"'review-' + idx + '-byline'",{part:2})]);
|
|
95
|
+
bind('src/components/Newsletter.jsx',[slot('newsletter-heading','newsletter-title'),slot('newsletter-description','newsletter-description')]);
|
|
96
|
+
bind('src/components/InfoPage.jsx',[{expression:'{page.badge}',name:"'badge'"},{expression:'{page.title}',name:"'title'"},{expression:'{page.subtitle}',name:"'subtitle'"},{expression:'{section.title}',name:"'section-' + idx + '-title'"},{expression:'{section.content}',name:"'section-' + idx + '-copy'"}]);
|
|
97
|
+
bind('src/App.jsx',[{expression:'Atelier Collection',name:"'category-badge'"},{expression:'{CATEGORY_META[cleanSlug]?.title',name:"'category-title'"},{expression:'{CATEGORY_META[cleanSlug]?.excerpt',name:"'category-description'"}]);
|
|
98
|
+
let app = output.get('src/App.jsx');
|
|
99
|
+
// Do not carry a managed product modal into a native transactional route
|
|
100
|
+
// whose subtree intentionally has no CMS document context.
|
|
101
|
+
app = app.replace('const handleNavigate = (url) => {', 'const handleNavigate = (url) => {\n setSelectedProductModal(null);');
|
|
102
|
+
const start = app.indexOf('<div className="atelier-app">'), end = app.lastIndexOf('</div>');
|
|
103
|
+
if (start < 0 || end < start) fail('The reviewed storefront root changed.');
|
|
104
|
+
app = app.slice(0,end + 6) + '</CmsStorefrontRoute>' + app.slice(end + 6);
|
|
105
|
+
app = app.slice(0,start) + '<CmsStorefrontRoute slug={cleanSlug}>' + app.slice(start);
|
|
106
|
+
output.set('src/App.jsx', app);
|
|
107
|
+
return output;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function planReviewedStorefrontContent(root, configuration, sourceEntries, prior) {
|
|
111
|
+
if (prior?.profile === REVIEWED_STOREFRONT_PROFILE) {
|
|
112
|
+
if (json(prior.routes.map(({path,kind,slug,locale}) => ({path,kind,slug,locale}))) !== json(configuration.routes)) fail('The installed reviewed storefront route ownership changed. Preserve it and review the adapter.');
|
|
113
|
+
const names = new Set(prior.files.map(file => file.path));
|
|
114
|
+
for (const row of prior.routes) {
|
|
115
|
+
const frozen = await readFrozenContent(root,row), source = sourceEntries.find(entry => entry.kind === row.kind && entry.slug === row.slug && entry.locale === row.locale);
|
|
116
|
+
if (!source || json(source.document) !== json(frozen.entry.document)) fail('A reviewed storefront source baseline changed. Preserve the imported block IDs and review it manually.');
|
|
117
|
+
for (const prefix of ['source','native','inventory','original']) names.add(row[`${prefix}File`]);
|
|
118
|
+
}
|
|
119
|
+
names.add(CONTENT_BINDINGS_FILE);
|
|
120
|
+
return { root, framework:'vite', routes:prior.routes, files:await Promise.all([...names].map(async name => ({path:name,content:await readProjectFile(root,name),state:'unchanged'}))) };
|
|
121
|
+
}
|
|
122
|
+
if (prior) return null;
|
|
123
|
+
const profile = await discoverReviewedStorefrontSource(root,{locale:configuration.routes[0]?.locale ?? 'en',readFile:name => readProjectFile(root,name)});
|
|
124
|
+
if (!profile) return null;
|
|
125
|
+
if (configuration.routes.length !== profile.manifest.entries.length || configuration.routes.some(row => !profile.manifest.entries.some(entry => entry.kind === row.kind && entry.slug === row.slug && entry.locale === row.locale && row.path === (entry.slug === 'home' ? '/' : '/' + entry.slug)))) fail('Every reviewed storefront URL must retain its original CMS identity.');
|
|
126
|
+
if (json(sourceEntries) !== json(profile.manifest.entries)) fail('The reviewed storefront import differs from the exact native source baseline. Existing CMS block IDs were preserved.');
|
|
127
|
+
const modules = transformedModules(profile.native.sources), files = [], routes = [], inventories = {};
|
|
128
|
+
const add = async (name, content, expected = null) => {
|
|
129
|
+
const current = await readProjectFile(root,name);
|
|
130
|
+
if (current !== expected) fail('A reviewed storefront output already exists or changed. Existing files were preserved.');
|
|
131
|
+
files.push({path:name,content,state:current === null ? 'create':'update',...(current !== null ? {previous:current}:{})});
|
|
132
|
+
};
|
|
133
|
+
const original = profile.native.sources.get('src/App.jsx'), native = modules.get('src/App.jsx');
|
|
134
|
+
const nativeFile = 'src/App.cms-native.jsx', originalFile = '.universalcms/content/storefront.original.txt';
|
|
135
|
+
const wrapper = "// Generated by @universalcms/connect. Native route state and CMS ownership are reviewed together.\nexport { default } from './App.cms-native';\n";
|
|
136
|
+
for (const route of configuration.routes) {
|
|
137
|
+
const entry = sourceEntries.find(entry => entry.slug === route.slug), keys = profile.native.keys[route.slug];
|
|
138
|
+
const inventory = {identity:{kind:route.kind,slug:route.slug,locale:route.locale},baseline:entry.document,bindings:Object.fromEntries(keys.map(key => [key,{variants:[{values:[],id:reviewedStorefrontBlockId(`${route.slug}:${key}`)}]}]))};
|
|
139
|
+
inventory.titleBlockId = reviewedStorefrontBlockId(`${route.slug}:${route.slug === 'home' ? 'hero-title' : ['outerwear','streetwear','tailoring','dresses','accessories'].includes(route.slug) ? 'category-title':'title'}`);
|
|
140
|
+
inventory.excerptBlockId = reviewedStorefrontBlockId(`${route.slug}:${route.slug === 'home' ? 'hero-description' : ['outerwear','streetwear','tailoring','dresses','accessories'].includes(route.slug) ? 'category-description':'subtitle'}`);
|
|
141
|
+
const inventoryFile = `.universalcms/content/storefront-${route.slug}.json`, inventoryText = json(inventory);
|
|
142
|
+
await add(inventoryFile,inventoryText); inventories[route.slug] = inventory;
|
|
143
|
+
routes.push({...route,sourceFile:'src/App.jsx',nativeFile,originalFile,inventoryFile,sourceHash:contentDigest(wrapper),nativeHash:contentDigest(native),originalHash:contentDigest(original),inventoryHash:contentDigest(inventoryText)});
|
|
144
|
+
}
|
|
145
|
+
await add(originalFile,original); await add(nativeFile,native); await add(helperFile,storefrontHelperTemplate()); await add(indexFile, Object.keys(inventories).map((slug,index) => `import route${index} from './storefront-${slug}.json';`).join('\n') + '\nexport default {' + Object.keys(inventories).map((slug,index) => `${JSON.stringify(slug)}:route${index}`).join(',') + '};\n');
|
|
146
|
+
for (const [name,content] of modules) if(name !== 'src/App.jsx') {
|
|
147
|
+
await add(`.universalcms/content/original-${path.basename(name)}.txt`,profile.native.sources.get(name));
|
|
148
|
+
await add(name,content,profile.native.sources.get(name));
|
|
149
|
+
}
|
|
150
|
+
await add('src/App.jsx',wrapper,original);
|
|
151
|
+
for (const [name,content] of profile.native.sources) if (!modules.has(name)) files.push({path:name,content,state:'unchanged'});
|
|
152
|
+
const registry = {version:1,profile:REVIEWED_STOREFRONT_PROFILE,nativePaths:reviewedNativePaths(REVIEWED_STOREFRONT_PROFILE),routes,files:files.filter(file => !file.path.startsWith('.universalcms/content/storefront-')).map(file => ({path:file.path,sha256:contentDigest(file.content)}))};
|
|
153
|
+
await add(CONTENT_BINDINGS_FILE,json(registry));
|
|
154
|
+
return {root,framework:'vite',files,routes};
|
|
155
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/** Native React/Vite binding setup. Source files are parsed, never executed. */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { lstat } from 'node:fs/promises';
|
|
4
|
+
import { projectRoot, readProjectFile, SetupError } from './core.mjs';
|
|
5
|
+
import { validatePublicationConfig } from './publication.mjs';
|
|
6
|
+
import { transformSourcePublication } from './source-bindings.mjs';
|
|
7
|
+
import { CONTENT_BINDINGS_FILE, contentDigest, readContentRegistry, readFrozenContent } from './content-registry.mjs';
|
|
8
|
+
import { readPlannedSourceFile } from './source-setup.mjs';
|
|
9
|
+
import { publicBindingInventory } from './public-bindings.mjs';
|
|
10
|
+
import { planReviewedStorefrontContent } from './content-storefront-setup.mjs';
|
|
11
|
+
|
|
12
|
+
export const CONTENT_ROUTES_FILE = '.universalcms/content.routes.json';
|
|
13
|
+
const json = value => `${JSON.stringify(value, null, 2)}\n`;
|
|
14
|
+
const fail = message => { throw new SetupError(message); };
|
|
15
|
+
const identity = row => `${row.kind}:${row.locale}:${row.slug}`;
|
|
16
|
+
const relativeImport = (from, to) => { const value = path.posix.relative(path.posix.dirname(from), to); return value.startsWith('.') ? value : `./${value}`; };
|
|
17
|
+
const modulePath = value => typeof value === 'string' && /^src\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+\.(?:jsx|tsx)$/.test(value) && !/(?:^|\/)(?:api|admin|account|auth|checkout|private|patient|vault|dashboard)(?:\/|\.)/.test(value);
|
|
18
|
+
|
|
19
|
+
export function viteContentPageTemplate(row, frozen) {
|
|
20
|
+
const native = relativeImport(row.sourceFile, row.nativeFile).replace(/\.(?:jsx|tsx)$/, '');
|
|
21
|
+
const inventory = relativeImport(row.sourceFile, row.inventoryFile);
|
|
22
|
+
let publicInventory;if(frozen){try{publicInventory=publicBindingInventory(frozen);}catch{fail('Public React content needs explicit review for structured or data-driven source bindings. Existing source was preserved.');}}
|
|
23
|
+
return `// Generated by @universalcms/connect. Published content only; native state stays mounted.\nimport { createElement } from 'react';\nimport { PublishedDocumentProvider } from '@universalcms/connect/react-publication';\nimport '@universalcms/connect/publication.css';\nimport NativePage from ${JSON.stringify(native)};\n${publicInventory?'const inventory = '+JSON.stringify(publicInventory)+';':'import inventory from '+JSON.stringify(inventory)+';'}\nexport * from ${JSON.stringify(native)};\n\nexport default function UniversalCMSPublishedPage(props${row.sourceFile.endsWith('.tsx') ? ': Record<string, unknown>' : ''}) {\n return createElement(PublishedDocumentProvider, { pathname: ${JSON.stringify(row.path)}, route: inventory${row.sourceFile.endsWith('.tsx') ? " as import('@universalcms/connect/react-publication').Inventory" : ''}, children: createElement(NativePage, props) });\n}\n`;
|
|
24
|
+
}
|
|
25
|
+
const wrapper = viteContentPageTemplate;
|
|
26
|
+
|
|
27
|
+
async function filePlan(root, filename, content, previous) {
|
|
28
|
+
const current = await readProjectFile(root, filename);
|
|
29
|
+
if (current !== null && (await lstat(path.join(root, filename))).nlink !== 1) fail('Content setup cannot replace linked files.');
|
|
30
|
+
if (current !== null && current !== content && current !== previous) fail('A content binding file contains custom changes. Preserve them and review the integration manually.');
|
|
31
|
+
return { path: filename, content, state: current === content ? 'unchanged' : current === null ? 'create' : 'update', ...(current !== null && current !== content ? { previous: current } : {}) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Explicit reviewed route/module mapping, or one unambiguous App homepage. */
|
|
35
|
+
export async function planViteContent(directory, configuration, sourcePlan) {
|
|
36
|
+
const root = await projectRoot(directory), config = validatePublicationConfig(configuration);
|
|
37
|
+
if (sourcePlan && sourcePlan.root !== root) fail('The planned source inventory belongs to a different website.');
|
|
38
|
+
const registry = await readContentRegistry(root);
|
|
39
|
+
const reviewedSourceText = readPlannedSourceFile(sourcePlan, '.universalcms/source.entries.json') ?? await readProjectFile(root, '.universalcms/source.entries.json');
|
|
40
|
+
let reviewedEntries;
|
|
41
|
+
try { reviewedEntries = JSON.parse(reviewedSourceText)?.entries; } catch { /* The ordinary planner supplies its existing diagnostic. */ }
|
|
42
|
+
if (Array.isArray(reviewedEntries)) {
|
|
43
|
+
const reviewed = await planReviewedStorefrontContent(root, config, reviewedEntries, registry);
|
|
44
|
+
if (reviewed) return reviewed;
|
|
45
|
+
}
|
|
46
|
+
const mappingText = readPlannedSourceFile(sourcePlan, CONTENT_ROUTES_FILE) ?? await readProjectFile(root, CONTENT_ROUTES_FILE);
|
|
47
|
+
let mappings;
|
|
48
|
+
if (mappingText !== null) {
|
|
49
|
+
try { mappings = JSON.parse(mappingText); } catch { fail('Use a JSON array of path and sourceFile mappings in .universalcms/content.routes.json.'); }
|
|
50
|
+
if (!Array.isArray(mappings) || mappings.length > 1000 || mappings.some(row => !row || Object.keys(row).sort().join() !== 'path,sourceFile' || typeof row.path !== 'string' || !modulePath(row.sourceFile)) || new Set(mappings.map(row => row.path)).size !== mappings.length || new Set(mappings.map(row => row.sourceFile)).size !== mappings.length) fail('Content routes require unique public paths and ordinary src/*.jsx or src/*.tsx modules. Map one default-exported page component per original URL.');
|
|
51
|
+
} else if (registry) mappings = registry.routes.map(row => ({ path: row.path, sourceFile: row.sourceFile }));
|
|
52
|
+
else {
|
|
53
|
+
const candidates = [];
|
|
54
|
+
if (config.routes.length === 1 && config.routes[0].path === '/') for (const filename of ['src/App.jsx', 'src/App.tsx']) if (await readProjectFile(root, filename) !== null) candidates.push(filename);
|
|
55
|
+
if (candidates.length !== 1) fail('CONTENT RENDERING INCOMPLETE: Map each reviewed Vite URL to its public page component in .universalcms/content.routes.json (for example [{"path":"/","sourceFile":"src/App.jsx"}]), then rerun setup. Source import and publication checks alone do not render CMS edits.');
|
|
56
|
+
mappings = [{ path: '/', sourceFile: candidates[0] }];
|
|
57
|
+
}
|
|
58
|
+
if (config.routes.length !== mappings.length || config.routes.some(route => !mappings.some(row => row.path === route.path))) fail('Every reviewed publication URL needs one content route mapping. Preserve custom routers and map their actual page modules explicitly.');
|
|
59
|
+
if (registry?.routes.some(row => !config.routes.some(route => route.path === row.path && identity(route) === identity(row)))) fail('Previously bound routes were removed or remapped. Preserve their ownership and review the existing content bindings manually.');
|
|
60
|
+
const sourceText = readPlannedSourceFile(sourcePlan, '.universalcms/source.entries.json') ?? await readProjectFile(root, '.universalcms/source.entries.json');
|
|
61
|
+
let entries;
|
|
62
|
+
try { entries = JSON.parse(sourceText).entries; if (!Array.isArray(entries)) throw Error(); } catch { fail('Published-content setup requires the reviewed Vite source manifest.'); }
|
|
63
|
+
const files = [], rows = [];
|
|
64
|
+
for (const route of config.routes) {
|
|
65
|
+
const mapping = mappings.find(row => row.path === route.path);
|
|
66
|
+
const previous = registry?.routes.find(row => row.path === route.path);
|
|
67
|
+
const exported = entries.find(entry => identity(entry) === identity(route));
|
|
68
|
+
if (!exported) fail('A content route has no matching reviewed source document.');
|
|
69
|
+
if (previous) {
|
|
70
|
+
if (previous.sourceFile !== mapping.sourceFile || identity(previous) !== identity(route)) fail('An installed content mapping changed. Review it manually before changing native module ownership.');
|
|
71
|
+
const frozen = await readFrozenContent(root, previous);
|
|
72
|
+
if (json(frozen.entry.document) !== json(exported.document)) fail('A bound source baseline changed. Keep the frozen import identities and edit public content in the CMS.');
|
|
73
|
+
if (await readProjectFile(root, previous.sourceFile) !== wrapper(previous, frozen.inventory)) fail('The existing React content wrapper needs review for the public binding format. Existing files were preserved; this setup does not rewrite older or customized adapters.');
|
|
74
|
+
for (const name of [previous.sourceFile, previous.nativeFile, previous.inventoryFile, previous.originalFile]) files.push(await filePlan(root, name, await readProjectFile(root, name)));
|
|
75
|
+
rows.push(previous); continue;
|
|
76
|
+
}
|
|
77
|
+
const original = await readProjectFile(root, mapping.sourceFile);
|
|
78
|
+
if (original === null || /@universalcms\/connect\/react-publication|\b(?:useSession|getServerSession|requireAuth|currentUser)\s*\(/.test(original)) fail('The selected public component needs a reviewed content binding. Existing integrations and access controls were preserved.');
|
|
79
|
+
if (exported.document?.blocks?.length === 0) {
|
|
80
|
+
fail('Public React source needs explicit review: a fallback page has no complete literal content inventory. Existing source was preserved.');
|
|
81
|
+
}
|
|
82
|
+
const isFallback = sourcePlan?.diagnostics?.discovery === 'fallback' || (exported.document?.blocks?.length === 1 && exported.document.blocks[0]?.id === 'block-home-heading');
|
|
83
|
+
if (isFallback) fail('Public React source needs explicit review: automatic fallback discovery has no verified native content bindings. Provide a reviewed source manifest and renderer mapping before full setup. Existing source was preserved.');
|
|
84
|
+
const transformed = transformSourcePublication({ source: original, slug: route.slug, locale: route.locale, filename: mapping.sourceFile, allowNamedExports: true, publicSource: true });
|
|
85
|
+
const extracted = transformed.inventory.baseline.blocks, actual = exported.document?.blocks;
|
|
86
|
+
// IDs imported by an AI mapping may differ from deterministic extraction.
|
|
87
|
+
// Reuse them only when the entire ordered block structure matches exactly.
|
|
88
|
+
const signature = block => JSON.stringify({ type: block.type, text: block.text, data: block.data ?? {} });
|
|
89
|
+
if (!Array.isArray(actual) || extracted.length !== actual.length || extracted.some((block, index) => signature(block) !== signature(actual[index]))) fail('The reviewed source document does not exactly match this page component. Preserve imported IDs and use an explicit shared renderer mapping; setup cannot guess which native text belongs to a CMS block.');
|
|
90
|
+
const ids = new Map(extracted.map((block, index) => [block.id, actual[index].id]));
|
|
91
|
+
for (const binding of Object.values(transformed.inventory.bindings)) for (const variant of binding.variants) variant.id = ids.get(variant.id);
|
|
92
|
+
if (transformed.inventory.titleBlockId) transformed.inventory.titleBlockId = ids.get(transformed.inventory.titleBlockId);
|
|
93
|
+
transformed.inventory.baseline = exported.document;
|
|
94
|
+
transformed.inventory.identity = { kind: route.kind, slug: route.slug, locale: route.locale };
|
|
95
|
+
const token = contentDigest(route.path).slice(0, 20), extension = path.posix.extname(mapping.sourceFile);
|
|
96
|
+
const row = { ...route, sourceFile: mapping.sourceFile, nativeFile: mapping.sourceFile.slice(0, -extension.length) + '.cms-native' + extension, inventoryFile: `.universalcms/content/${token}.json`, originalFile: `.universalcms/content/${token}.original.txt` };
|
|
97
|
+
const inventoryText = json(transformed.inventory), source = wrapper(row, transformed.inventory);
|
|
98
|
+
Object.assign(row, { sourceHash: contentDigest(source), nativeHash: contentDigest(transformed.source), inventoryHash: contentDigest(inventoryText), originalHash: contentDigest(original) });
|
|
99
|
+
files.push(await filePlan(root, row.originalFile, original), await filePlan(root, row.nativeFile, transformed.source), await filePlan(root, row.inventoryFile, inventoryText), await filePlan(root, row.sourceFile, source, original));
|
|
100
|
+
rows.push(row);
|
|
101
|
+
}
|
|
102
|
+
files.push(await filePlan(root, CONTENT_BINDINGS_FILE, json({ version: 1, routes: rows }), registry ? await readProjectFile(root, CONTENT_BINDINGS_FILE) : undefined));
|
|
103
|
+
files.push(await filePlan(root, '.universalcms/source.entries.json', sourceText));
|
|
104
|
+
if (mappingText !== null) files.push(await filePlan(root, CONTENT_ROUTES_FILE, mappingText));
|
|
105
|
+
if (files.some(file => Buffer.byteLength(file.content) > 128 * 1024)) fail('A generated content binding exceeds the bounded setup-file budget. Split the mapping into reviewed components instead of saving an unreadable integration.');
|
|
106
|
+
return { root, framework: 'vite', files, routes: rows };
|
|
107
|
+
}
|
package/dist/core.mjs
CHANGED
|
@@ -4,59 +4,16 @@ import { randomUUID } from 'node:crypto';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { analyzeProjectDirectory } from './vendor/analyze-project.mjs';
|
|
6
6
|
import { verifyPublishedDelivery } from './vendor/check-delivery.mjs';
|
|
7
|
-
import { createTemplates, legacySetupGuide } from './templates.mjs';
|
|
7
|
+
import { createTemplates, legacySetupGuide, previousSetupGuide, priorPublicationSetupGuide, previousPublicationSetupGuide, previousRenderingSetupGuide, previousViteSourceWrapper, previousPublicationViteWrapper, previousDiagnosticViteWrapper } from './templates.mjs';
|
|
8
|
+
import { frameworkOwnershipReview } from './framework-ownership.mjs';
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
import { SetupError, projectRoot, inspect, readProjectFile } from './project-files.mjs';
|
|
11
|
+
export { SetupError, projectRoot, readProjectFile };
|
|
10
12
|
const fail = message => { throw new SetupError(message); };
|
|
11
13
|
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
12
|
-
const maxFileBytes = 128 * 1024;
|
|
13
14
|
export const CONFIG_FILE = '.universalcms/config.json';
|
|
14
15
|
const plain = value => value && typeof value === 'object' && !Array.isArray(value);
|
|
15
16
|
|
|
16
|
-
export async function projectRoot(directory) {
|
|
17
|
-
const requested = path.resolve(directory);
|
|
18
|
-
const stat = await lstat(requested);
|
|
19
|
-
if (!stat.isDirectory() || stat.isSymbolicLink()) fail('Choose an ordinary project directory, not a symbolic link.');
|
|
20
|
-
return realpath(requested);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Every segment is checked before reading or creating; external symlinks are never followed. */
|
|
24
|
-
async function inspect(root, relative, directory = false) {
|
|
25
|
-
if (typeof relative !== 'string' || !relative || relative.includes('\\') || path.isAbsolute(relative) || relative.split('/').some(part => !part || part === '.' || part === '..')) fail('Unsafe generated path.');
|
|
26
|
-
let target = root;
|
|
27
|
-
const segments = relative.split('/');
|
|
28
|
-
for (let index = 0; index < segments.length; index++) {
|
|
29
|
-
target = path.join(target, segments[index]);
|
|
30
|
-
let stat;
|
|
31
|
-
try { stat = await lstat(target); }
|
|
32
|
-
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
33
|
-
if (stat.isSymbolicLink() || await realpath(target) !== target) fail('A setup path is a symbolic link. Use ordinary project files.');
|
|
34
|
-
const shouldBeDirectory = index < segments.length - 1 || directory;
|
|
35
|
-
if (shouldBeDirectory ? !stat.isDirectory() : !stat.isFile()) fail('A setup path has an incompatible file type.');
|
|
36
|
-
if (index === segments.length - 1) return { target, stat };
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export async function readProjectFile(root, relative) {
|
|
41
|
-
const found = await inspect(root, relative);
|
|
42
|
-
if (!found) return null;
|
|
43
|
-
if (found.stat.size > maxFileBytes) fail('A setup file exceeds the read limit.');
|
|
44
|
-
const handle = await open(found.target, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
45
|
-
try {
|
|
46
|
-
const stat = await handle.stat();
|
|
47
|
-
if (!stat.isFile() || stat.ino !== found.stat.ino || stat.dev !== found.stat.dev || stat.size > maxFileBytes) fail('A project file changed during inspection. Retry setup.');
|
|
48
|
-
const buffer = Buffer.alloc(maxFileBytes + 1);
|
|
49
|
-
let bytes = 0;
|
|
50
|
-
while (bytes < buffer.length) {
|
|
51
|
-
const result = await handle.read(buffer, bytes, buffer.length - bytes, bytes);
|
|
52
|
-
if (!result.bytesRead) break;
|
|
53
|
-
bytes += result.bytesRead;
|
|
54
|
-
}
|
|
55
|
-
if (bytes > maxFileBytes) fail('A setup file exceeds the read limit.');
|
|
56
|
-
return new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, bytes));
|
|
57
|
-
} finally { await handle.close(); }
|
|
58
|
-
}
|
|
59
|
-
|
|
60
17
|
function supportedVersion(value, majors, minMinor = 0) {
|
|
61
18
|
if (typeof value !== 'string') return false;
|
|
62
19
|
const match = /^(?:\^|~)?(\d+)\.(\d+)\.(\d+)$/.exec(value);
|
|
@@ -81,13 +38,22 @@ export function validateConfig(input) {
|
|
|
81
38
|
}
|
|
82
39
|
|
|
83
40
|
async function checkNextRoute(root, appDirectory, mount, existingConfig) {
|
|
41
|
+
let settingsObservations = [];
|
|
84
42
|
const mountName = mount.slice(1);
|
|
85
43
|
const route = `${appDirectory}/${mountName}`;
|
|
86
44
|
const found = await inspect(root, route, true);
|
|
87
45
|
if (found && !existingConfig) fail('The chosen Next.js mount already exists. Choose a free mount or integrate it manually.');
|
|
88
46
|
// A sibling group/dynamic route can own the same URL. Never guess its semantics.
|
|
89
47
|
const entries = await readdir(path.join(root, appDirectory), { withFileTypes: true });
|
|
90
|
-
if (entries.some(entry => entry.name
|
|
48
|
+
if (existingConfig && entries.some(entry => entry.name === '[...cmsPath]')) {
|
|
49
|
+
// The explicit settings installer owns this route. Validate its entire
|
|
50
|
+
// current plan; a folder name or a user-edited manifest alone is not proof.
|
|
51
|
+
const { planWebsiteSettings } = await import('./website-setup.mjs');
|
|
52
|
+
const settings = await planWebsiteSettings(root);
|
|
53
|
+
if (settings.files.some(file => file.state !== 'unchanged') || !settings.files.some(file => file.path === `${appDirectory}/[...cmsPath]/page.tsx`)) fail('The website settings route needs review before setup.');
|
|
54
|
+
settingsObservations = settings.files;
|
|
55
|
+
}
|
|
56
|
+
if (entries.some(entry => (entry.name.startsWith('(') || entry.name.startsWith('[') || entry.name.startsWith('@')) && !(settingsObservations.length && entry.name === '[...cmsPath]'))) fail('Route groups, parallel routes or dynamic root routes need a manual route review before setup.');
|
|
91
57
|
for (const directory of ['pages', 'src/pages']) {
|
|
92
58
|
if (!await inspect(root, directory, true)) continue;
|
|
93
59
|
const siblings = await readdir(path.join(root, directory), { withFileTypes: true });
|
|
@@ -101,6 +67,7 @@ async function checkNextRoute(root, appDirectory, mount, existingConfig) {
|
|
|
101
67
|
if (children.some(name => !['page.jsx', 'error.jsx'].includes(name))) fail('The generated mount contains additional routes. Review the route tree manually.');
|
|
102
68
|
}
|
|
103
69
|
}
|
|
70
|
+
return settingsObservations;
|
|
104
71
|
}
|
|
105
72
|
|
|
106
73
|
export async function analyze(directory) {
|
|
@@ -119,6 +86,8 @@ export async function planSetup(directory, options, { reconfigure = false } = {}
|
|
|
119
86
|
if (manifest.workspaces || analysis.warnings.some(warning => /invalid|workspace|Conflicting/.test(warning))) fail('Choose one valid website project. Conflicting, workspace or unreadable configuration needs manual review.');
|
|
120
87
|
const framework = options.framework ?? (deps.next ? 'next' : deps.vite && deps.react ? 'vite' : null);
|
|
121
88
|
if (!['next', 'vite'].includes(framework)) fail('Supported templates are Next.js App Router and React/Vite. Other frameworks need manual setup.');
|
|
89
|
+
const ownership = await frameworkOwnershipReview(manifest, framework, name => readProjectFile(root, name));
|
|
90
|
+
if (ownership) fail(ownership);
|
|
122
91
|
if (!supportedVersion(deps.react, [19], 2)) fail('The renderer requires a declared stable React 19.2 or newer 19.x version. Upgrade deliberately or use manual integration.');
|
|
123
92
|
let appDirectory = null;
|
|
124
93
|
if (framework === 'next') {
|
|
@@ -146,7 +115,7 @@ export async function planSetup(directory, options, { reconfigure = false } = {}
|
|
|
146
115
|
if (prior.framework !== config.framework || prior.mount !== config.mount || prior.appDirectory !== config.appDirectory) fail('Changing the generated route or framework needs a manual integration review. Existing routes were preserved.');
|
|
147
116
|
}
|
|
148
117
|
if (existingConfig && !reconfigure && existingConfig !== `${JSON.stringify(config, null, 2)}\n`) fail('This project already has a different CMS connection. Use interactive setup to update unchanged generated files, or review the integration manually.');
|
|
149
|
-
|
|
118
|
+
const settingsObservations = framework === 'next' ? await checkNextRoute(root, appDirectory, config.mount, existingConfig) : [];
|
|
150
119
|
const templateOptions = { existingNodeServer: framework === 'vite' && analysis.runtime === 'node' && analysis.rendering === 'server' };
|
|
151
120
|
const files = await createTemplates(config, templateOptions);
|
|
152
121
|
files.unshift({ path: CONFIG_FILE, content: `${JSON.stringify(config, null, 2)}\n` });
|
|
@@ -155,11 +124,12 @@ export async function planSetup(directory, options, { reconfigure = false } = {}
|
|
|
155
124
|
for (const file of files) {
|
|
156
125
|
const current = await readProjectFile(root, file.path);
|
|
157
126
|
const old = previous.get(file.path);
|
|
158
|
-
const generatedBefore = old !== undefined && (old === current || (file.path === '.universalcms/SETUP.md' && legacySetupGuide(old) === current));
|
|
127
|
+
const generatedBefore = old !== undefined && (old === current || (file.path === '.universalcms/vite.config.mjs' && (previousViteSourceWrapper(old) === current || previousPublicationViteWrapper(old) === current || previousDiagnosticViteWrapper(old) === current)) || (file.path === '.universalcms/SETUP.md' && (previousRenderingSetupGuide(old) === current || previousPublicationSetupGuide(old) === current || priorPublicationSetupGuide(old) === current || previousSetupGuide(old) === current || legacySetupGuide(old) === current)));
|
|
159
128
|
file.state = current === null ? 'create' : current === file.content ? 'unchanged' : reconfigure && generatedBefore ? 'update' : 'conflict';
|
|
160
129
|
if (file.state === 'update') file.previous = current;
|
|
161
130
|
}
|
|
162
131
|
if (files.some(file => file.state === 'conflict')) fail('A generated file already contains different content. No files were changed. Preserve your edits and finish the integration manually.');
|
|
132
|
+
for (const observation of settingsObservations) if (!files.some(file => file.path === observation.path)) files.push(observation);
|
|
163
133
|
return { root, config, analysis, files };
|
|
164
134
|
}
|
|
165
135
|
|
|
@@ -169,6 +139,7 @@ export async function validatePlan(plan) {
|
|
|
169
139
|
if (await projectRoot(plan.root) !== plan.root) fail('The project directory changed after planning. Run setup again from the intended directory.');
|
|
170
140
|
for (const file of plan.files) {
|
|
171
141
|
const current = await readProjectFile(plan.root, file.path);
|
|
142
|
+
if (file.state === 'unchanged' && current === null) fail('A file was removed after planning. Existing changes were preserved; run setup again.');
|
|
172
143
|
if (file.state === 'update') {
|
|
173
144
|
if (current !== file.previous) fail('A file changed after planning. No existing file was overwritten.');
|
|
174
145
|
const found = await inspect(plan.root, file.path);
|
|
@@ -214,7 +185,17 @@ export async function applyPlan(plan) {
|
|
|
214
185
|
export async function checkConnection(directory, { slug, env = process.env, fetcher } = {}) {
|
|
215
186
|
const root = await projectRoot(directory);
|
|
216
187
|
let config;
|
|
217
|
-
try {
|
|
188
|
+
try {
|
|
189
|
+
const existing = await readProjectFile(root, CONFIG_FILE);
|
|
190
|
+
if (existing !== null) config = validateConfig(JSON.parse(existing));
|
|
191
|
+
else {
|
|
192
|
+
const { readPublicationConfiguration } = await import('./publication-setup.mjs');
|
|
193
|
+
const publication = await readPublicationConfiguration(root);
|
|
194
|
+
const route = publication?.routes.find(item => item.kind === 'page' && item.slug === slug);
|
|
195
|
+
if (!publication || !route) throw Error();
|
|
196
|
+
config = { ...publication, locale: route.locale };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
218
199
|
catch { fail('Run init first and keep its valid connection configuration.'); }
|
|
219
200
|
return verifyPublishedDelivery({ slug, locale: config.locale, env: {
|
|
220
201
|
// Match the generated runtime's private origin override while retaining its
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
|
2
|
+
const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
3
|
+
const configs = name => ['js', 'mjs', 'cjs', 'ts'].map(extension => `${name}.config.${extension}`);
|
|
4
|
+
const owners = [
|
|
5
|
+
{ label: 'Next.js', adapter: 'next', dependencies: ['next'], files: configs('next') },
|
|
6
|
+
{ label: 'Nuxt', adapter: 'nuxt', dependencies: ['nuxt'], files: configs('nuxt') },
|
|
7
|
+
{ label: 'Astro', adapter: 'astro', dependencies: ['astro'], files: [...configs('astro'), 'astro.config.mts', 'astro.config.cts'] },
|
|
8
|
+
{ label: 'SvelteKit', adapter: 'svelte', dependencies: ['@sveltejs/kit', 'svelte'], files: configs('svelte') },
|
|
9
|
+
{ label: 'Angular', adapter: 'angular', dependencies: ['@angular/core'], files: ['angular.json'] },
|
|
10
|
+
{ label: 'Vue', adapter: 'vue', dependencies: ['vue', '@vue/cli-service'], files: configs('vue') },
|
|
11
|
+
{ label: 'Remix or React Router framework', dependencies: ['@remix-run/react', '@remix-run/dev', '@react-router/dev'], files: [...configs('remix'), ...configs('react-router')] },
|
|
12
|
+
{ label: 'Gatsby', dependencies: ['gatsby'], files: ['gatsby-config.js', 'gatsby-config.mjs', 'gatsby-config.ts'] },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
/** A missing framework command is not evidence that another framework's files
|
|
16
|
+
* belong to React/Vite. This only narrows adapter eligibility; it does not change
|
|
17
|
+
* the public project-analysis report or infer a backend from dependencies. */
|
|
18
|
+
export async function frameworkOwnershipReview(manifest, adapter, readFile) {
|
|
19
|
+
if (!plain(manifest) || ['dependencies', 'devDependencies', 'optionalDependencies'].some(name => manifest[name] !== undefined && !plain(manifest[name]))) return 'The website dependency declarations need manual review.';
|
|
20
|
+
const declared = name => ['dependencies', 'devDependencies', 'optionalDependencies'].some(field => plain(manifest[field]) && own(manifest[field], name));
|
|
21
|
+
for (const owner of owners) {
|
|
22
|
+
if (owner.adapter === adapter || adapter === 'nuxt' && owner.adapter === 'vue') continue;
|
|
23
|
+
if (owner.dependencies.some(declared)) return `${owner.label} ownership evidence requires a reviewed integration; automatic ${adapter === 'vite' ? 'React/Vite' : adapter === 'astro' ? 'Astro' : 'Next.js'} setup cannot adopt it.`;
|
|
24
|
+
for (const file of owner.files) {
|
|
25
|
+
try {
|
|
26
|
+
if (await readFile(file) !== null) return `${owner.label} configuration requires a reviewed integration; automatic ${adapter === 'vite' ? 'React/Vite' : adapter === 'astro' ? 'Astro' : 'Next.js'} setup cannot adopt it.`;
|
|
27
|
+
} catch { return 'A framework configuration could not be safely inspected. Preserve it and complete a manual integration review.'; }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ordinaryNextCommand(value, operation) {
|
|
34
|
+
if (typeof value !== 'string' || value.length > 8192 || /[\r\n\0;&|`$<>\\]/.test(value)) return false;
|
|
35
|
+
const words = []; let token = '', quote = '', started = false;
|
|
36
|
+
for (const character of value.trim()) {
|
|
37
|
+
if (quote) { if (character === quote) quote = ''; else token += character; }
|
|
38
|
+
else if (character === '"' || character === "'") { quote = character; started = true; }
|
|
39
|
+
else if (/\s/.test(character)) { if (started) { words.push(token); token = ''; started = false; } }
|
|
40
|
+
else { token += character; started = true; }
|
|
41
|
+
}
|
|
42
|
+
if (quote) return false;
|
|
43
|
+
if (started) words.push(token);
|
|
44
|
+
if (words[0] !== 'next' || words[1] !== operation) return false;
|
|
45
|
+
const flags = new Set(operation === 'dev' ? ['--turbo', '--turbopack', '--webpack', '--experimental-https', '--disable-source-maps'] : []);
|
|
46
|
+
let directory = false;
|
|
47
|
+
for (let index = 2; index < words.length; index++) {
|
|
48
|
+
const word = words[index];
|
|
49
|
+
if (word === '.') { if (directory) return false; directory = true; continue; }
|
|
50
|
+
if (flags.has(word)) continue;
|
|
51
|
+
const equal = word.indexOf('='), name = equal === -1 ? word : word.slice(0, equal);
|
|
52
|
+
const argument = equal === -1 ? words[++index] : word.slice(equal + 1);
|
|
53
|
+
if (['-p', '--port'].includes(name)) { if (!/^\d{1,5}$/.test(argument ?? '') || Number(argument) < 1 || Number(argument) > 65535) return false; }
|
|
54
|
+
else if (['-H', '--hostname'].includes(name)) { if (!/^[a-z\d._:[\]-]+$/i.test(argument ?? '')) return false; }
|
|
55
|
+
else if (operation === 'start' && name === '--keepAliveTimeout') { if (!/^\d{1,9}$/.test(argument ?? '')) return false; }
|
|
56
|
+
else return false;
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Separate predev/prestart hooks remain owned by the project. Only commands
|
|
62
|
+
* that actually start Next are checked; custom wrappers are never executed. */
|
|
63
|
+
export function nextStartupReview(manifest) {
|
|
64
|
+
if (manifest.scripts === undefined) return null;
|
|
65
|
+
if (!plain(manifest.scripts)) return 'The website scripts need manual review.';
|
|
66
|
+
for (const name of ['dev', 'start']) if (own(manifest.scripts, name) && !ordinaryNextCommand(manifest.scripts[name], name)) return 'Next.js dev/start scripts use a custom server, project directory or unrecognized wrapper. Preserve that startup and use a reviewed Next.js integration.';
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { analyze, projectRoot, readProjectFile, SetupError } from './core.mjs';
|
|
2
|
+
import { inspectAstroProject } from './astro-setup.mjs';
|
|
3
|
+
import { frameworkOwnershipReview, nextStartupReview } from './framework-ownership.mjs';
|
|
4
|
+
import { nativeFramework, nativeFrameworkLabels, inspectNativeProject, isNativeFramework } from './native-adapters.mjs';
|
|
5
|
+
|
|
6
|
+
const plain = value => value && typeof value === 'object' && !Array.isArray(value);
|
|
7
|
+
const supportedVersion = (value, majors, minimumMinor = 0) => {
|
|
8
|
+
const match = typeof value === 'string' && /^(?:\^|~)?(\d+)\.(\d+)\.(\d+)$/.exec(value);
|
|
9
|
+
return !!match && majors.includes(Number(match[1])) && Number(match[2]) >= minimumMinor;
|
|
10
|
+
};
|
|
11
|
+
const labels = new Set(['Next.js', 'React', 'Vue', 'Nuxt', 'Angular', 'Astro', 'SvelteKit', 'Laravel', 'WordPress', 'Static', 'Custom']);
|
|
12
|
+
|
|
13
|
+
/** Select from local evidence, never from a CMS label or the absence of Next.js. */
|
|
14
|
+
export async function inspectSetupFramework(directory, { analysis: existingAnalysis } = {}) {
|
|
15
|
+
const root = await projectRoot(directory);
|
|
16
|
+
const analysis = existingAnalysis ?? await analyze(root);
|
|
17
|
+
const label = labels.has(analysis.framework) ? analysis.framework : 'Custom';
|
|
18
|
+
const manual = reason => {
|
|
19
|
+
const guidance = ['Laravel', 'WordPress'].includes(label)
|
|
20
|
+
? 'Use the existing PHP backend and server-side delivery REST API; do not install React adapters.'
|
|
21
|
+
: ['Vue', 'Nuxt', 'Angular', 'SvelteKit'].includes(label)
|
|
22
|
+
? `Use the supported ${label} Node setup from Developer setup. Custom routes, layouts and unsupported versions require review.`
|
|
23
|
+
: 'Use Developer setup to review the existing runtime, public routes and content bindings with your developer.';
|
|
24
|
+
throw new SetupError(`Detected ${label}. ${reason} Automatic setup supports reviewed Next.js, React/Vite, Astro, Vue, Nuxt, Angular SSR, SvelteKit and literal HTML profiles with compatible installed versions. WordPress uses its separate native plugin and WP-CLI. ${guidance} No integration files or keys were saved.`);
|
|
25
|
+
};
|
|
26
|
+
let manifest;
|
|
27
|
+
try { manifest = JSON.parse(await readProjectFile(root, 'package.json')); } catch { /* Report only a fixed, public diagnostic. */ }
|
|
28
|
+
if (!plain(manifest)) manual('A valid website package.json is required for this CLI adapter.');
|
|
29
|
+
if (manifest.workspaces || analysis.warnings.some(value => /invalid|workspace|conflicting/i.test(value))) manual('Workspace, conflicting or unreadable project evidence requires a manual review.');
|
|
30
|
+
for (const key of ['dependencies', 'devDependencies', 'scripts']) if (manifest[key] !== undefined && !plain(manifest[key])) manual('The website manifest needs review.');
|
|
31
|
+
const deps = { ...manifest.devDependencies, ...manifest.dependencies };
|
|
32
|
+
let framework;
|
|
33
|
+
const native = nativeFramework(manifest);
|
|
34
|
+
if (native) {
|
|
35
|
+
await inspectNativeProject(root, native, manifest);
|
|
36
|
+
framework = native;
|
|
37
|
+
} else if (deps.astro && label === 'Astro') {
|
|
38
|
+
await inspectAstroProject(root, manifest);
|
|
39
|
+
framework = 'astro';
|
|
40
|
+
} else if (label === 'Next.js' && deps.next) {
|
|
41
|
+
if (!supportedVersion(deps.next, [16]) || analysis.runtime !== 'node') manual('Static exports, unsupported Next.js versions and uncertain server runtimes need a framework-specific integration.');
|
|
42
|
+
const startup = nextStartupReview(manifest);
|
|
43
|
+
if (startup) manual(startup);
|
|
44
|
+
framework = 'next';
|
|
45
|
+
} else if (label === 'React' && analysis.frontend === 'React' && deps.react && deps.vite && !deps.next) {
|
|
46
|
+
if (!supportedVersion(deps.vite, [6, 7, 8])) manual('This Vite version is not supported.');
|
|
47
|
+
if (analysis.runtime === 'node' || analysis.rendering === 'server') manual('The existing custom server must retain its routing and renderer; integrate the CMS handlers explicitly.');
|
|
48
|
+
framework = 'vite';
|
|
49
|
+
} else manual('This project does not match an automatic page-binding adapter.');
|
|
50
|
+
const ownership = await frameworkOwnershipReview(manifest, framework, name => readProjectFile(root, name));
|
|
51
|
+
if (ownership) manual(ownership);
|
|
52
|
+
if (!isNativeFramework(framework) && !supportedVersion(deps.react, [19], 2)) manual('The shared renderer requires a declared stable React 19.2 or newer 19.x version. Upgrade deliberately or use the existing renderer.');
|
|
53
|
+
return { root, framework, label: nativeFrameworkLabels[framework] ?? (framework === 'vite' ? 'React/Vite' : framework === 'astro' ? 'Astro' : 'Next.js'), analysis, manifest };
|
|
54
|
+
}
|