@universalcms/connect 0.0.0-stage → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +779 -2
- package/dist/angular-runtime.d.mts +4 -0
- package/dist/angular-runtime.mjs +7 -0
- package/dist/angular-setup.mjs +124 -0
- package/dist/astro-runtime.d.mts +6 -0
- package/dist/astro-runtime.mjs +255 -0
- package/dist/astro-setup.mjs +215 -0
- package/dist/astro-source.mjs +451 -0
- package/dist/astro-website.d.mts +5 -0
- package/dist/astro-website.mjs +42 -0
- package/dist/cli.mjs +552 -0
- package/dist/connector-runtime.mjs +130 -0
- package/dist/content-next-setup.mjs +200 -0
- package/dist/content-registry-validation.mjs +40 -0
- package/dist/content-registry.mjs +44 -0
- package/dist/content-setup.mjs +13 -0
- package/dist/content-storefront-setup.mjs +155 -0
- package/dist/content-vite-setup.mjs +107 -0
- package/dist/core.mjs +205 -0
- package/dist/framework-ownership.mjs +68 -0
- package/dist/framework.mjs +54 -0
- package/dist/guided.mjs +335 -0
- package/dist/native-adapters.mjs +31 -0
- package/dist/native-chrome-model.d.mts +3 -0
- package/dist/native-chrome-model.mjs +14 -0
- package/dist/native-chrome-review.mjs +49 -0
- package/dist/native-chrome-source.mjs +69 -0
- package/dist/native-route-review.mjs +152 -0
- package/dist/native-routes.mjs +27 -0
- package/dist/native-website-model.d.mts +18 -0
- package/dist/native-website-model.mjs +67 -0
- package/dist/native-website-server.d.mts +1 -0
- package/dist/native-website-server.mjs +35222 -0
- package/dist/native-website-server.mjs.LEGAL.txt +56 -0
- package/dist/native-website.d.mts +3 -0
- package/dist/native-website.mjs +120 -0
- package/dist/native-website.mjs.LEGAL.txt +56 -0
- package/dist/next-publication.d.mts +12 -0
- package/dist/next-publication.mjs +107 -0
- package/dist/next-settings-layout.mjs +233 -0
- package/dist/next-source.d.mts +8 -0
- package/dist/next-source.mjs +93 -0
- package/dist/page-discovery.mjs +236 -0
- package/dist/page-mapping-review.mjs +160 -0
- package/dist/preflight-recovery.mjs +122 -0
- package/dist/preflight.mjs +138 -0
- package/dist/project-files.mjs +56 -0
- package/dist/public-bindings.d.mts +3 -0
- package/dist/public-bindings.mjs +28 -0
- package/dist/publication-browser.d.mts +5 -0
- package/dist/publication-browser.mjs +258 -0
- package/dist/publication-check.mjs +141 -0
- package/dist/publication-document.d.mts +2 -0
- package/dist/publication-document.mjs +59 -0
- package/dist/publication-next-setup.mjs +134 -0
- package/dist/publication-setup.mjs +138 -0
- package/dist/publication-verification.mjs +85 -0
- package/dist/publication.css +6 -0
- package/dist/publication.d.mts +31 -0
- package/dist/publication.mjs +320 -0
- package/dist/react-publication.d.mts +22 -0
- package/dist/react-publication.mjs +301 -0
- package/dist/react-website.d.mts +15 -0
- package/dist/react-website.mjs +735 -0
- package/dist/reviewed-commerce-ownership.mjs +26 -0
- package/dist/reviewed-commerce-settings-setup.mjs +243 -0
- package/dist/reviewed-native-ownership.mjs +118 -0
- package/dist/reviewed-storefront-source.mjs +227 -0
- package/dist/runtime-dependencies.mjs +59 -0
- package/dist/setup-environment.mjs +307 -0
- package/dist/source-bindings.mjs +77 -0
- package/dist/source-check.mjs +149 -0
- package/dist/source-collect.mjs +80 -0
- package/dist/source-config.mjs +20 -0
- package/dist/source-extract.mjs +514 -0
- package/dist/source-finalize.mjs +52 -0
- package/dist/source-routes.mjs +65 -0
- package/dist/source-setup.mjs +490 -0
- package/dist/staged-setup.mjs +287 -0
- package/dist/static-runtime.mjs +67 -0
- package/dist/static-setup.mjs +136 -0
- package/dist/svelte-runtime.d.mts +27 -0
- package/dist/svelte-runtime.mjs +40 -0
- package/dist/svelte-setup.mjs +135 -0
- package/dist/svelte-template.mjs +48 -0
- package/dist/templates.mjs +248 -0
- package/dist/vendor/analyze-project.mjs +82 -0
- package/dist/vendor/astro-compiler/LICENSE +53 -0
- package/dist/vendor/astro-compiler/astro.wasm +0 -0
- package/dist/vendor/astro-compiler/chunk-W5DTLHV4.js +1 -0
- package/dist/vendor/astro-compiler/node/index.js +1 -0
- package/dist/vendor/check-delivery.mjs +158 -0
- package/dist/vendor/dependency-diagnostics.mjs +72 -0
- package/dist/vendor/inspect-installed-dependencies.mjs +125 -0
- package/dist/vendor/project-analysis.mjs +498 -0
- package/dist/vendor/react-LICENSE.txt +21 -0
- package/dist/vendor/react-document.mjs +124 -0
- package/dist/vendor/react-dom-LICENSE.txt +21 -0
- package/dist/vendor/react-form.mjs +63 -0
- package/dist/vendor/react-safety.mjs +47 -0
- package/dist/vendor/source-manifest-provider.mjs +104 -0
- package/dist/vendor/typescript-LICENSE.txt +55 -0
- package/dist/vendor/typescript.cjs +200276 -0
- package/dist/vite-handler.mjs +54 -0
- package/dist/vite-publication-server.d.mts +3 -0
- package/dist/vite-publication-server.mjs +82 -0
- package/dist/vite-publication.d.mts +15 -0
- package/dist/vite-publication.mjs +241 -0
- package/dist/vite-source-discovery.mjs +315 -0
- package/dist/vite-source.d.mts +30 -0
- package/dist/vite-source.mjs +141 -0
- package/dist/vue-runtime.d.mts +10 -0
- package/dist/vue-runtime.mjs +50 -0
- package/dist/vue-server.d.mts +2 -0
- package/dist/vue-server.mjs +58 -0
- package/dist/vue-setup.mjs +127 -0
- package/dist/vue-source.mjs +168 -0
- package/dist/website-check.mjs +41 -0
- package/dist/website-http.mjs +149 -0
- package/dist/website-model.d.mts +20 -0
- package/dist/website-model.mjs +155 -0
- package/dist/website-next.d.mts +2 -0
- package/dist/website-next.mjs +29 -0
- package/dist/website-node.d.mts +3 -0
- package/dist/website-node.mjs +50 -0
- package/dist/website-runtime.d.mts +13 -0
- package/dist/website-runtime.mjs +396 -0
- package/dist/website-schema.mjs +22175 -0
- package/dist/website-setup.mjs +256 -0
- package/dist/website.css +49 -0
- package/dist/wizard.mjs +298 -0
- package/package.json +60 -4
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/** Local page/screen discovery only. It never establishes public content or binding ownership. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { lstat, readdir, realpath } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import ts from './vendor/typescript.cjs';
|
|
6
|
+
import { projectRoot, readProjectFile, SetupError } from './core.mjs';
|
|
7
|
+
|
|
8
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
9
|
+
const plain = value => value && typeof value === 'object' && !Array.isArray(value);
|
|
10
|
+
const limits = { files: 1000, nodes: 5000, bytes: 8 * 1024 * 1024, depth: 12, candidates: 1000 };
|
|
11
|
+
const hidden = /^(?:\.|node_modules$|dist$|build$|coverage$|vendor$|\.next$)/;
|
|
12
|
+
const sensitive = /^(?:env|credentials?|secrets?)(?:[.-]|$)/i;
|
|
13
|
+
const sourceExtension = /\.(?:[cm]?[jt]sx?|vue|svelte|astro|html?|mdx?)$/;
|
|
14
|
+
const moduleExtensions = ['.jsx', '.tsx', '.js', '.ts', '.mjs', '.vue', '.svelte', '.astro'];
|
|
15
|
+
const nativePath = /(?:^|\/)(?:api|admin|auth|account|dashboard|billing|checkout|private|internal|patients?|vault)(?:\/|$)/i;
|
|
16
|
+
const safeRelative = value => typeof value === 'string' && value.length <= 500 && !value.includes('\\') && !/cms_(?:server|public)[._]|sk-[A-Za-z0-9_-]{12,}|AIza[A-Za-z0-9_-]{20,}/i.test(value) && !path.posix.isAbsolute(value) && value.split('/').every(part => part && part !== '.' && part !== '..' && !hidden.test(part) && !sensitive.test(part) && /^[A-Za-z0-9_@()[\].+ -]+$/.test(part));
|
|
17
|
+
const display = value => typeof value === 'string' && /^[A-Za-z0-9_./:@()[\] -]{1,120}$/.test(value) ? value : 'Unresolved component';
|
|
18
|
+
const unwrap = node => { while (node && (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node) || ts.isNonNullExpression(node))) node = node.expression; return node; };
|
|
19
|
+
const jsxName = node => ts.isJsxSelfClosingElement(node) ? node.tagName : ts.isJsxElement(node) ? node.openingElement.tagName : null;
|
|
20
|
+
const jsxAttributes = node => ts.isJsxSelfClosingElement(node) ? node.attributes.properties : ts.isJsxElement(node) ? node.openingElement.attributes.properties : [];
|
|
21
|
+
const propertyName = node => node && (ts.isIdentifier(node) || ts.isStringLiteral(node)) ? node.text : null;
|
|
22
|
+
const literalString = node => { node = unwrap(node); return node && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) ? node.text : null; };
|
|
23
|
+
|
|
24
|
+
/** A deterministic metadata inventory. Literal discovery is NOT a CMS-ready capability. */
|
|
25
|
+
export async function inspectPageCandidates(directory, options = {}) {
|
|
26
|
+
if (!plain(options) || Object.keys(options).some(key => !['locale', 'websiteOrigin'].includes(key))) throw new SetupError('Page discovery accepts only locale and optional website origin.');
|
|
27
|
+
try { if (typeof (options.locale ?? 'en') !== 'string' || (options.locale ?? 'en').length > 35 || !Intl.getCanonicalLocales(options.locale ?? 'en').length) throw Error(); } catch { throw new SetupError('Use a valid discovery locale.'); }
|
|
28
|
+
if (options.websiteOrigin !== undefined) {
|
|
29
|
+
try { const url = new URL(options.websiteOrigin); if (typeof options.websiteOrigin !== 'string' || url.origin !== options.websiteOrigin || url.username || url.password || !['https:', 'http:'].includes(url.protocol) || url.protocol === 'http:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) throw Error(); } catch { throw new SetupError('Use an exact HTTPS website origin or HTTP loopback origin.'); }
|
|
30
|
+
}
|
|
31
|
+
const root = await projectRoot(directory), observations = new Map(), cache = new Map(), candidates = new Map(), blockers = new Map();
|
|
32
|
+
let bytes = 0, nodes = 0, framework = 'custom', exhausted = false;
|
|
33
|
+
const block = (code, sourceFile, message) => {
|
|
34
|
+
const value = { code, ...(sourceFile && safeRelative(sourceFile) ? { path: sourceFile } : {}), message };
|
|
35
|
+
blockers.set(JSON.stringify(value), value);
|
|
36
|
+
};
|
|
37
|
+
const budget = () => { exhausted = true; block('inventory-budget', null, 'Discovery exceeded its bounded file, byte, depth or candidate budget. Preserve undiscovered routes and review the project.'); };
|
|
38
|
+
async function info(relative, directoryOnly = false) {
|
|
39
|
+
if (!safeRelative(relative)) { block('unsafe-path', null, 'A source path is outside the allowed local inventory. It was not read.'); return null; }
|
|
40
|
+
let target = root;
|
|
41
|
+
for (const [index, part] of relative.split('/').entries()) {
|
|
42
|
+
target = path.join(target, part);
|
|
43
|
+
let stat; try { stat = await lstat(target); } catch (error) { if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null; block('unreadable-path', relative, 'A local source path could not be inspected.'); return null; }
|
|
44
|
+
if (stat.isSymbolicLink() || await realpath(target) !== target) { block('linked-path', relative, 'Linked source paths are not followed. Preserve this route for manual review.'); return null; }
|
|
45
|
+
if (index < relative.split('/').length - 1 || directoryOnly) { if (!stat.isDirectory()) return null; }
|
|
46
|
+
else return stat;
|
|
47
|
+
}
|
|
48
|
+
return { isDirectory: () => true };
|
|
49
|
+
}
|
|
50
|
+
async function read(relative) {
|
|
51
|
+
if (cache.has(relative)) return cache.get(relative);
|
|
52
|
+
if (exhausted) return null;
|
|
53
|
+
const stat = await info(relative);
|
|
54
|
+
if (!stat) { cache.set(relative, null); return null; }
|
|
55
|
+
if (!stat.isFile() || stat.nlink !== 1) { block('nonordinary-file', relative, 'Only ordinary unlinked source files can be observed.'); cache.set(relative, null); return null; }
|
|
56
|
+
if (stat.size > 128 * 1024 || observations.size >= limits.files || bytes + stat.size > limits.bytes) { budget(); return null; }
|
|
57
|
+
let text;
|
|
58
|
+
try { text = await readProjectFile(root, relative); } catch { block('unreadable-source', relative, 'A source file changed, exceeded its read limit or could not be read safely.'); return null; }
|
|
59
|
+
if (text !== null) { bytes += Buffer.byteLength(text); observations.set(relative, hash(text)); }
|
|
60
|
+
cache.set(relative, text); return text;
|
|
61
|
+
}
|
|
62
|
+
async function walk(base, visit, depth = 0) {
|
|
63
|
+
if (exhausted) return;
|
|
64
|
+
if (depth > limits.depth) { budget(); return; }
|
|
65
|
+
if (base && !await info(base, true)) return;
|
|
66
|
+
let entries;
|
|
67
|
+
try { entries = await readdir(base ? path.join(root, base) : root, { withFileTypes: true }); } catch { block('unreadable-directory', base, 'A source directory could not be inventoried.'); return; }
|
|
68
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
69
|
+
if (exhausted) return;
|
|
70
|
+
if (++nodes > limits.nodes) { budget(); return; }
|
|
71
|
+
if (hidden.test(entry.name) || sensitive.test(entry.name)) continue;
|
|
72
|
+
const name = base ? `${base}/${entry.name}` : entry.name;
|
|
73
|
+
if (!safeRelative(name)) { block('unsafe-path', null, 'An unsupported source filename was omitted. Preserve it for review.'); continue; }
|
|
74
|
+
if (entry.isSymbolicLink()) { block('linked-path', name, 'Linked source paths are not followed. Preserve this route for manual review.'); continue; }
|
|
75
|
+
if (entry.isDirectory()) await walk(name, visit, depth + 1);
|
|
76
|
+
else if (entry.isFile()) await visit(name);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const add = (value, locator = '') => {
|
|
80
|
+
if (candidates.size >= limits.candidates) { budget(); return; }
|
|
81
|
+
const identity = JSON.stringify([framework, value.kind, value.path, value.hostPath, value.sourceFile, value.exportName ?? null, locator]);
|
|
82
|
+
const candidate = { id: `page-${hash(identity).slice(0, 24)}`, ...value, label: display(value.label) };
|
|
83
|
+
if (!candidate.reason) candidate.reason = nativePath.test(candidate.path ?? candidate.sourceFile) ? 'Application or protected ownership requires review; preserve native behavior.' : 'Discovery only; public content, bindings and publication ownership require review.';
|
|
84
|
+
candidates.set(candidate.id, candidate);
|
|
85
|
+
};
|
|
86
|
+
function parse(source, filename) {
|
|
87
|
+
if (source === null) return null;
|
|
88
|
+
const ast = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true, /x$/.test(filename) ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
|
89
|
+
if (ast.parseDiagnostics.length) { block('source-syntax', filename, 'Source syntax could not be parsed. Preserve its pages for review.'); return null; }
|
|
90
|
+
return ast;
|
|
91
|
+
}
|
|
92
|
+
function imports(ast) {
|
|
93
|
+
const values = new Map();
|
|
94
|
+
for (const statement of ast.statements) if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier) && statement.importClause && !statement.importClause.isTypeOnly) {
|
|
95
|
+
const specifier = statement.moduleSpecifier.text, clause = statement.importClause;
|
|
96
|
+
if (clause.name) values.set(clause.name.text, { specifier, exportName: 'default' });
|
|
97
|
+
if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) for (const item of clause.namedBindings.elements) if (!item.isTypeOnly) values.set(item.name.text, { specifier, exportName: item.propertyName?.text ?? item.name.text });
|
|
98
|
+
if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) values.set(clause.namedBindings.name.text, { specifier, exportName: '*' });
|
|
99
|
+
}
|
|
100
|
+
return values;
|
|
101
|
+
}
|
|
102
|
+
async function resolveModule(from, imported) {
|
|
103
|
+
if (!imported?.specifier?.startsWith('.') || /[?#\\]/.test(imported.specifier)) return null;
|
|
104
|
+
const base = path.posix.normalize(path.posix.join(path.posix.dirname(from), imported.specifier));
|
|
105
|
+
const choices = sourceExtension.test(base) ? [base] : path.posix.extname(base) ? [] : [...moduleExtensions.map(ext => base + ext), ...moduleExtensions.map(ext => `${base}/index${ext}`)];
|
|
106
|
+
const found = [];
|
|
107
|
+
for (const name of choices.filter(safeRelative)) { const stat = await info(name); if (stat?.isFile()) found.push(name); }
|
|
108
|
+
if (found.length !== 1) { block('module-resolution', from, 'A referenced page module is missing, linked or ambiguous. Keep it native until reviewed.'); return null; }
|
|
109
|
+
const text = await read(found[0]);
|
|
110
|
+
return text === null ? null : { sourceFile: found[0], text, exportName: imported.exportName };
|
|
111
|
+
}
|
|
112
|
+
function componentReferences(node, imported) {
|
|
113
|
+
const names = new Set();
|
|
114
|
+
const scan = current => { const name = jsxName(current); if (name && ts.isIdentifier(name) && imported.has(name.text)) names.add(name.text); ts.forEachChild(current, scan); };
|
|
115
|
+
scan(node); return [...names].sort();
|
|
116
|
+
}
|
|
117
|
+
const routePath = value => typeof value === 'string' && /^\/(?:[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*)?$/.test(value) ? value : null;
|
|
118
|
+
async function reactScreens(file, ast, hostPath) {
|
|
119
|
+
const imported = imports(ast), states = new Set(), selections = [];
|
|
120
|
+
const isState = expression => ts.isIdentifier(expression) ? imported.get(expression.text)?.specifier === 'react' && imported.get(expression.text)?.exportName === 'useState' : ts.isPropertyAccessExpression(expression) && expression.name.text === 'useState' && ts.isIdentifier(expression.expression) && imported.get(expression.expression.text)?.specifier === 'react';
|
|
121
|
+
const scanStates = node => { if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.name.elements[0] && ts.isBindingElement(node.name.elements[0]) && ts.isIdentifier(node.name.elements[0].name) && ts.isCallExpression(unwrap(node.initializer)) && isState(unwrap(node.initializer).expression)) states.add(node.name.elements[0].name.text); ts.forEachChild(node, scanStates); }; scanStates(ast);
|
|
122
|
+
const condition = expression => { const node = unwrap(expression); if (!node || !ts.isBinaryExpression(node) || ![ts.SyntaxKind.EqualsEqualsEqualsToken, ts.SyntaxKind.EqualsEqualsToken].includes(node.operatorToken.kind)) return null; for (const [identifier, literal] of [[unwrap(node.left), unwrap(node.right)], [unwrap(node.right), unwrap(node.left)]]) { const value = literalString(literal); if (identifier && ts.isIdentifier(identifier) && states.has(identifier.text) && value !== null && /^[a-zA-Z0-9_-]{1,80}$/.test(value)) return { selector: identifier.text, value }; } return null; };
|
|
123
|
+
const collect = (node, selected, fallback) => { const names = new Set(); const visit = child => { const tag = jsxName(child); if (tag && ts.isIdentifier(tag) && /^[A-Z]/.test(tag.text)) names.add(tag.text); ts.forEachChild(child, visit); }; visit(node); for (const name of names) selections.push({ name, selected, fallback }); };
|
|
124
|
+
const scan = node => {
|
|
125
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) collect(node.right, condition(node.left), 'Conditional component ownership is unresolved; preserve native behavior.');
|
|
126
|
+
if (ts.isConditionalExpression(node)) { collect(node.whenTrue, condition(node.condition), 'Conditional component ownership is unresolved; preserve native behavior.'); collect(node.whenFalse, null, 'Alternative conditional component ownership is unresolved; preserve native behavior.'); }
|
|
127
|
+
if (ts.isSwitchStatement(node)) for (const clause of node.caseBlock.clauses) { const literal = ts.isCaseClause(clause) ? literalString(clause.expression) : null; const selected = ts.isIdentifier(unwrap(node.expression)) && states.has(unwrap(node.expression).text) && literal !== null && /^[a-zA-Z0-9_-]{1,80}$/.test(literal) ? { selector: unwrap(node.expression).text, value: literal } : null; collect(clause, selected, 'Dynamic or default screen ownership is unresolved; preserve native behavior.'); }
|
|
128
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && /^[A-Z]/.test(node.name.text) && node.initializer && ts.isElementAccessExpression(unwrap(node.initializer))) selections.push({ name: node.name.text, selected: null, fallback: 'Computed screen selection requires review; preserve native behavior.' });
|
|
129
|
+
ts.forEachChild(node, scan);
|
|
130
|
+
}; scan(ast);
|
|
131
|
+
for (const selection of selections) {
|
|
132
|
+
const resolved = await resolveModule(file, imported.get(selection.name));
|
|
133
|
+
const literal = selection.selected && resolved;
|
|
134
|
+
add({ kind: 'screen', path: null, hostPath, sourceFile: resolved?.sourceFile ?? file, ...(resolved ? { exportName: resolved.exportName } : {}), label: selection.selected ? `${selection.name} (${selection.selected.selector} = ${selection.selected.value})` : selection.name, discovery: literal ? 'literal' : 'unresolved', reason: literal ? 'Literal screen under an existing host, not a separate URL. Preserve native behavior until content bindings and publication ownership are reviewed.' : selection.fallback }, `${selection.name}:${selection.selected?.selector ?? '?'}:${selection.selected?.value ?? '?'}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async function jsxRoutes(file, ast, parent = '/') {
|
|
138
|
+
const imported = imports(ast), discovered = [];
|
|
139
|
+
function scan(node, host) {
|
|
140
|
+
const tag = jsxName(node), binding = tag && ts.isIdentifier(tag) ? imported.get(tag.text) : null;
|
|
141
|
+
if (binding?.exportName === 'Route' && ['react-router', 'react-router-dom'].includes(binding.specifier)) {
|
|
142
|
+
const attributes = jsxAttributes(node), pathAttribute = attributes.find(item => ts.isJsxAttribute(item) && item.name.text === 'path');
|
|
143
|
+
const index = attributes.some(item => ts.isJsxAttribute(item) && item.name.text === 'index');
|
|
144
|
+
const literal = pathAttribute && ts.isJsxAttribute(pathAttribute) ? literalString(pathAttribute.initializer) : null;
|
|
145
|
+
const joined = literal?.startsWith('/') ? literal : literal !== null && host ? `${host === '/' ? '' : host}/${literal}` : index ? host : null;
|
|
146
|
+
const pathname = routePath(joined), element = attributes.find(item => ts.isJsxAttribute(item) && item.name.text === 'element');
|
|
147
|
+
const refs = element && ts.isJsxAttribute(element) && element.initializer ? componentReferences(element.initializer, imported) : [];
|
|
148
|
+
if (refs.length) for (const name of refs) discovered.push({ name, pathname, label: literal ?? (index ? host : 'Unresolved route') });
|
|
149
|
+
else if (pathAttribute || index) discovered.push({ name: null, pathname: null, label: literal ?? 'Unresolved route' });
|
|
150
|
+
ts.forEachChild(node, child => scan(child, pathname)); return;
|
|
151
|
+
}
|
|
152
|
+
ts.forEachChild(node, child => scan(child, host));
|
|
153
|
+
}
|
|
154
|
+
scan(ast, parent);
|
|
155
|
+
for (const row of discovered) { const resolved = row.name && await resolveModule(file, imported.get(row.name)); add({ kind: 'route', path: row.pathname, hostPath: row.pathname, sourceFile: resolved?.sourceFile ?? file, ...(resolved ? { exportName: resolved.exportName } : {}), label: row.pathname ?? display(row.label), discovery: row.pathname && resolved ? 'literal' : 'unresolved', ...(!row.pathname || !resolved ? { reason: 'Dynamic, guarded or unresolved route component; preserve native ownership.' } : {}) }, `jsx:${row.label}:${row.name}`); }
|
|
156
|
+
return discovered.length;
|
|
157
|
+
}
|
|
158
|
+
async function objectRoutes(file, source, parent = '/') {
|
|
159
|
+
const ast = parse(source, file); if (!ast) return 0;
|
|
160
|
+
const imported = imports(ast), rows = [];
|
|
161
|
+
function scan(node, host) {
|
|
162
|
+
if (ts.isObjectLiteralExpression(node)) {
|
|
163
|
+
const properties = new Map(node.properties.filter(ts.isPropertyAssignment).map(item => [propertyName(item.name), item.initializer]));
|
|
164
|
+
if (properties.has('path') && ['component', 'components', 'loadComponent', 'loadChildren', 'children', 'redirectTo'].some(key => properties.has(key))) {
|
|
165
|
+
const literal = literalString(properties.get('path')), joined = literal?.startsWith('/') ? literal : literal !== null && host ? `${host === '/' ? '' : host}/${literal}` : null;
|
|
166
|
+
const pathname = routePath(joined === '' ? '/' : joined), component = unwrap(properties.get('component'));
|
|
167
|
+
if (component || properties.has('loadComponent') || properties.has('loadChildren') || properties.has('components')) rows.push({ pathname, label: literal ?? 'Unresolved route', name: component && ts.isIdentifier(component) ? component.text : null });
|
|
168
|
+
if (properties.has('children')) scan(properties.get('children'), pathname);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
ts.forEachChild(node, child => scan(child, host));
|
|
173
|
+
}
|
|
174
|
+
scan(ast, parent);
|
|
175
|
+
for (const row of rows) { const resolved = row.name && await resolveModule(file, imported.get(row.name)); add({ kind: 'route', path: row.pathname, hostPath: row.pathname, sourceFile: resolved?.sourceFile ?? file, ...(resolved ? { exportName: resolved.exportName } : {}), label: row.pathname ?? display(row.label), discovery: row.pathname && resolved ? 'literal' : 'unresolved', ...(!row.pathname || !resolved ? { reason: 'Dynamic route loading or component ownership requires review; preserve native behavior.' } : {}) }, `object:${row.label}:${row.name}`); }
|
|
176
|
+
return rows.length;
|
|
177
|
+
}
|
|
178
|
+
async function fileRoutes(base, type) {
|
|
179
|
+
await walk(base, async file => {
|
|
180
|
+
if (!sourceExtension.test(file)) return;
|
|
181
|
+
const relative = file.slice(base.length + 1), parts = relative.split('/'), filename = parts.pop();
|
|
182
|
+
const isPage = type === 'next' ? /^page\.[jt]sx?$/.test(filename) : type === 'svelte' ? /^\+page\.(?:svelte|md|mdx)$/.test(filename) : type === 'nuxt' ? /\.vue$/.test(filename) : /\.(?:astro|md|mdx)$/.test(filename);
|
|
183
|
+
if (!isPage) { if (sourceExtension.test(file)) await read(file); return; }
|
|
184
|
+
if (await read(file) === null) return;
|
|
185
|
+
let segments = [...parts], unresolved = false;
|
|
186
|
+
if (['next', 'svelte'].includes(type)) segments = segments.filter(part => { if (/^\([A-Za-z0-9_-]+\)$/.test(part)) return false; if (part.startsWith('@') || part.startsWith('_')) unresolved = true; return true; });
|
|
187
|
+
else { const stem = filename.replace(/\.[^.]+$/, ''); if (stem !== 'index') segments.push(stem); }
|
|
188
|
+
const pathname = unresolved ? null : routePath('/' + segments.join('/'));
|
|
189
|
+
add({ kind: 'route', path: pathname, hostPath: pathname, sourceFile: file, exportName: 'default', label: '/' + segments.join('/'), discovery: pathname ? 'literal' : 'unresolved', ...(!pathname ? { reason: 'Dynamic, parallel or ambiguous file route; preserve native ownership until reviewed.' } : {}) }, `file:${relative}`);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let manifest = {};
|
|
194
|
+
const packageText = await read('package.json');
|
|
195
|
+
if (packageText !== null) { try { manifest = JSON.parse(packageText); if (!plain(manifest) || ['dependencies', 'devDependencies'].some(key => manifest[key] !== undefined && !plain(manifest[key]))) throw Error(); } catch { manifest = {}; block('manifest-invalid', 'package.json', 'Package metadata is invalid; framework and ownership require review.'); } }
|
|
196
|
+
const deps = { ...manifest.devDependencies, ...manifest.dependencies };
|
|
197
|
+
const selected = [deps.next && 'next', deps.nuxt && 'nuxt', deps['@sveltejs/kit'] && 'svelte', deps['@angular/core'] && 'angular', deps.astro && 'astro', deps.vue && !deps.nuxt && 'vue', deps.react && deps.vite && !deps.next && !deps.astro && 'vite'].filter(Boolean);
|
|
198
|
+
if (selected.length === 1) framework = selected[0];
|
|
199
|
+
else if (selected.length > 1) block('framework-conflict', 'package.json', 'Multiple framework owners were detected. Preserve the project and review its routing.');
|
|
200
|
+
else if (!Object.keys(deps).some(name => name !== '@universalcms/connect') && await read('index.html') !== null) framework = 'static';
|
|
201
|
+
else block('framework-unresolved', 'package.json', 'This project has no unambiguous supported page discovery profile.');
|
|
202
|
+
if (manifest.workspaces) block('workspace-review', 'package.json', 'Workspace boundaries require review; this inventory covers only the selected folder.');
|
|
203
|
+
for (const config of ['tsconfig.json', 'jsconfig.json', ...(framework === 'next' ? ['next.config.js', 'next.config.mjs', 'next.config.cjs', 'next.config.ts', 'middleware.ts', 'middleware.js', 'src/middleware.ts', 'src/middleware.js'] : framework === 'nuxt' ? ['nuxt.config.ts', 'nuxt.config.js'] : framework === 'angular' ? ['angular.json'] : framework === 'svelte' ? ['svelte.config.js', 'svelte.config.ts', 'vite.config.js', 'vite.config.ts'] : framework === 'astro' ? ['astro.config.mjs', 'astro.config.ts'] : ['vite.config.js', 'vite.config.mjs', 'vite.config.ts'])]) await read(config);
|
|
204
|
+
if (framework === 'next') {
|
|
205
|
+
for (const base of ['app', 'src/app']) await fileRoutes(base, 'next');
|
|
206
|
+
for (const base of ['pages', 'src/pages']) if (await info(base, true)) block('pages-router-review', base, 'Next Pages Router needs a separate discovery profile; preserve these routes.');
|
|
207
|
+
} else if (framework === 'nuxt') { for (const base of ['app/pages', 'pages']) await fileRoutes(base, 'nuxt'); }
|
|
208
|
+
else if (framework === 'svelte') await fileRoutes('src/routes', 'svelte');
|
|
209
|
+
else if (framework === 'astro') await fileRoutes('src/pages', 'astro');
|
|
210
|
+
else if (framework === 'vite') {
|
|
211
|
+
const index = await read('index.html'), sourceMatches = [...(index ?? '').matchAll(/<script\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi)].map(match => match[1].replace(/^\//, '')).filter(name => /^src\//.test(name) && sourceExtension.test(name));
|
|
212
|
+
const mounted = [];
|
|
213
|
+
for (const mainFile of sourceMatches) { const ast = parse(await read(mainFile), mainFile); if (!ast) continue; const imported = imports(ast); const scan = node => { if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'render') for (const argument of node.arguments) for (const name of componentReferences(argument, imported)) mounted.push({ mainFile, imported: imported.get(name) }); ts.forEachChild(node, scan); }; scan(ast); }
|
|
214
|
+
const resolved = [];
|
|
215
|
+
for (const mount of mounted) { const value = await resolveModule(mount.mainFile, mount.imported); if (value && !resolved.some(row => row.sourceFile === value.sourceFile)) resolved.push(value); }
|
|
216
|
+
if (resolved.length !== 1) { block('mount-review', 'index.html', 'The mounted React page is missing or ambiguous; preserve native mounting.'); for (const file of ['src/App.jsx', 'src/App.tsx', 'src/App.js', 'src/App.ts']) { const text = await read(file); if (text !== null) resolved.push({ sourceFile: file, text, exportName: 'default' }); } }
|
|
217
|
+
for (const app of resolved) { const ast = parse(app.text, app.sourceFile); if (!ast) continue; const routes = await jsxRoutes(app.sourceFile, ast); if (!routes) add({ kind: 'route', path: '/', hostPath: '/', sourceFile: app.sourceFile, exportName: app.exportName, label: '/', discovery: mounted.length && resolved.length === 1 ? 'literal' : 'unresolved' }); await reactScreens(app.sourceFile, ast, routes ? null : '/'); }
|
|
218
|
+
} else if (framework === 'vue') {
|
|
219
|
+
let routes = 0;
|
|
220
|
+
for (const base of ['src/router', 'src/routes']) await walk(base, async file => { if (/\.[cm]?[jt]s$/.test(file)) routes += await objectRoutes(file, await read(file)); });
|
|
221
|
+
for (const file of ['src/router.ts', 'src/router.js']) { const text = await read(file); if (text !== null) routes += await objectRoutes(file, text); }
|
|
222
|
+
const app = await read('src/App.vue'); if (app !== null && !routes) add({ kind: 'route', path: '/', hostPath: '/', sourceFile: 'src/App.vue', exportName: 'default', label: '/', discovery: 'literal' });
|
|
223
|
+
for (const file of ['src/main.ts', 'src/main.js']) await read(file);
|
|
224
|
+
} else if (framework === 'angular') {
|
|
225
|
+
let routes = 0;
|
|
226
|
+
await walk('src/app', async file => { if (/routes\.[jt]s$/.test(file)) routes += await objectRoutes(file, await read(file)); });
|
|
227
|
+
for (const file of ['src/app/app.ts', 'src/app/app.component.ts']) { const text = await read(file); if (text !== null && !routes) add({ kind: 'route', path: '/', hostPath: '/', sourceFile: file, label: '/', discovery: 'literal' }); }
|
|
228
|
+
for (const file of ['src/main.ts', 'src/main.server.ts', 'src/app/app.config.ts', 'src/app/app.config.server.ts']) await read(file);
|
|
229
|
+
} else if (framework === 'static') {
|
|
230
|
+
await walk('', async file => { if (!/\.html?$/.test(file) || await read(file) === null) return; const pathname = routePath('/' + file.replace(/(?:^|\/)index\.html?$/, '').replace(/\/$/, '')); add({ kind: 'route', path: pathname, hostPath: pathname, sourceFile: file, label: '/' + file, discovery: pathname ? 'literal' : 'unresolved', ...(!pathname ? { reason: 'Hosting URL for this HTML file requires review; preserve the original file.' } : {}) }, `html:${file}`); });
|
|
231
|
+
}
|
|
232
|
+
const result = [...candidates.values()].sort((a, b) => a.kind.localeCompare(b.kind) || (a.path ?? '').localeCompare(b.path ?? '') || a.sourceFile.localeCompare(b.sourceFile) || a.id.localeCompare(b.id));
|
|
233
|
+
const seenPaths = new Set(); for (const candidate of result.filter(row => row.kind === 'route' && row.path !== null)) { if (seenPaths.has(candidate.path)) block('route-overlap', candidate.sourceFile, 'More than one candidate owns the same literal path. Review before selecting CMS ownership.'); seenPaths.add(candidate.path); }
|
|
234
|
+
if (!result.length) block('no-page-candidates', null, 'No supported page candidates were found. Existing application behavior remains native.');
|
|
235
|
+
return { version: 1, framework, profile: `page-discovery-${framework}-v1`, candidates: result, observations: [...observations].sort(([a], [b]) => a.localeCompare(b)).map(([path, sha256]) => ({ path, sha256 })), blockers: [...blockers.values()].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) };
|
|
236
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/** Local owner intent only. A review never installs bindings or changes publication ownership. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { lstat, realpath } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { projectRoot, readProjectFile, applyPlan, SetupError } from './core.mjs';
|
|
6
|
+
import { inspectPageCandidates } from './page-discovery.mjs';
|
|
7
|
+
|
|
8
|
+
export const PAGE_MAPPING_REVIEW_FILE = '.universalcms/pages.review.json';
|
|
9
|
+
const profile = 'page-mapping-review-v1';
|
|
10
|
+
const modes = ['cms-page', 'connected-screen', 'native'];
|
|
11
|
+
const installedFiles = ['.universalcms/content.bindings.json', '.universalcms/publication.config.json', '.universalcms/website.integration.json', '.universalcms/reviewed.integration.json'];
|
|
12
|
+
const plans = new WeakMap();
|
|
13
|
+
const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
14
|
+
const fail = message => { throw new SetupError(message); };
|
|
15
|
+
const digest = value => createHash('sha256').update(value).digest('hex');
|
|
16
|
+
const json = value => JSON.stringify(value, null, 2) + '\n';
|
|
17
|
+
const canonical = value => JSON.stringify(value, (_, item) => plain(item) ? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item);
|
|
18
|
+
const freeze = value => { if (value && typeof value === 'object') { Object.values(value).forEach(freeze); Object.freeze(value); } return value; };
|
|
19
|
+
|
|
20
|
+
function optionsFor(input, allowSelections = true) {
|
|
21
|
+
if (!plain(input) || Object.keys(input).some(key => ![...(allowSelections ? ['selections'] : []), 'locale', 'websiteOrigin'].includes(key))) fail('Page mapping review accepts exact selections, a locale and an optional public website origin. Do not provide credentials.');
|
|
22
|
+
let locale;
|
|
23
|
+
try { if (typeof (input.locale ?? 'en') !== 'string' || (input.locale ?? 'en').length > 35) throw Error(); locale = Intl.getCanonicalLocales(input.locale ?? 'en')[0]; if (!locale) throw Error(); }
|
|
24
|
+
catch { fail('Use a valid page mapping locale.'); }
|
|
25
|
+
let websiteOrigin;
|
|
26
|
+
if (input.websiteOrigin !== undefined) {
|
|
27
|
+
try {
|
|
28
|
+
if (typeof input.websiteOrigin !== 'string' || input.websiteOrigin.length > 2048 || /[\s\\]|cms_server\.|sk-[A-Za-z0-9_-]{12,}|AIza[A-Za-z0-9_-]{20,}/i.test(input.websiteOrigin)) throw Error();
|
|
29
|
+
const url = new URL(input.websiteOrigin);
|
|
30
|
+
if (url.username || url.password || url.search || url.hash || url.pathname !== '/' || url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) throw Error();
|
|
31
|
+
websiteOrigin = url.origin;
|
|
32
|
+
} catch { fail('Use an exact HTTPS website origin without credentials, paths or queries (HTTP loopback is allowed for development).'); }
|
|
33
|
+
}
|
|
34
|
+
return { locale, ...(websiteOrigin ? { websiteOrigin } : {}) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Check presence without opening installed connector files, which are not part
|
|
38
|
+
// of the public source inventory. Mapping review never reassigns an installation.
|
|
39
|
+
async function beforeInstallation(root) {
|
|
40
|
+
let directory;
|
|
41
|
+
try { directory = await lstat(path.join(root, '.universalcms')); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
42
|
+
if (directory && (!directory.isDirectory() || directory.isSymbolicLink() || await realpath(path.join(root, '.universalcms')) !== path.join(root, '.universalcms'))) fail('The local review directory must be an ordinary directory without links.');
|
|
43
|
+
for (const filename of installedFiles) {
|
|
44
|
+
try { await lstat(path.join(root, filename)); }
|
|
45
|
+
catch (error) { if (error.code === 'ENOENT') continue; throw error; }
|
|
46
|
+
fail('A website integration is already installed. Preserve its ownership and use a reviewed adapter update; page mapping review does not reassign installed content.');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function exactSelections(input, candidates) {
|
|
51
|
+
if (!Array.isArray(input) || input.length > 1000) fail('Provide a list of exact candidate id and mode choices.');
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
for (const row of input) {
|
|
54
|
+
if (!plain(row) || Object.keys(row).sort().join(',') !== 'id,mode' || typeof row.id !== 'string' || !modes.includes(row.mode) || seen.has(row.id) || !candidates.some(candidate => candidate.id === row.id)) fail('Select each discovered candidate at most once using only its exact id and cms-page, connected-screen or native mode.');
|
|
55
|
+
seen.add(row.id);
|
|
56
|
+
}
|
|
57
|
+
return candidates.filter(row => seen.has(row.id)).map(row => ({ id: row.id, mode: input.find(value => value.id === row.id).mode }));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function reviewedChoice(candidate, selected, candidates, blockers) {
|
|
61
|
+
const mode = selected?.mode ?? 'native';
|
|
62
|
+
const needsAdapter = mode !== 'native' && (blockers.length > 0 || mode === 'connected-screen' || candidate.kind !== 'route' || candidate.discovery !== 'literal' || candidate.path === null);
|
|
63
|
+
const sharedHost = mode === 'cms-page' && candidate.path !== null && candidates.some(row => row.kind === 'screen' && row.hostPath === candidate.path);
|
|
64
|
+
return {
|
|
65
|
+
id: candidate.id, mode, explicit: !!selected, kind: candidate.kind, path: candidate.path, hostPath: candidate.hostPath, sourceFile: candidate.sourceFile,
|
|
66
|
+
bindingStatus: mode === 'native' ? 'not-requested' : 'not-checked', automatic: false,
|
|
67
|
+
requiresAdapter: mode === 'native' ? false : needsAdapter ? true : null,
|
|
68
|
+
publicationEffect: mode === 'native' ? 'unchanged' : mode === 'connected-screen' ? 'proposed-hide-cms-content-keep-native-app' : needsAdapter ? 'requires-decision' : 'proposed-url-gate',
|
|
69
|
+
reason: mode === 'native' ? 'Keep current website behavior. No content is imported or publication ownership changed.'
|
|
70
|
+
: mode === 'connected-screen' ? 'Requested behavior: hide this screen\'s CMS content when unpublished, keep native forms and app controls working, and leave other screens at the same URL unaffected. This is intent only; an adapter must implement and verify that boundary.'
|
|
71
|
+
: blockers.length ? 'Discovery has blockers. Preserve current behavior until those blockers, the content bindings and publication ownership are reviewed.'
|
|
72
|
+
: needsAdapter ? 'This discovery is not a resolved page URL. A reviewed adapter must establish a real URL and publication ownership before integration.'
|
|
73
|
+
: sharedHost ? 'A CMS-owned page would gate this whole URL, including native screens that share it. Review that effect and the content bindings before integration.'
|
|
74
|
+
: 'A CMS-owned page would require a published entry at this URL. Existing setup planners must still validate its content bindings and publication ownership.',
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function validPreviousReview(text) {
|
|
79
|
+
let value;
|
|
80
|
+
try { value = JSON.parse(text); } catch { fail('The existing page mapping review is not valid JSON. Preserve it and review it manually.'); }
|
|
81
|
+
const keys = ['version', 'profile', 'status', 'framework', 'discoveryProfile', 'locale', 'websiteOrigin', 'inventoryHash', 'observations', 'selections'];
|
|
82
|
+
const selectionKeys = ['id', 'mode', 'explicit', 'kind', 'path', 'hostPath', 'sourceFile', 'bindingStatus', 'automatic', 'requiresAdapter', 'publicationEffect', 'reason'];
|
|
83
|
+
if (!plain(value) || Object.keys(value).sort().join(',') !== keys.sort().join(',') || value.version !== 1 || value.profile !== profile || value.status !== 'intent-only' || typeof value.framework !== 'string' || typeof value.discoveryProfile !== 'string' || !/^[a-f0-9]{64}$/.test(value.inventoryHash) || !Array.isArray(value.observations) || !Array.isArray(value.selections) || value.observations.length > 1000 || value.selections.length > 1000) fail('The existing page mapping review has an unsupported format. Preserve it and review it manually.');
|
|
84
|
+
optionsFor({ locale: value.locale, ...(value.websiteOrigin === null ? {} : { websiteOrigin: value.websiteOrigin }) }, false);
|
|
85
|
+
if (value.observations.some(row => !plain(row) || Object.keys(row).sort().join(',') !== 'path,sha256' || typeof row.path !== 'string' || !/^[a-f0-9]{64}$/.test(row.sha256)) || new Set(value.observations.map(row => row.path)).size !== value.observations.length || value.selections.some(row => !plain(row) || Object.keys(row).sort().join(',') !== selectionKeys.sort().join(',') || typeof row.id !== 'string' || !modes.includes(row.mode) || typeof row.explicit !== 'boolean' || !['route', 'screen'].includes(row.kind) || ![row.path, row.hostPath].every(item => item === null || typeof item === 'string') || typeof row.sourceFile !== 'string' || !['not-requested', 'not-checked'].includes(row.bindingStatus) || row.automatic !== false || ![null, false, true].includes(row.requiresAdapter) || !['unchanged', 'requires-decision', 'proposed-url-gate', 'proposed-hide-cms-content-keep-native-app'].includes(row.publicationEffect) || typeof row.reason !== 'string') || new Set(value.selections.map(row => row.id)).size !== value.selections.length) fail('The existing page mapping review has unsupported choices. Preserve it and review it manually.');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Plan one local intent record. Use applyPageMappingReview to revalidate before saving. */
|
|
89
|
+
export async function planPageMappingReview(directory, options = {}) {
|
|
90
|
+
const publicOptions = optionsFor(options), root = await projectRoot(directory);
|
|
91
|
+
await beforeInstallation(root);
|
|
92
|
+
const inventory = await inspectPageCandidates(root, publicOptions);
|
|
93
|
+
const selections = exactSelections(options.selections ?? [], inventory.candidates);
|
|
94
|
+
const previous = await readProjectFile(root, PAGE_MAPPING_REVIEW_FILE);
|
|
95
|
+
if (previous !== null) {
|
|
96
|
+
if ((await lstat(path.join(root, PAGE_MAPPING_REVIEW_FILE))).nlink !== 1) fail('The existing page mapping review is linked. Preserve it and review manually.');
|
|
97
|
+
validPreviousReview(previous);
|
|
98
|
+
}
|
|
99
|
+
const review = { version: 1, profile, status: 'intent-only', framework: inventory.framework, discoveryProfile: inventory.profile,
|
|
100
|
+
locale: publicOptions.locale, websiteOrigin: publicOptions.websiteOrigin ?? null, inventoryHash: digest(canonical(inventory)),
|
|
101
|
+
observations: inventory.observations.map(row => ({ ...row })), selections: inventory.candidates.map(candidate => reviewedChoice(candidate, selections.find(row => row.id === candidate.id), inventory.candidates, inventory.blockers)),
|
|
102
|
+
};
|
|
103
|
+
const content = json(review);
|
|
104
|
+
if (Buffer.byteLength(content) > 128 * 1024) fail('The page mapping review exceeds its bounded file size. Review a smaller website project.');
|
|
105
|
+
const file = { path: PAGE_MAPPING_REVIEW_FILE, content, state: previous === null ? 'create' : previous === content ? 'unchanged' : 'update', ...(previous !== null && previous !== content ? { previous } : {}) };
|
|
106
|
+
const plan = freeze({ root, framework: inventory.framework, status: 'intent-only', installed: false, liveVerified: false, renderedVerified: false, review, candidates: inventory.candidates, blockers: inventory.blockers, files: [file] });
|
|
107
|
+
plans.set(plan, { publicOptions, selections, inventory: canonical(inventory), previous, root, file });
|
|
108
|
+
return plan;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Only an issued, unchanged plan can save; never writes source or installed ownership. */
|
|
112
|
+
export async function applyPageMappingReview(plan) {
|
|
113
|
+
const state = plans.get(plan);
|
|
114
|
+
if (!state) fail('Use the original page mapping review plan. Edited or reconstructed plans cannot be saved.');
|
|
115
|
+
if (await projectRoot(state.root) !== state.root) fail('The project directory changed during review. No review was saved.');
|
|
116
|
+
await beforeInstallation(state.root);
|
|
117
|
+
const current = await inspectPageCandidates(state.root, state.publicOptions);
|
|
118
|
+
if (canonical(current) !== state.inventory) fail('The page inventory or public sources changed during review. No review was saved; inspect the project again.');
|
|
119
|
+
if (await readProjectFile(state.root, PAGE_MAPPING_REVIEW_FILE) !== state.previous) fail('The local page review changed during review. Existing edits were preserved.');
|
|
120
|
+
if (state.previous !== null && (await lstat(path.join(state.root, PAGE_MAPPING_REVIEW_FILE))).nlink !== 1) fail('The local page review became linked. Existing files were preserved.');
|
|
121
|
+
return applyPlan({ root: state.root, files: [...installedFiles.map(path => ({ path, content: null, state: 'absent' })), state.file] });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function numberedChoices(answer, candidates, mode) {
|
|
125
|
+
if (typeof answer !== 'string') fail('Enter displayed item numbers or cancel the review.');
|
|
126
|
+
const value = answer.trim();
|
|
127
|
+
if (!value) return [];
|
|
128
|
+
if (!/^\d+(?:\s*,\s*\d+)*$/.test(value)) fail('Use only the displayed item numbers separated by commas.');
|
|
129
|
+
const indexes = value.split(',').map(item => Number(item.trim()) - 1);
|
|
130
|
+
if (new Set(indexes).size !== indexes.length || indexes.some(index => !Number.isSafeInteger(index) || index < 0 || index >= candidates.length)) fail('Choose each displayed item at most once.');
|
|
131
|
+
return indexes.map(index => ({ id: candidates[index].id, mode }));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Cancellation is the default. No keys, execution, network or integration changes. */
|
|
135
|
+
export async function runPageMappingReview(directory, io, options = {}) {
|
|
136
|
+
const publicOptions = optionsFor(options);
|
|
137
|
+
const initial = await planPageMappingReview(directory, { ...publicOptions, selections: options.selections ?? [] });
|
|
138
|
+
io.write('Review page and screen ownership. This saves local intent only; it does not install routes or bindings, import content, publish, or verify a connection. All unselected items keep native behavior.\n');
|
|
139
|
+
initial.candidates.forEach((row, index) => io.write(` ${index + 1}. ${row.label} — ${row.kind === 'route' ? `URL ${row.path ?? '(unresolved)'}` : `screen within ${row.hostPath ?? '(unresolved native URL)'}, not a separate URL`} — ${row.sourceFile}\n`));
|
|
140
|
+
if (initial.blockers.length) io.write(`Discovery has ${initial.blockers.length} blocker(s). Unresolved and undiscovered behavior stays native; adapter review is still required.\n`);
|
|
141
|
+
if (!initial.candidates.length) { io.write('No page or screen candidates were safely discovered. No review was saved.\n'); return { saved: false }; }
|
|
142
|
+
io.write('CMS page: proposed ownership of a whole URL; unpublished content would gate that URL and any screens sharing it. Connected screen: request hiding its CMS content when unpublished while native forms, app controls and other screens keep working. This boundary still requires an adapter. Neither choice establishes an installed capability.\n');
|
|
143
|
+
let selections = options.selections;
|
|
144
|
+
if (selections === undefined) {
|
|
145
|
+
const pageAnswer = await io.ask('CMS page item numbers, comma-separated (blank selects none; cancel stops): ');
|
|
146
|
+
if (typeof pageAnswer === 'string' && pageAnswer.trim().toLowerCase() === 'cancel') return { saved: false };
|
|
147
|
+
const screenAnswer = await io.ask('Connected screen item numbers, comma-separated (blank selects none; cancel stops): ');
|
|
148
|
+
if (typeof screenAnswer === 'string' && screenAnswer.trim().toLowerCase() === 'cancel') return { saved: false };
|
|
149
|
+
selections = [...numberedChoices(pageAnswer, initial.candidates, 'cms-page'), ...numberedChoices(screenAnswer, initial.candidates, 'connected-screen')];
|
|
150
|
+
}
|
|
151
|
+
const plan = await planPageMappingReview(directory, { ...publicOptions, selections });
|
|
152
|
+
if (plan.review.inventoryHash !== initial.review.inventoryHash) fail('The page inventory or public sources changed during review. No review was saved.');
|
|
153
|
+
for (const row of plan.review.selections) io.write(` ${row.mode}: ${row.kind === 'screen' ? `screen in ${row.hostPath ?? 'an unresolved URL'}` : row.path ?? 'unresolved URL'} — ${row.sourceFile}\n ${row.reason}\n`);
|
|
154
|
+
io.write(`Only ${PAGE_MAPPING_REVIEW_FILE} will be saved. Existing application files remain unchanged. The review is not consumed automatically by setup; supported planners and any required adapter must still validate the choices.\n`);
|
|
155
|
+
const confirmation = await io.ask('Save this local intent review? [y/N] ');
|
|
156
|
+
if (typeof confirmation !== 'string' || !['y', 'yes'].includes(confirmation.trim().toLowerCase())) { io.write('Page mapping review cancelled. No files changed.\n'); return { saved: false }; }
|
|
157
|
+
await applyPageMappingReview(plan);
|
|
158
|
+
io.write('Local page mapping intent saved. Nothing was integrated or published. Review unresolved bindings and publication behavior before running supported setup and live website checks.\n');
|
|
159
|
+
return { saved: true, status: 'intent-only', review: plan.review };
|
|
160
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/** Public setup diagnostics. Never echo arbitrary exceptions or project source. */
|
|
2
|
+
import { SetupError } from './core.mjs';
|
|
3
|
+
import { validateNativeRoutes } from './native-routes.mjs';
|
|
4
|
+
|
|
5
|
+
const diagnostics = new WeakMap();
|
|
6
|
+
const catalog = {
|
|
7
|
+
'native-chrome-layout': {stage:'content',message:'This native page owns its header and footer. Run review-layout to inspect and add supported literal settings slots before setup; custom components need a reviewed framework adapter.'},
|
|
8
|
+
'next-route-ownership': {
|
|
9
|
+
stage: 'content',
|
|
10
|
+
message: 'Unreviewed dynamic, parallel or custom website routes need explicit content ownership before full setup. Existing routes were preserved; automatic publication control cannot silently hide them.',
|
|
11
|
+
},
|
|
12
|
+
'native-settings-layout': {
|
|
13
|
+
stage: 'settings',
|
|
14
|
+
message: 'This website already owns its header or footer. Preserve its native links and add WebsiteProvider with CmsNavigation slots to the existing desktop header, mobile menu and footer using a reviewed adapter. Automatic setup is incomplete until those slots are verified.',
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
// Fixed messages from existing planners may be surfaced verbatim without
|
|
18
|
+
// copying arbitrary error text. Interpolated paths must use setupReviewError.
|
|
19
|
+
const fixedReasons = new Map([
|
|
20
|
+
...[
|
|
21
|
+
'Existing content bindings need review; they are never wrapped a second time.',
|
|
22
|
+
'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.',
|
|
23
|
+
'This page needs a reviewed content adapter. Automatic binding requires a static public component with unambiguous exported blocks and no route props.',
|
|
24
|
+
'Custom route exports need a reviewed content adapter. Existing code was preserved.',
|
|
25
|
+
'A repeated source block needs an explicit immutable row identity. Index-based content binding is unsafe.',
|
|
26
|
+
'Repeated content has ambiguous row identities. Provide reviewed bindings before enabling rendering.',
|
|
27
|
+
'Overlapping source content requires a reviewed renderer.',
|
|
28
|
+
'Public React source needs explicit review for repeated, nested or data-driven content. Literal native bindings are required before private setup.',
|
|
29
|
+
'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.',
|
|
30
|
+
'Public React source retains editorial copy outside its literal bindings. Review the source dependencies before private setup.',
|
|
31
|
+
].map(message => [message, {code: 'content-bindings-review', stage: 'content'}]),
|
|
32
|
+
...[
|
|
33
|
+
'An existing settings, sitemap or robots route needs manual composition. It was preserved.',
|
|
34
|
+
'A custom Vite router needs reviewed settings and new-page slots. Original pages were preserved.',
|
|
35
|
+
'This reviewed native application needs explicit shared settings slots. Existing routing, commerce and custom adapters were preserved.',
|
|
36
|
+
].map(message => [message, {code: 'settings-composition-review', stage: 'settings'}]),
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
const secretLike = /cms_(?:server|public)[._]|sk-[A-Za-z0-9_-]{12,}|AIza[A-Za-z0-9_-]{20,}|(?:^|\/)(?:\.env[^/]*|\.git|\.ssh|\.aws|credentials|secrets?)(?:\/|$)/i;
|
|
40
|
+
export function sanitizeSetupPaths(paths) {
|
|
41
|
+
const input = Array.isArray(paths) ? paths : [];
|
|
42
|
+
const accepted = new Set();
|
|
43
|
+
for (const value of input.slice(0, 4000)) {
|
|
44
|
+
if (typeof value !== 'string' || value.length > 500 || !/^(?:app\/|src\/|pages\/|components\/|\.universalcms\/content\/)[A-Za-z0-9_+@()[\]./-]+$|^(?:[a-z0-9-]+\/)*index\.html$/.test(value) || secretLike.test(value)) continue;
|
|
45
|
+
if (value.split('/').some(part => !part || part === '.' || part === '..' || part.startsWith('.') && part !== '.universalcms')) continue;
|
|
46
|
+
accepted.add(value);
|
|
47
|
+
}
|
|
48
|
+
const result = [...accepted].sort().slice(0, 20);
|
|
49
|
+
return { paths: result, omittedPathCount: input.length - result.length };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Only errors created here carry public diagnostic metadata. */
|
|
53
|
+
export function setupReviewError(code, paths = []) {
|
|
54
|
+
if (!Object.hasOwn(catalog, code)) throw new SetupError('The setup review diagnostic is invalid. Preserve the project and inspect the connector installation.');
|
|
55
|
+
const definition = catalog[code], safe = sanitizeSetupPaths(paths);
|
|
56
|
+
const locations = safe.paths.length ? ` Review paths: ${safe.paths.join(', ')}.` : '';
|
|
57
|
+
const omitted = safe.omittedPathCount ? ' Additional paths require local review and were omitted from this diagnostic.' : '';
|
|
58
|
+
const error = new SetupError(definition.message + locations + omitted);
|
|
59
|
+
diagnostics.set(error, { code, stage: definition.stage, message: error.message, ...safe });
|
|
60
|
+
return error;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function setupReviewDiagnostic(error) {
|
|
64
|
+
const value = diagnostics.get(error);
|
|
65
|
+
if (value) return { ...value, paths: [...value.paths] };
|
|
66
|
+
const fixed = error instanceof SetupError && fixedReasons.get(error.message);
|
|
67
|
+
return fixed ? {...fixed, message: error.message, paths: [], omittedPathCount: 0} : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const cli = (name, args = []) => ({ executable: 'npx', args: ['--no-install', 'universalcms-connect', name, ...args] });
|
|
71
|
+
const scopeFields = { apiOrigin: '--api-origin', siteId: '--site', environmentId: '--environment', locale: '--locale', websiteOrigin: '--website-origin', adminOrigin: '--admin-origin' };
|
|
72
|
+
const simpleNativeDirectory = /^(?:src\/)?app\/(?:[a-z0-9]+(?:-[a-z0-9]+)*\/)+\[[A-Za-z][A-Za-z0-9_]*\]$/;
|
|
73
|
+
function canReviewRoutes(paths) {
|
|
74
|
+
if (!paths.every(name => simpleNativeDirectory.test(name))) return false;
|
|
75
|
+
try {
|
|
76
|
+
validateNativeRoutes({nativePaths: [], nativePatterns: paths.map(name => name.replace(/^(?:src\/)?app/, ''))});
|
|
77
|
+
return true;
|
|
78
|
+
} catch { return false; }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Connection has already passed publicConnection; never use raw CLI options. */
|
|
82
|
+
export function setupRecovery({ phase, framework, blocker, connection }) {
|
|
83
|
+
const retry = connection ? cli('preflight', Object.entries(scopeFields).flatMap(([name, flag]) => [flag, connection[name]])) : null;
|
|
84
|
+
const reviewablePaths = phase !== 'installed-integration' && blocker?.paths.length && !blocker.omittedPathCount;
|
|
85
|
+
const routeReview = framework === 'Next.js' && blocker?.code === 'next-route-ownership' && reviewablePaths && canReviewRoutes(blocker.paths);
|
|
86
|
+
const nativeLayoutReview = ['Vue','Nuxt','Angular','SvelteKit','Static'].includes(framework) && blocker?.code === 'native-chrome-layout' && reviewablePaths;
|
|
87
|
+
const layoutReview = (framework === 'Next.js' && blocker?.code === 'native-settings-layout' || nativeLayoutReview) && reviewablePaths;
|
|
88
|
+
const commandName = routeReview ? 'review-routes' : layoutReview ? 'review-layout' : null;
|
|
89
|
+
const kind = commandName ? routeReview ? 'native-route-review' : 'native-layout-review'
|
|
90
|
+
: phase === 'installed-integration' ? 'repair-installation'
|
|
91
|
+
: phase === 'runtime' ? 'repair-runtime'
|
|
92
|
+
: phase === 'framework' ? 'review-framework'
|
|
93
|
+
: phase === 'public-scope' ? 'correct-public-scope' : 'reviewed-adapter';
|
|
94
|
+
const first = routeReview
|
|
95
|
+
? 'Review which exact routes stay website-owned. The CLI inspects supported Next.js native routes and their local dependencies, then asks before saving an ownership manifest. It does not bind application data to CMS content.'
|
|
96
|
+
: layoutReview
|
|
97
|
+
? nativeLayoutReview ? 'Review the existing literal native header and footer. The CLI proposes explicit empty settings slots, preserves native children and classes, and saves the reviewed source hashes only after confirmation. Unsupported components still require a framework adapter.' : 'Review the existing Next.js layout and navigation slots. The CLI can save a reviewed layout plan only for its supported structure; custom components and interactions must remain intact. A refusal still needs a developer adapter.'
|
|
98
|
+
: phase === 'installed-integration'
|
|
99
|
+
? 'Inspect and repair the existing adapter in place. Restore its matching public connection and reviewed files; do not rerun scaffold setup, delete manifests or replace a key to bypass this failure.'
|
|
100
|
+
: phase === 'runtime'
|
|
101
|
+
? 'Install or repair the matching runtime package using this website’s Developer setup and existing package manager. Preserve the framework, lockfile and current integration.'
|
|
102
|
+
: phase === 'framework'
|
|
103
|
+
? 'Use the actual framework and runtime detected in this folder. Review its supported version/profile or implement the appropriate native server or private-build adapter; changing the CMS framework label does not repair code.'
|
|
104
|
+
: phase === 'public-scope'
|
|
105
|
+
? 'Open the matching website folder and compare the six public Developer setup values with its saved connection. Keep existing scope and private credentials; never overwrite a conflicting connection to make a command pass.'
|
|
106
|
+
: 'Give the blocker and affected public files to your developer or AI coding assistant. Implement and verify a reviewed native adapter that preserves route ownership, content, layout and interactions. Do not delete routes or weaken publication checks to bypass the refusal.';
|
|
107
|
+
const steps = [
|
|
108
|
+
{ stage: 'review', message: first },
|
|
109
|
+
{ stage: 'preflight', message: commandName
|
|
110
|
+
? 'After the reviewed change, rerun the exact scoped preflight command. Continue only when it reports planned; resolving one blocker can reveal another independent compatibility requirement.'
|
|
111
|
+
: 'For the supported generated profile, rerun scoped preflight after repair. For a custom adapter, use its reviewed validator, build and runtime checks; do not force generic setup over customized integration files.' },
|
|
112
|
+
{ stage: 'installation', message: 'For a new supported installation, run the scoped setup --original-routes-only command after preflight passes and review every proposed file and managed URL. A saved review manifest is not an installed source, publication, content or settings runtime.' },
|
|
113
|
+
{ stage: 'verification', message: 'Keep keys private. Restart or deploy the completed adapter, verify source/publication and all twelve settings consumers at the exact saved website origin, then verify native controls and new published navigation in a browser. Import and publication remain separate owner actions.' },
|
|
114
|
+
];
|
|
115
|
+
return {
|
|
116
|
+
version: 1, kind, requiresReview: true, automatic: false,
|
|
117
|
+
preserves: ['source', 'native-routes', 'private-credentials', 'connection-scope', 'published-content'],
|
|
118
|
+
steps,
|
|
119
|
+
...(commandName ? { command: cli(commandName), planCommand: cli(commandName, ['--dry-run']) } : {}),
|
|
120
|
+
...(retry ? { retry } : {}),
|
|
121
|
+
};
|
|
122
|
+
}
|