@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,315 @@
|
|
|
1
|
+
/** Conservative Vite route discovery. Parses source; never loads website code. */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import ts from './vendor/typescript.cjs';
|
|
4
|
+
import { SetupError } from './core.mjs';
|
|
5
|
+
import { extractStaticPage } from './source-extract.mjs';
|
|
6
|
+
import { transformSourcePublication } from './source-bindings.mjs';
|
|
7
|
+
|
|
8
|
+
const privateSegment = /^(?:admin|account|dashboard|profile|auth|login|register|checkout|orders|settings|billing|portal|patient|patients|vault|records)(?:[-.]|$)|(?:^|-)vault$|^(?:medical|health)-records(?:-|$)/i;
|
|
9
|
+
const accessLogic = /\b(?:cookies|headers|useSession|getServerSession|useAuth|useUser|useContext|auth|requireAuth|currentUser|redirect)\s*\(/;
|
|
10
|
+
const customRouter = /\b(?:window|document)\s*\.\s*(?:location|history)\b|\b(?:history|location)\s*\.\s*(?:pathname|pushState|replaceState)\b|\b(?:useRoutes|createBrowserRouter|createHashRouter|RouterProvider|HashRouter)\b/;
|
|
11
|
+
const fail = (reason, ownershipReview = false) => { const error = new SetupError(`Vite source export is missing. Automatic source mapping could not safely determine the public page components: ${reason}. Follow the manual developer mapping guide for this React/Vite website to create .universalcms/source.entries.json and .universalcms/content.routes.json, then rerun setup. No AI is required. No project code was executed and no source mapping was saved.`); if (ownershipReview) error.code = 'VITE_SOURCE_OWNERSHIP_REVIEW'; throw error; };
|
|
12
|
+
const unbox = node => { while (node && (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node) || ts.isNonNullExpression(node))) node = node.expression; return node; };
|
|
13
|
+
const open = node => node && ts.isJsxElement(node) ? node.openingElement : node && ts.isJsxSelfClosingElement(node) ? node : null;
|
|
14
|
+
const noAttributes = node => open(node)?.attributes.properties.length === 0;
|
|
15
|
+
const children = node => node && (ts.isJsxElement(node) || ts.isJsxFragment(node)) ? node.children.filter(child => !(ts.isJsxText(child) && !child.text.trim()) && !(ts.isJsxExpression(child) && !child.expression)) : [];
|
|
16
|
+
const plainModule = value => /^src\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+\.(?:jsx|tsx)$/.test(value) && !value.split('/').some(segment => privateSegment.test(segment));
|
|
17
|
+
|
|
18
|
+
function parse(source, filename) {
|
|
19
|
+
if (typeof source !== 'string' || Buffer.byteLength(source) > 128 * 1024) fail('a source module is missing or exceeds its inspection budget');
|
|
20
|
+
const file = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
21
|
+
if (file.parseDiagnostics.length) fail('a source module does not parse');
|
|
22
|
+
let count = 0;
|
|
23
|
+
const walk = (node, depth = 0) => { if (++count > 30_000 || depth > 100) fail('the source tree exceeds its inspection budget'); ts.forEachChild(node, child => walk(child, depth + 1)); };
|
|
24
|
+
walk(file); return file;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function entryModule(html) {
|
|
28
|
+
// This deliberately accepts ordinary Vite entry documents, not the whole HTML
|
|
29
|
+
// language. Quoted > characters and ordinary comments cannot invent entries.
|
|
30
|
+
const allowed = new Set(['html', 'head', 'title', 'meta', 'link', 'body', 'div', 'script']);
|
|
31
|
+
const scripts = [];
|
|
32
|
+
let cursor = 0;
|
|
33
|
+
while (cursor < html.length) {
|
|
34
|
+
const start = html.indexOf('<', cursor);
|
|
35
|
+
if (start < 0) break;
|
|
36
|
+
if (html.startsWith('<!--', start)) {
|
|
37
|
+
const end = html.indexOf('-->', start + 4);
|
|
38
|
+
if (end < 0 || html.slice(start + 4, end).includes('--')) fail('index.html has an ambiguous comment');
|
|
39
|
+
cursor = end + 3; continue;
|
|
40
|
+
}
|
|
41
|
+
if (/^<!doctype\s+html\s*>/i.test(html.slice(start))) {
|
|
42
|
+
cursor = start + /^<!doctype\s+html\s*>/i.exec(html.slice(start))[0].length; continue;
|
|
43
|
+
}
|
|
44
|
+
let end = start + 1, quote;
|
|
45
|
+
for (; end < html.length; end++) {
|
|
46
|
+
const character = html[end];
|
|
47
|
+
if (quote) { if (character === quote) quote = undefined; }
|
|
48
|
+
else if (character === '"' || character === "'") quote = character;
|
|
49
|
+
else if (character === '>') break;
|
|
50
|
+
else if (character === '<') fail('index.html has ambiguous markup');
|
|
51
|
+
}
|
|
52
|
+
if (end === html.length || quote) fail('index.html has incomplete markup');
|
|
53
|
+
const token = html.slice(start, end + 1), tag = /^<(\/?)([A-Za-z][A-Za-z0-9-]*)([\s\S]*?)>$/.exec(token);
|
|
54
|
+
if (!tag || !allowed.has(tag[2].toLowerCase())) fail('index.html uses a custom loader or unsupported inert/foreign markup');
|
|
55
|
+
const name = tag[2].toLowerCase();
|
|
56
|
+
if (tag[1]) { if (tag[3].trim()) fail('index.html has an invalid closing tag'); cursor = end + 1; continue; }
|
|
57
|
+
const attributes = new Map();
|
|
58
|
+
let remaining = tag[3].replace(/\/\s*$/, '');
|
|
59
|
+
while (remaining.trim()) {
|
|
60
|
+
const attribute = /^\s+([A-Za-z_:][A-Za-z0-9_.:-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s'"=<>`]+)))?/.exec(remaining);
|
|
61
|
+
if (!attribute) fail('index.html has unsupported attributes');
|
|
62
|
+
const key = attribute[1].toLowerCase(), value = attribute[2] ?? attribute[3] ?? attribute[4] ?? '';
|
|
63
|
+
// Font URLs commonly contain raw query separators. These passive values
|
|
64
|
+
// do not choose the executable entry; script attributes stay literal and
|
|
65
|
+
// entity-like encodings still require a full HTML-parser review.
|
|
66
|
+
const passiveValue = name === 'link' && ['href', 'title'].includes(key) || name === 'meta' && key === 'content';
|
|
67
|
+
if (attributes.has(key) || /^on/.test(key) || key === 'xmlns' || key.includes(':') || value.includes('\0') || value.includes('&') && (!passiveValue || /&#|&[A-Za-z][A-Za-z0-9]*;/.test(value))) fail('index.html has executable or ambiguous attributes');
|
|
68
|
+
attributes.set(key, value); remaining = remaining.slice(attribute[0].length);
|
|
69
|
+
}
|
|
70
|
+
cursor = end + 1;
|
|
71
|
+
if (attributes.has('http-equiv')) fail('index.html has a redirect or custom response policy');
|
|
72
|
+
if (name === 'title') {
|
|
73
|
+
const closing = /<\/title\s*>/gi; closing.lastIndex = cursor;
|
|
74
|
+
const close = closing.exec(html); if (!close) fail('index.html has an incomplete title');
|
|
75
|
+
cursor = close.index + close[0].length; continue;
|
|
76
|
+
}
|
|
77
|
+
if (name !== 'script') continue;
|
|
78
|
+
const close = /<\/script\s*>/gi; close.lastIndex = cursor;
|
|
79
|
+
const closing = close.exec(html);
|
|
80
|
+
if (!closing || html.slice(cursor, closing.index).trim()) fail('index.html has an inline executable loader');
|
|
81
|
+
if (attributes.get('type') !== 'module' || !/^\/src\/(?:main|index)\.(?:jsx|tsx)$/.test(attributes.get('src') ?? '') || [...attributes.keys()].some(key => !['type', 'src', 'crossorigin'].includes(key))) fail('index.html has an unsupported script loader');
|
|
82
|
+
scripts.push(attributes.get('src').slice(1)); cursor = closing.index + closing[0].length;
|
|
83
|
+
}
|
|
84
|
+
if (scripts.length !== 1) fail('index.html must have one active ordinary module entry');
|
|
85
|
+
return scripts[0];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function checkModuleStatements(file, mode, mountedImport) {
|
|
89
|
+
const known = new Set();
|
|
90
|
+
const inert = expression => {
|
|
91
|
+
const node = unbox(expression);
|
|
92
|
+
if (!node) return false;
|
|
93
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) || ts.isNumericLiteral(node) || [ts.SyntaxKind.TrueKeyword, ts.SyntaxKind.FalseKeyword, ts.SyntaxKind.NullKeyword].includes(node.kind) || ts.isArrowFunction(node) || ts.isFunctionExpression(node)) return true;
|
|
94
|
+
if (ts.isIdentifier(node)) return known.has(node.text);
|
|
95
|
+
if (ts.isPrefixUnaryExpression(node) && [ts.SyntaxKind.MinusToken, ts.SyntaxKind.PlusToken].includes(node.operator)) return ts.isNumericLiteral(node.operand);
|
|
96
|
+
if (ts.isArrayLiteralExpression(node)) return node.elements.every(inert);
|
|
97
|
+
if (ts.isObjectLiteralExpression(node)) return node.properties.every(item => ts.isPropertyAssignment(item) && (ts.isIdentifier(item.name) || ts.isStringLiteral(item.name) || ts.isNumericLiteral(item.name)) && inert(item.initializer));
|
|
98
|
+
return false;
|
|
99
|
+
};
|
|
100
|
+
for (const statement of file.statements) {
|
|
101
|
+
if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) {
|
|
102
|
+
const specifier = statement.moduleSpecifier.text, clause = statement.importClause;
|
|
103
|
+
if (clause?.isTypeOnly) continue;
|
|
104
|
+
if (!clause) {
|
|
105
|
+
if (/^\.\.?\/(?:[A-Za-z0-9_./-]+)\.css$/.test(specifier)) continue;
|
|
106
|
+
fail('a source module has an unknown side-effect import');
|
|
107
|
+
}
|
|
108
|
+
const builtin = ['react', 'react-dom/client', 'lucide-react', ...(mode === 'page' ? [] : ['react-router-dom', 'react-router'])].includes(specifier);
|
|
109
|
+
const localPage = (mode === 'router' || mode === 'main' && clause.name?.text === mountedImport) && specifier.startsWith('.') && clause.name && !clause.namedBindings;
|
|
110
|
+
if (!builtin && !localPage) fail('a source module imports runtime code that needs access review');
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement) || ts.isEmptyStatement(statement)) continue;
|
|
114
|
+
if (ts.isExpressionStatement(statement) && ts.isStringLiteral(statement.expression)) continue;
|
|
115
|
+
if (mode === 'main' && ts.isExpressionStatement(statement)) continue; // The caller requires precisely one recognized mount.
|
|
116
|
+
if (ts.isFunctionDeclaration(statement) && mode !== 'main') { if (statement.name) known.add(statement.name.text); continue; }
|
|
117
|
+
if (ts.isVariableStatement(statement) && statement.declarationList.flags & ts.NodeFlags.Const && mode !== 'main') {
|
|
118
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
119
|
+
if (!ts.isIdentifier(declaration.name) || !inert(declaration.initializer)) fail('a source module executes unresolved initialization code');
|
|
120
|
+
known.add(declaration.name.text);
|
|
121
|
+
}
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (ts.isExportAssignment(statement) && mode !== 'main' && inert(statement.expression)) continue;
|
|
125
|
+
fail('a source module executes guards or other top-level control flow');
|
|
126
|
+
}
|
|
127
|
+
if (mode !== 'page') return;
|
|
128
|
+
const component = defaultComponent(file);
|
|
129
|
+
if (!component || component.parameters?.length !== 0) fail('automatic pages need a default component without props or parameter initializers');
|
|
130
|
+
if (!component?.body || !ts.isBlock(component.body)) return;
|
|
131
|
+
const stateHooks = new Set();
|
|
132
|
+
for (const statement of file.statements) if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text === 'react') {
|
|
133
|
+
if (statement.importClause?.name) stateHooks.add(`${statement.importClause.name.text}.useState`);
|
|
134
|
+
const bindings = statement.importClause?.namedBindings;
|
|
135
|
+
if (bindings && ts.isNamedImports(bindings)) for (const item of bindings.elements) if ((item.propertyName?.text ?? item.name.text) === 'useState') stateHooks.add(item.name.text);
|
|
136
|
+
}
|
|
137
|
+
for (const statement of component.body.statements) {
|
|
138
|
+
if (ts.isReturnStatement(statement) || ts.isFunctionDeclaration(statement) || ts.isEmptyStatement(statement)) continue;
|
|
139
|
+
if (ts.isVariableStatement(statement) && statement.declarationList.flags & ts.NodeFlags.Const) {
|
|
140
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
141
|
+
const initializer = unbox(declaration.initializer);
|
|
142
|
+
const state = initializer && ts.isCallExpression(initializer) && stateHooks.has(initializer.expression.getText(file)) && initializer.arguments.length === 1 && inert(initializer.arguments[0]) && !ts.isIdentifier(unbox(initializer.arguments[0])) && !ts.isArrowFunction(unbox(initializer.arguments[0])) && !ts.isFunctionExpression(unbox(initializer.arguments[0]));
|
|
143
|
+
if (state ? !ts.isArrayBindingPattern(declaration.name) || declaration.name.elements.some(item => !ts.isBindingElement(item) || !ts.isIdentifier(item.name) || item.initializer || item.dotDotDotToken) : !ts.isIdentifier(declaration.name) || !inert(initializer)) fail('a page executes unresolved initialization or access code');
|
|
144
|
+
if (ts.isIdentifier(declaration.name)) known.add(declaration.name.text);
|
|
145
|
+
}
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
fail('a page executes unresolved runtime control flow');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function checkViteConfiguration(source, filename) {
|
|
153
|
+
const file = parse(source, filename), definitions = new Set(), reactPlugins = new Set();
|
|
154
|
+
let exported;
|
|
155
|
+
for (const statement of file.statements) {
|
|
156
|
+
if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) {
|
|
157
|
+
const specifier = statement.moduleSpecifier.text, clause = statement.importClause;
|
|
158
|
+
if (clause?.isTypeOnly) continue;
|
|
159
|
+
if (specifier === 'vite' && clause?.namedBindings && ts.isNamedImports(clause.namedBindings) && clause.namedBindings.elements.every(item => (item.propertyName?.text ?? item.name.text) === 'defineConfig')) {
|
|
160
|
+
for (const item of clause.namedBindings.elements) definitions.add(item.name.text);
|
|
161
|
+
} else if (['@vitejs/plugin-react', '@vitejs/plugin-react-swc'].includes(specifier) && clause?.name && !clause.namedBindings) reactPlugins.add(clause.name.text);
|
|
162
|
+
else fail('automatic discovery cannot review a custom Vite plugin or config import');
|
|
163
|
+
} else if (ts.isExportAssignment(statement) && !exported) exported = unbox(statement.expression);
|
|
164
|
+
else fail('automatic discovery cannot review executable Vite configuration');
|
|
165
|
+
}
|
|
166
|
+
if (exported && ts.isCallExpression(exported) && ts.isIdentifier(exported.expression) && definitions.has(exported.expression.text) && exported.arguments.length === 1) exported = unbox(exported.arguments[0]);
|
|
167
|
+
if (!exported || !ts.isObjectLiteralExpression(exported)) fail('automatic discovery requires a literal Vite configuration');
|
|
168
|
+
for (const property of exported.properties) {
|
|
169
|
+
if (!ts.isPropertyAssignment(property) || !(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name))) fail('automatic discovery cannot review computed Vite configuration');
|
|
170
|
+
const key = property.name.text, value = unbox(property.initializer);
|
|
171
|
+
if (key === 'plugins') {
|
|
172
|
+
if (!ts.isArrayLiteralExpression(value) || value.elements.some(element => !ts.isCallExpression(element) || !ts.isIdentifier(element.expression) || !reactPlugins.has(element.expression.text) || element.arguments.length !== 0)) fail('automatic discovery needs ordinary official React plugins; custom Vite middleware requires a reviewed mapping');
|
|
173
|
+
} else if (['server', 'preview'].includes(key)) {
|
|
174
|
+
if (!ts.isObjectLiteralExpression(value) || value.properties.some(item => !ts.isPropertyAssignment(item) || !['host', 'port', 'strictPort', 'open'].includes(item.name?.text) || ![ts.SyntaxKind.StringLiteral, ts.SyntaxKind.NumericLiteral, ts.SyntaxKind.TrueKeyword, ts.SyntaxKind.FalseKeyword].includes(item.initializer.kind))) fail('automatic discovery cannot review custom Vite server behavior');
|
|
175
|
+
} else fail('automatic discovery cannot review custom Vite configuration fields');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function imports(file) {
|
|
180
|
+
const defaults = new Map(), routers = new Map(), strict = new Set(), roots = new Set();
|
|
181
|
+
for (const statement of file.statements) {
|
|
182
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || statement.importClause?.isTypeOnly) continue;
|
|
183
|
+
const specifier = statement.moduleSpecifier.text, clause = statement.importClause;
|
|
184
|
+
if (clause?.name) {
|
|
185
|
+
if (specifier.startsWith('.')) defaults.set(clause.name.text, specifier);
|
|
186
|
+
if (specifier === 'react') strict.add(`${clause.name.text}.StrictMode`);
|
|
187
|
+
if (specifier === 'react-dom/client') roots.add(`${clause.name.text}.createRoot`);
|
|
188
|
+
}
|
|
189
|
+
if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) for (const item of clause.namedBindings.elements) {
|
|
190
|
+
if (item.isTypeOnly) continue;
|
|
191
|
+
const imported = item.propertyName?.text ?? item.name.text;
|
|
192
|
+
if (['react-router-dom', 'react-router'].includes(specifier) && ['BrowserRouter', 'Routes', 'Route'].includes(imported)) routers.set(item.name.text, imported);
|
|
193
|
+
if (specifier === 'react' && imported === 'StrictMode') strict.add(item.name.text);
|
|
194
|
+
if (specifier === 'react-dom/client' && imported === 'createRoot') roots.add(item.name.text);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { defaults, routers, strict, roots };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function defaultComponent(file) {
|
|
201
|
+
let component;
|
|
202
|
+
for (const statement of file.statements) {
|
|
203
|
+
if (ts.isFunctionDeclaration(statement) && statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword)) component = statement;
|
|
204
|
+
if (ts.isExportAssignment(statement)) {
|
|
205
|
+
const value = unbox(statement.expression);
|
|
206
|
+
if (ts.isArrowFunction(value) || ts.isFunctionExpression(value)) component = value;
|
|
207
|
+
else if (ts.isIdentifier(value)) {
|
|
208
|
+
component = file.statements.find(item => ts.isFunctionDeclaration(item) && item.name?.text === value.text);
|
|
209
|
+
for (const item of file.statements) if (ts.isVariableStatement(item)) for (const declaration of item.declarationList.declarations) {
|
|
210
|
+
if (ts.isIdentifier(declaration.name) && declaration.name.text === value.text) component = unbox(declaration.initializer);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return component;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function returnedDefault(file) {
|
|
219
|
+
const component = defaultComponent(file);
|
|
220
|
+
if (!component || !(ts.isFunctionDeclaration(component) || ts.isArrowFunction(component) || ts.isFunctionExpression(component)) || component.parameters.length || !component.body) fail('the router needs a default component without route props');
|
|
221
|
+
if (!ts.isBlock(component.body)) return unbox(component.body);
|
|
222
|
+
if (component.body.statements.length !== 1 || !ts.isReturnStatement(component.body.statements[0])) fail('the router contains runtime control flow');
|
|
223
|
+
return unbox(component.body.statements[0].expression);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** readFile is the source planner's bounded, symlink-safe read callback. */
|
|
227
|
+
export async function discoverViteSource(root, { locale = 'en', readFile }) {
|
|
228
|
+
let bytes = 0;
|
|
229
|
+
const read = async filename => {
|
|
230
|
+
const source = await readFile(filename);
|
|
231
|
+
if (source !== null && (bytes += Buffer.byteLength(source)) > 8 * 1024 * 1024) fail('the source inventory exceeds its inspection budget');
|
|
232
|
+
return source;
|
|
233
|
+
};
|
|
234
|
+
const resolveModule = async (from, specifier) => {
|
|
235
|
+
if (typeof specifier !== 'string' || !specifier.startsWith('.') || specifier.includes('\\') || /[?#]/.test(specifier)) fail('a page uses a nonlocal or dynamic module');
|
|
236
|
+
const base = path.posix.normalize(path.posix.join(path.posix.dirname(from), specifier));
|
|
237
|
+
const candidates = /\.(?:jsx|tsx)$/.test(base) ? [base] : path.posix.extname(base) ? [] : [`${base}.jsx`, `${base}.tsx`, `${base}/index.jsx`, `${base}/index.tsx`];
|
|
238
|
+
if (!candidates.length || candidates.some(filename => !plainModule(filename))) fail('a page module is outside the ordinary public src directory');
|
|
239
|
+
const found = [];
|
|
240
|
+
for (const filename of candidates) { const source = await read(filename); if (source !== null) found.push({ filename, source }); }
|
|
241
|
+
if (found.length !== 1) fail('a page import is missing or ambiguous');
|
|
242
|
+
return found[0];
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
for (const extension of ['js', 'mjs', 'cjs', 'ts', 'mts', 'cts']) {
|
|
246
|
+
const filename = `vite.config.${extension}`, source = await read(filename);
|
|
247
|
+
if (source !== null) checkViteConfiguration(source, filename);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Verify the mounted entry. Merely finding an unused App.jsx is not evidence
|
|
251
|
+
// that it owns /, or that an authentication wrapper is absent.
|
|
252
|
+
const index = await read('index.html');
|
|
253
|
+
if (index === null) fail('the conventional index.html entry is missing');
|
|
254
|
+
const mainFile = entryModule(index), mainSource = await read(mainFile), main = parse(mainSource, mainFile), mainImports = imports(main);
|
|
255
|
+
if (accessLogic.test(mainSource) || customRouter.test(mainSource)) fail('the entry requires access or custom-router review', true);
|
|
256
|
+
const renders = main.statements.filter(ts.isExpressionStatement).map(statement => unbox(statement.expression)).filter(node => ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'render');
|
|
257
|
+
if (renders.length !== 1 || renders[0].arguments.length !== 1) fail('the entry must mount one static React root');
|
|
258
|
+
if (main.statements.filter(statement => ts.isExpressionStatement(statement) && !ts.isStringLiteral(statement.expression)).length !== 1) fail('the entry executes runtime code outside its React mount');
|
|
259
|
+
const render = renders[0], create = unbox(render.expression.expression);
|
|
260
|
+
if (!ts.isCallExpression(create) || !mainImports.roots.has(create.expression.getText(main))) fail('the React root is not a direct createRoot mount');
|
|
261
|
+
const target = unbox(create.arguments[0]);
|
|
262
|
+
if (create.arguments.length !== 1 || !target || !ts.isCallExpression(target) || target.expression.getText(main) !== 'document.getElementById' || target.arguments.length !== 1 || !ts.isStringLiteral(target.arguments[0])) fail('the React root uses a custom mount expression');
|
|
263
|
+
let mounted = unbox(render.arguments[0]);
|
|
264
|
+
while (open(mounted) && (mainImports.strict.has(open(mounted).tagName.getText(main)) || mainImports.routers.get(open(mounted).tagName.getText(main)) === 'BrowserRouter')) {
|
|
265
|
+
const nested = children(mounted); if (!noAttributes(mounted) || nested.length !== 1) fail('the React root has an unsupported wrapper'); mounted = nested[0];
|
|
266
|
+
}
|
|
267
|
+
if (!open(mounted) || !noAttributes(mounted) || children(mounted).length || !mainImports.defaults.has(open(mounted).tagName.getText(main))) fail('the mounted app is guarded, parameterized or ambiguous');
|
|
268
|
+
checkModuleStatements(main, 'main', open(mounted).tagName.getText(main));
|
|
269
|
+
const app = await resolveModule(mainFile, mainImports.defaults.get(open(mounted).tagName.getText(main)));
|
|
270
|
+
if (accessLogic.test(app.source) || customRouter.test(app.source)) fail('the app uses access controls or a custom history/location router', true);
|
|
271
|
+
const appAst = parse(app.source, app.filename), appImports = imports(appAst);
|
|
272
|
+
const usesRouter = [...appImports.routers.values()].some(value => value === 'Routes' || value === 'Route');
|
|
273
|
+
checkModuleStatements(appAst, usesRouter ? 'router' : 'page');
|
|
274
|
+
const mappings = [], entries = [];
|
|
275
|
+
const addPage = (pathname, page) => {
|
|
276
|
+
if (!/^\/(?:[a-z0-9]+(?:[-/][a-z0-9]+)*)?$/.test(pathname) || pathname === '/home') fail('a public URL is dynamic, ambiguous or unsupported');
|
|
277
|
+
if (mappings.some(row => row.path === pathname || row.sourceFile === page.filename)) fail('public URLs must have distinct page modules and identities');
|
|
278
|
+
if (accessLogic.test(page.source) || customRouter.test(page.source)) fail('a page requires access or custom-router review', true);
|
|
279
|
+
checkModuleStatements(parse(page.source, page.filename), 'page');
|
|
280
|
+
const slug = pathname.slice(1) || 'home';
|
|
281
|
+
const extracted = extractStaticPage({ source: page.source, slug, locale, filename: page.filename });
|
|
282
|
+
if (!extracted.entry || !extracted.complete) fail('a page contains unresolved components, data or conditional content');
|
|
283
|
+
// Export only pages that can also be bound to publications without guessing
|
|
284
|
+
// props, row identities or partial native content.
|
|
285
|
+
try { transformSourcePublication({ source: page.source, slug, locale, filename: page.filename, allowNamedExports: true }); }
|
|
286
|
+
catch { fail('a public page needs an explicit content binding'); }
|
|
287
|
+
mappings.push({ path: pathname, sourceFile: page.filename }); entries.push(extracted.entry);
|
|
288
|
+
if (mappings.length > 1000) fail('the public route inventory exceeds its inspection budget');
|
|
289
|
+
};
|
|
290
|
+
if (!usesRouter) addPage('/', app);
|
|
291
|
+
else {
|
|
292
|
+
const routes = [];
|
|
293
|
+
let returned = returnedDefault(appAst);
|
|
294
|
+
if (!returned) fail('the router has no returned route tree');
|
|
295
|
+
while (ts.isJsxFragment(returned) || open(returned) && appImports.routers.get(open(returned).tagName.getText(appAst)) === 'BrowserRouter') {
|
|
296
|
+
const nested = children(returned); if (open(returned) && !noAttributes(returned) || nested.length !== 1) fail('the router has an unsupported layout or wrapper'); returned = nested[0];
|
|
297
|
+
}
|
|
298
|
+
if (!open(returned) || appImports.routers.get(open(returned).tagName.getText(appAst)) !== 'Routes' || !noAttributes(returned)) fail('only a flat static Routes tree can be mapped automatically');
|
|
299
|
+
for (const route of children(returned)) {
|
|
300
|
+
if (!open(route) || appImports.routers.get(open(route).tagName.getText(appAst)) !== 'Route' || children(route).length) fail('nested, conditional or guarded routes need an explicit mapping');
|
|
301
|
+
const attributes = open(route).attributes.properties;
|
|
302
|
+
if (attributes.some(attribute => !ts.isJsxAttribute(attribute) || !['path', 'element'].includes(attribute.name.getText(appAst))) || attributes.length !== 2) fail('routes need literal path and direct element attributes');
|
|
303
|
+
const pathname = attributes.find(attribute => attribute.name.getText(appAst) === 'path')?.initializer;
|
|
304
|
+
if (!pathname || !ts.isStringLiteral(pathname)) fail('route paths must be literal strings');
|
|
305
|
+
if (pathname.text === '*' || pathname.text.split('/').some(segment => privateSegment.test(segment))) continue;
|
|
306
|
+
const element = attributes.find(attribute => attribute.name.getText(appAst) === 'element')?.initializer;
|
|
307
|
+
const pageNode = element && ts.isJsxExpression(element) ? unbox(element.expression) : null;
|
|
308
|
+
if (!pageNode || !open(pageNode) || !noAttributes(pageNode) || children(pageNode).length || !appImports.defaults.has(open(pageNode).tagName.getText(appAst))) fail('route elements must be local default-imported pages without props or guards');
|
|
309
|
+
routes.push({ pathname: pathname.text, specifier: appImports.defaults.get(open(pageNode).tagName.getText(appAst)) });
|
|
310
|
+
}
|
|
311
|
+
if (!routes.length || routes.length > 1000) fail('there are no bounded public page routes');
|
|
312
|
+
for (const route of routes) addPage(route.pathname, await resolveModule(app.filename, route.specifier));
|
|
313
|
+
}
|
|
314
|
+
return { manifest: { version: 1, entries }, mappings, diagnostics: { automaticPages: entries.length, discovery: usesRouter ? 'react-router' : 'single-page' } };
|
|
315
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
|
|
3
|
+
export type PublicSourceConfiguration = {
|
|
4
|
+
version: 1;
|
|
5
|
+
siteId: string;
|
|
6
|
+
environmentId: string;
|
|
7
|
+
websiteOrigin: string;
|
|
8
|
+
adminOrigin: string;
|
|
9
|
+
locale?: string;
|
|
10
|
+
publicStaticPages: true;
|
|
11
|
+
/** Close the initial public source export after migration. */
|
|
12
|
+
exportClosed?: true;
|
|
13
|
+
/** Must be empty: apply exclusions directly to the reviewed manifest. */
|
|
14
|
+
skipSlugs?: string[];
|
|
15
|
+
/** Must be empty: apply exclusions directly to the reviewed manifest. */
|
|
16
|
+
excludedRoutes?: string[];
|
|
17
|
+
};
|
|
18
|
+
export const VITE_SOURCE_MANIFEST_FILE: '.universalcms/source.entries.json';
|
|
19
|
+
/** Uses only the fixed, reviewed JSON manifest; never executes website source. */
|
|
20
|
+
export function createViteSourceHandler(options: { sourceRoot: string; configuration: PublicSourceConfiguration }): (request: IncomingMessage, response: ServerResponse) => Promise<boolean>;
|
|
21
|
+
type Middleware = (request: IncomingMessage, response: ServerResponse, next: () => void) => void;
|
|
22
|
+
type ViteSourceServerOptions = { port?: number; strictPort?: boolean };
|
|
23
|
+
/** Development/preview middleware; static production hosts need a server adapter. */
|
|
24
|
+
export function universalCmsSource(options: { configuration: PublicSourceConfiguration }): {
|
|
25
|
+
name: string;
|
|
26
|
+
config(config: { server?: ViteSourceServerOptions; preview?: ViteSourceServerOptions }, environment: { command: 'build' | 'serve' }): { server: ViteSourceServerOptions & { port: number; strictPort: true }; preview: ViteSourceServerOptions & { port: number; strictPort: true } } | undefined;
|
|
27
|
+
configResolved(config: { root: string; command?: 'build' | 'serve'; server?: ViteSourceServerOptions; preview?: ViteSourceServerOptions }): void;
|
|
28
|
+
configureServer(server: { middlewares: { use(handler: Middleware): unknown } }): void;
|
|
29
|
+
configurePreviewServer(server: { middlewares: { use(handler: Middleware): unknown } }): void;
|
|
30
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { lstat, open, realpath } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createSourceManifestProvider } from './vendor/source-manifest-provider.mjs';
|
|
5
|
+
import { validateSourceConfiguration } from './source-config.mjs';
|
|
6
|
+
|
|
7
|
+
export const VITE_SOURCE_MANIFEST_FILE = '.universalcms/source.entries.json';
|
|
8
|
+
const MAX_BYTES = 20 * 1024 * 1024;
|
|
9
|
+
const stamp = stat => [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs, stat.nlink, stat.mode].join(':');
|
|
10
|
+
|
|
11
|
+
/** Read one explicitly reviewed public JSON file, never executable website code. */
|
|
12
|
+
async function readManifest(sourceRoot, locale) {
|
|
13
|
+
const requested = path.resolve(sourceRoot), rootStat = await lstat(requested);
|
|
14
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw Error('Invalid source root');
|
|
15
|
+
const root = await realpath(requested);
|
|
16
|
+
let target = root;
|
|
17
|
+
const parts = VITE_SOURCE_MANIFEST_FILE.split('/');
|
|
18
|
+
let found;
|
|
19
|
+
for (const [index, part] of parts.entries()) {
|
|
20
|
+
target = path.join(target, part);
|
|
21
|
+
const stat = await lstat(target);
|
|
22
|
+
if (stat.isSymbolicLink() || await realpath(target) !== target || (index === parts.length - 1 ? !stat.isFile() || stat.nlink !== 1 : !stat.isDirectory())) throw Error('Invalid source file');
|
|
23
|
+
found = stat;
|
|
24
|
+
}
|
|
25
|
+
if (found.size > MAX_BYTES) throw Error('Source budget');
|
|
26
|
+
const file = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
27
|
+
try {
|
|
28
|
+
const before = await file.stat();
|
|
29
|
+
if (stamp(before) !== stamp(found)) throw Error('Source changed');
|
|
30
|
+
const buffer = Buffer.alloc(MAX_BYTES + 1);
|
|
31
|
+
let size = 0;
|
|
32
|
+
while (size < buffer.length) {
|
|
33
|
+
const result = await file.read(buffer, size, buffer.length - size, size);
|
|
34
|
+
if (!result.bytesRead) break;
|
|
35
|
+
size += result.bytesRead;
|
|
36
|
+
}
|
|
37
|
+
if (size > MAX_BYTES || stamp(await file.stat()) !== stamp(before) || stamp(await lstat(target)) !== stamp(before)) throw Error('Source changed or exceeded budget');
|
|
38
|
+
const json = new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, size));
|
|
39
|
+
const manifest = JSON.parse(json);
|
|
40
|
+
if (!manifest || !Array.isArray(manifest.entries) || manifest.entries.length === 0) throw Error('No reviewed public content');
|
|
41
|
+
if (manifest.entries.some(entry => typeof entry?.locale !== 'string' || entry.locale.length > 35 || Intl.getCanonicalLocales(entry.locale)[0] !== locale)) throw Error('Source locale does not match reviewed configuration');
|
|
42
|
+
return json;
|
|
43
|
+
} finally { await file.close(); }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function validateViteConfiguration(value) {
|
|
47
|
+
const config = validateSourceConfiguration(value);
|
|
48
|
+
if (config.skipSlugs.length || config.excludedRoutes.length) throw Error('Apply source exclusions in the reviewed Vite manifest before enabling its export');
|
|
49
|
+
return config;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function capture(provider, request) {
|
|
53
|
+
const result = { handled: false, status: 404, headers: {}, body: undefined };
|
|
54
|
+
result.handled = provider(request, {
|
|
55
|
+
writeHead(status, headers) { result.status = status; result.headers = headers; },
|
|
56
|
+
end(body) { result.body = body; },
|
|
57
|
+
});
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Node middleware for a reviewed source manifest. Returns false for other URLs.
|
|
63
|
+
* Deploy this handler with the reviewed file when serving outside Vite; a static
|
|
64
|
+
* hosting/CDN deployment cannot provide the required scoped server endpoint.
|
|
65
|
+
*/
|
|
66
|
+
export function createViteSourceHandler({ sourceRoot, configuration }) {
|
|
67
|
+
const config = validateViteConfiguration(configuration);
|
|
68
|
+
const binding = { siteId: config.siteId, environmentId: config.environmentId, sourceOrigin: config.websiteOrigin, adminOrigin: config.adminOrigin, publicContent: true };
|
|
69
|
+
const probe = createSourceManifestProvider({ ...binding, manifestJson: '{"version":1,"entries":[]}' });
|
|
70
|
+
let pending;
|
|
71
|
+
return async function handle(request, response) {
|
|
72
|
+
// The provider's origin, host, credential and scope checks run before disk I/O.
|
|
73
|
+
const gate = capture(probe, request);
|
|
74
|
+
if (!gate.handled) return false;
|
|
75
|
+
const send = result => {
|
|
76
|
+
if (response.destroyed || response.writableEnded) return;
|
|
77
|
+
response.writeHead(result.status, result.headers);
|
|
78
|
+
response.end(result.body);
|
|
79
|
+
};
|
|
80
|
+
if (gate.status !== 200 || !['GET', 'HEAD'].includes(request.method)) { send(gate); return true; }
|
|
81
|
+
if (config.exportClosed) {
|
|
82
|
+
const body = JSON.stringify({ version: 1, siteId: config.siteId, environmentId: config.environmentId, sourceExport: 'closed' });
|
|
83
|
+
send({ status: 410, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'Access-Control-Allow-Origin': config.adminOrigin, Vary: 'Origin', 'X-Content-Type-Options': 'nosniff' }, body: request.method === 'HEAD' ? undefined : body });
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
// Coalesce in-flight reads only; a completed check never caches old content.
|
|
88
|
+
pending ??= readManifest(sourceRoot, config.locale).then(manifestJson => createSourceManifestProvider({ ...binding, manifestJson })).finally(() => { pending = undefined; });
|
|
89
|
+
const provider = await pending;
|
|
90
|
+
send(capture(provider, request));
|
|
91
|
+
} catch {
|
|
92
|
+
const body = JSON.stringify({ error: 'Website source setup is incomplete. Add a non-empty reviewed public source manifest at .universalcms/source.entries.json, then check again.' });
|
|
93
|
+
send({ status: 503, headers: {
|
|
94
|
+
'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store',
|
|
95
|
+
'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer',
|
|
96
|
+
'Access-Control-Allow-Origin': config.adminOrigin, Vary: 'Origin', 'Content-Length': Buffer.byteLength(body),
|
|
97
|
+
}, body: request.method === 'HEAD' ? undefined : body });
|
|
98
|
+
}
|
|
99
|
+
return true;
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Install before Vite's SPA fallback during normal development and local preview. */
|
|
104
|
+
export function universalCmsSource({ configuration }) {
|
|
105
|
+
const config = validateViteConfiguration(configuration);
|
|
106
|
+
const origin = new URL(config.websiteOrigin);
|
|
107
|
+
const localPort = origin.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(origin.hostname) ? Number(origin.port || 80) : null;
|
|
108
|
+
const portError = () => { throw Error(`Universal CMS source export requires the registered website port ${localPort} with strictPort enabled. Remove conflicting Vite port overrides or update the reviewed website origin.`); };
|
|
109
|
+
let sourceRoot;
|
|
110
|
+
function install(server) {
|
|
111
|
+
if (!sourceRoot) throw Error('Universal CMS source plugin requires the resolved website root');
|
|
112
|
+
const handle = createViteSourceHandler({ sourceRoot, configuration: config });
|
|
113
|
+
server.middlewares.use((request, response, next) => {
|
|
114
|
+
void handle(request, response).then(handled => { if (!handled) next(); }, () => {
|
|
115
|
+
if (!response.destroyed && !response.writableEnded) {
|
|
116
|
+
response.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
117
|
+
response.end(JSON.stringify({ error: 'Website source setup is incomplete.' }));
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
name: 'universalcms-public-source',
|
|
124
|
+
config(value, environment) {
|
|
125
|
+
if (localPort === null) return;
|
|
126
|
+
// Source reads bind the exact registered Host. Do not silently switch to
|
|
127
|
+
// another development port or Vite preview's separate default port.
|
|
128
|
+
if (environment.command === 'serve' && [value.server?.port, value.preview?.port].some(port => port !== undefined && port !== localPort)) portError();
|
|
129
|
+
return {
|
|
130
|
+
server: { ...value.server, port: localPort, strictPort: true },
|
|
131
|
+
preview: { ...value.preview, port: localPort, strictPort: true },
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
configResolved(resolved) {
|
|
135
|
+
if (localPort !== null && resolved.command === 'serve' && [resolved.server, resolved.preview].some(server => server?.port !== localPort || server?.strictPort !== true)) portError();
|
|
136
|
+
sourceRoot = resolved.root;
|
|
137
|
+
},
|
|
138
|
+
configureServer: install,
|
|
139
|
+
configurePreviewServer: install,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { PublicationConfiguration } from './publication.mjs';
|
|
2
|
+
import type { NativeWebsitePayload } from './native-website-model.mjs';
|
|
3
|
+
export type VuePublicPayload = NativeWebsitePayload & { bindings: Record<string, { id: string; version: number; type: string; text: string; data?: Record<string, unknown>; url?: string; alt?: string }> };
|
|
4
|
+
export type VueRuntimeOptions = { configuration?: PublicationConfiguration; sourceConfiguration: unknown; contentBindings?: unknown; snapshot: unknown; sourceRoot: string; env?: Record<string,string|undefined> | (()=>Record<string,string|undefined>|Promise<Record<string,string|undefined>>); fetcher?: typeof fetch };
|
|
5
|
+
export type VuePrepared = { pass: true; payload: VuePublicPayload | null; context: unknown; headers: Record<string,string> } | { pass: false; response: Response };
|
|
6
|
+
export function vuePublicPayload(context: unknown): VuePublicPayload | null;
|
|
7
|
+
export function serializeVuePayload(payload: unknown): string;
|
|
8
|
+
export function createVueRuntime(options: VueRuntimeOptions): { handle(request: Request,render: (payload: VuePublicPayload | null)=>Promise<Response>): Promise<Response>; prepare(request: Request): Promise<VuePrepared> };
|
|
9
|
+
export function renderVueWebsite(payload: VuePublicPayload | null,nativeHtml?: string): string;
|
|
10
|
+
export function finishNuxtHtml(html: string,prepared: VuePrepared): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Native Vue/Nuxt server boundary. No delivery credentials enter hydration. */
|
|
2
|
+
import { createAstroRuntime, astroBound } from './astro-runtime.mjs';
|
|
3
|
+
import { renderNativeWebsite } from './native-website-server.mjs';
|
|
4
|
+
import { websiteHtml } from './website-http.mjs';
|
|
5
|
+
import { PUBLICATION_SCRIPT_PATH } from './publication-browser.mjs';
|
|
6
|
+
|
|
7
|
+
export function vuePublicPayload(context) {
|
|
8
|
+
if (!context) return null;
|
|
9
|
+
const bindings = {};
|
|
10
|
+
for (const block of context.inventory?.baseline.blocks ?? []) {
|
|
11
|
+
const current = astroBound(context, block.id, block.type);
|
|
12
|
+
if (current) bindings[block.id] = current;
|
|
13
|
+
}
|
|
14
|
+
return { pathname: context.pathname, scope: context.scope, initialResult: context.result, initialSettings: context.settings, ...(context.publicInventory ? { inventory: context.publicInventory } : {}), bindings };
|
|
15
|
+
}
|
|
16
|
+
export function serializeVuePayload(payload) { return JSON.stringify(payload).replace(/[<>&\u2028\u2029]/g,char=>'\\u'+char.charCodeAt(0).toString(16).padStart(4,'0')); }
|
|
17
|
+
|
|
18
|
+
export function createVueRuntime(options) {
|
|
19
|
+
const runtime=createAstroRuntime(options);
|
|
20
|
+
return {
|
|
21
|
+
async handle(request, render) {
|
|
22
|
+
const context={request,locals:{}};
|
|
23
|
+
return runtime.onRequest(context, async()=>render(vuePublicPayload(context.locals.universalcms)));
|
|
24
|
+
},
|
|
25
|
+
/** Nitro middleware cannot await the framework renderer; resolve the same
|
|
26
|
+
* gate first, retaining private metadata only on this server request. */
|
|
27
|
+
async prepare(request) {
|
|
28
|
+
const context={request,locals:{}};let passed=false;
|
|
29
|
+
const response=await runtime.onRequest(context,async()=>{passed=true;return new Response('<html><head></head><body></body></html>',{headers:{'Content-Type':'text/html; charset=utf-8'}});});
|
|
30
|
+
return passed&&response.status===200?{pass:true,payload:vuePublicPayload(context.locals.universalcms),context:context.locals.universalcms,headers:Object.fromEntries(response.headers)}:{pass:false,response};
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function renderVueWebsite(payload, nativeHtml='') {
|
|
35
|
+
if(!payload)return `<div id="app">${nativeHtml}</div>`;
|
|
36
|
+
return renderNativeWebsite(payload,payload.inventory?`<div id="universalcms-native">${nativeHtml}</div>`:'');
|
|
37
|
+
}
|
|
38
|
+
export function finishNuxtHtml(html, prepared) {
|
|
39
|
+
if(!prepared?.payload)return html;
|
|
40
|
+
if(typeof html!=='string'||Buffer.byteLength(html)>8*1024*1024)throw Error('Nuxt rendering unavailable.');
|
|
41
|
+
const open='<universalcms-website data-universalcms-native>',close='</universalcms-website>';
|
|
42
|
+
const start=html.indexOf(open),end=html.indexOf(close,start);
|
|
43
|
+
if(start<0||end<start||html.indexOf(open,start+open.length)!==-1||html.indexOf(close,end+close.length)!==-1)throw Error('Nuxt native boundary missing.');
|
|
44
|
+
const native=html.slice(start+open.length,end);
|
|
45
|
+
// Nuxt owns its light DOM and hydration markers; do not add a native wrapper.
|
|
46
|
+
html=html.slice(0,start)+renderNativeWebsite(prepared.payload,prepared.payload.inventory?native:'')+html.slice(end+close.length);
|
|
47
|
+
const {settings,metadata,inventory}=prepared.context,seo=settings.sections.seo.state==='ready';
|
|
48
|
+
html=websiteHtml(html,seo||!inventory?metadata:null,{seo,branding:settings.sections.branding.state==='ready'?settings.sections.branding.data:undefined});
|
|
49
|
+
return html.replace(/<head\b[^>]*>/i,head=>head+`<script src="${PUBLICATION_SCRIPT_PATH}"></script>`);
|
|
50
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Official Vue/Vite SSR host. Generated application imports remain website-owned. */
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { readFile, lstat, realpath } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
import { createVueRuntime, renderVueWebsite, serializeVuePayload } from './vue-runtime.mjs';
|
|
7
|
+
const types={'.js':'text/javascript','.css':'text/css','.svg':'image/svg+xml','.png':'image/png','.jpg':'image/jpeg','.webp':'image/webp','.ico':'image/x-icon','.woff2':'font/woff2','.json':'application/json','.txt':'text/plain','.xml':'application/xml','.pdf':'application/pdf'};
|
|
8
|
+
async function asset(root,pathname){
|
|
9
|
+
if(!pathname.startsWith('/')||/[\\%?#\u0000-\u0020]/.test(pathname)||pathname.split('/').slice(1).some(p=>!p||p==='.'||p==='..'||p.startsWith('.')))return null;
|
|
10
|
+
const type=types[path.extname(pathname)];if(!type)return null;
|
|
11
|
+
let target=root;
|
|
12
|
+
try{for(const part of pathname.slice(1).split('/')){target=path.join(target,part);const stat=await lstat(target);if(stat.isSymbolicLink()||await realpath(target)!==target)return null;}
|
|
13
|
+
const stat=await lstat(target);if(!stat.isFile()||stat.nlink!==1||stat.size>16*1024*1024)return null;
|
|
14
|
+
return new Response(await readFile(target),{headers:{'Content-Type':type,'Cache-Control':'no-cache'}});
|
|
15
|
+
}catch(error){if(['ENOENT','ENOTDIR'].includes(error.code))return null;throw error;}
|
|
16
|
+
}
|
|
17
|
+
export async function startVueWebsite({root,production=false,vite}){
|
|
18
|
+
root=await realpath(root);
|
|
19
|
+
const read=async name=>JSON.parse(await readFile(path.join(root,'.universalcms',name),'utf8'));
|
|
20
|
+
const sourceConfiguration=await read('source.config.json'),snapshot=await read('source.generated.json');
|
|
21
|
+
let configuration,contentBindings;try{configuration=await read('publication.config.json');contentBindings=await read('content.bindings.json');}catch(error){if(error.code!=='ENOENT')throw error;}
|
|
22
|
+
const runtime=createVueRuntime({configuration,contentBindings,sourceConfiguration,snapshot,sourceRoot:root});
|
|
23
|
+
const origin=new URL(sourceConfiguration.websiteOrigin),port=Number(process.env.PORT??(origin.port||(origin.protocol==='https:'?443:80)));
|
|
24
|
+
if(!Number.isInteger(port)||port<1||port>65535)throw Error('Invalid website port.');
|
|
25
|
+
let dev=null;
|
|
26
|
+
const built=production?await import(pathToFileURL(path.join(root,'.universalcms/dist/server/vue.entry-server.mjs')).href):null;
|
|
27
|
+
const server=createServer({requestTimeout:15000,headersTimeout:10000,maxHeaderSize:8192},async(req,res)=>{
|
|
28
|
+
try{
|
|
29
|
+
if(!req.url?.startsWith('/')||req.url.startsWith('//')||/[\\\s#]/.test(req.url)||req.headers.host!==origin.host||req.url.split('?')[0].split('/').some(part=>part==='.'||part==='..')||/%(?:2e|2f|5c|25)/i.test(req.url.split('?')[0])){res.writeHead(403,{'Cache-Control':'no-store'});res.end();return;}
|
|
30
|
+
const pathname=req.url.split('?')[0];
|
|
31
|
+
if(pathname.includes('/.universalcms/')&&!['/.universalcms/vue.app.mjs','/.universalcms/vue.client.mjs'].includes(pathname)){res.writeHead(404,{'Cache-Control':'no-store'});res.end();return;}
|
|
32
|
+
const request=new Request(new URL(req.url,origin),{method:req.method,headers:new Headers(Object.entries(req.headers).flatMap(([k,v])=>v===undefined?[]:[[k,Array.isArray(v)?v.join(', '):v]]))});
|
|
33
|
+
let delegated=false;
|
|
34
|
+
const response=await runtime.handle(request,async payload=>{
|
|
35
|
+
if(!payload&&new URL(request.url).pathname!=='/'){
|
|
36
|
+
if(dev){delegated=true;dev.middlewares(req,res,()=>{if(!res.writableEnded){res.writeHead(404);res.end();}});return new Response(null,{status:204});}
|
|
37
|
+
return await asset(path.join(root,'.universalcms/dist/client'),new URL(request.url).pathname)??new Response(null,{status:404});
|
|
38
|
+
}
|
|
39
|
+
let template=await readFile(path.join(root,production?'.universalcms/dist/client/.universalcms/vue.index.html':'.universalcms/vue.index.html'),'utf8');
|
|
40
|
+
if(dev)template=await dev.transformIndexHtml(request.url,template);
|
|
41
|
+
const render=dev?(await dev.ssrLoadModule('/.universalcms/vue.entry-server.mjs')).render:built.render;
|
|
42
|
+
const native=payload&&!payload.inventory?'':await render(payload);
|
|
43
|
+
const markup=renderVueWebsite(payload,native);
|
|
44
|
+
const body=template.replace('<!--universalcms-app-->',markup).replace('<!--universalcms-state-->',`<script type="application/json" id="universalcms-vue-state">${serializeVuePayload(payload)}</script>`);
|
|
45
|
+
return new Response(body,{headers:{'Content-Type':'text/html; charset=utf-8'}});
|
|
46
|
+
});
|
|
47
|
+
if(delegated)return;
|
|
48
|
+
res.writeHead(response.status,Object.fromEntries(response.headers));res.end(req.method==='HEAD'?undefined:Buffer.from(await response.arrayBuffer()));
|
|
49
|
+
}catch{if(!res.headersSent)res.writeHead(503,{'Cache-Control':'no-store','Content-Type':'text/plain'});res.end('Website temporarily unavailable.');}
|
|
50
|
+
});
|
|
51
|
+
// Attach Vite's websocket to this website's port; the default standalone HMR
|
|
52
|
+
// port collides when several connected websites are developed together.
|
|
53
|
+
if(!production){
|
|
54
|
+
const websocket=Number(vite.version?.split('.')[0])>=8?{ws:{server}}:{hmr:{server}};
|
|
55
|
+
dev=await vite.createServer({root,configFile:path.join(root,'.universalcms/vue.config.mjs'),server:{middlewareMode:true,...websocket},appType:'custom'});
|
|
56
|
+
}
|
|
57
|
+
server.on('close',()=>{void dev?.close();});server.listen(port,'0.0.0.0');return server;
|
|
58
|
+
}
|