@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,138 @@
|
|
|
1
|
+
/** Read-only original-route planning. No credentials, project execution or requests. */
|
|
2
|
+
import { analyze, CONFIG_FILE, planSetup, projectRoot, readProjectFile, SetupError, validateConfig, validatePlan } from './core.mjs';
|
|
3
|
+
import { inspectSetupFramework } from './framework.mjs';
|
|
4
|
+
import { assertConnectorRuntime } from './connector-runtime.mjs';
|
|
5
|
+
import { assertRuntimeDependencies } from './runtime-dependencies.mjs';
|
|
6
|
+
import { readConnection, validateConnection } from './staged-setup.mjs';
|
|
7
|
+
import { setupSource, readSourceSettings, validateSourcePlan } from './source-setup.mjs';
|
|
8
|
+
import { planPublicationSetup, planInstalledPublication, readPublicationConfiguration } from './publication-setup.mjs';
|
|
9
|
+
import { planContentSetup } from './content-setup.mjs';
|
|
10
|
+
import { assertPlannedSettingsCompatibility } from './website-setup.mjs';
|
|
11
|
+
import { setupReviewDiagnostic, setupRecovery } from './preflight-recovery.mjs';
|
|
12
|
+
|
|
13
|
+
const fields = ['apiOrigin', 'siteId', 'environmentId', 'locale', 'websiteOrigin', 'adminOrigin'];
|
|
14
|
+
const labels = new Set(['Next.js', 'React', 'Vue', 'Nuxt', 'Angular', 'Astro', 'SvelteKit', 'Laravel', 'WordPress', 'Static', 'Custom']);
|
|
15
|
+
const guidance = {
|
|
16
|
+
project: 'Open the actual website project folder. Its ordinary public files must be readable.',
|
|
17
|
+
framework: 'Automatic original-route setup requires a compatible reviewed Next.js, React/Vite, Astro, Vue, Nuxt, Angular SSR, SvelteKit or literal HTML project. WordPress uses its native plugin and WP-CLI. Review conflicting framework evidence, custom servers and unsupported versions before continuing. Other frameworks require their existing server or private-build adapter from Developer setup; changing the selected framework label cannot install an adapter.',
|
|
18
|
+
runtime: 'Install the matching @universalcms/connect release as a production dependency in this website folder using Developer setup. A cached or external CLI is insufficient. Restore missing local runtime files before continuing.',
|
|
19
|
+
'public-scope': 'Provide the six public Developer setup values, or use this folder’s saved public connection. They must match existing connection, source, publication and delivery configuration. Private environment values are never used by preflight.',
|
|
20
|
+
'installed-integration': 'Existing integration files are incomplete, customized or use a different scope. Preserve them and review the existing adapter before continuing. Do not rerun scaffold setup or replace a key to repair a missing adapter.',
|
|
21
|
+
source: 'The public source inventory or its route ownership needs review. Preserve custom adapters and explicitly map public content; private, dynamic and custom-router routes cannot be guessed.',
|
|
22
|
+
publication: 'Original website URLs need an exact reviewed publication mapping and compatible server routes. Preserve existing routes and adapters before continuing.',
|
|
23
|
+
content: 'The original website renderer needs reviewed content bindings. Preserve native interactions and route ownership; source import alone does not connect published edits.',
|
|
24
|
+
settings: 'The native header, footer or route ownership needs a reviewed settings adapter. Preserve native navigation and compose the shared settings components before continuing.',
|
|
25
|
+
};
|
|
26
|
+
const summary = 'Read-only plan only. No files, keys or CMS data changed. A successful plan does not verify key access, installation, live delivery or rendered settings. Run the reviewed setup, restart or deploy, then check publication and settings and verify the actual website.';
|
|
27
|
+
const requireMatch = (saved, requested, names) => {
|
|
28
|
+
if (names.some(name => saved[name] !== requested[name])) throw Error('Public scope mismatch');
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// assertConnectionMatches also inspects private environment settings. Preflight
|
|
32
|
+
// deliberately compares only separately validated public configuration instead.
|
|
33
|
+
async function publicConnection(root, options) {
|
|
34
|
+
if (!options || typeof options !== 'object' || Array.isArray(options) || Object.keys(options).some(name => !fields.includes(name))) throw Error('Public options only');
|
|
35
|
+
const saved = await readConnection(root);
|
|
36
|
+
const entered = Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined));
|
|
37
|
+
const connection = validateConnection({ version: 1, locale: 'en', ...(saved ?? {}), ...entered });
|
|
38
|
+
if (saved) requireMatch(saved, connection, fields);
|
|
39
|
+
const configText = await readProjectFile(root, CONFIG_FILE);
|
|
40
|
+
const config = configText === null ? null : validateConfig(JSON.parse(configText));
|
|
41
|
+
if (config) requireMatch(config, connection, ['apiOrigin', 'siteId', 'environmentId', 'locale']);
|
|
42
|
+
const source = await readSourceSettings(root);
|
|
43
|
+
if (Object.keys(source).length) requireMatch(source, connection, fields.filter(name => name !== 'apiOrigin'));
|
|
44
|
+
const publication = await readPublicationConfiguration(root);
|
|
45
|
+
if (publication) {
|
|
46
|
+
requireMatch(publication, connection, ['apiOrigin', 'siteId', 'environmentId', 'websiteOrigin']);
|
|
47
|
+
if (publication.routes.some(route => route.locale !== connection.locale)) throw Error('Publication locale mismatch');
|
|
48
|
+
}
|
|
49
|
+
return { connection, config, publication };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function unchangedInstallation(root, plan) {
|
|
53
|
+
if (!plan || plan.files.some(file => file.state !== 'unchanged')) throw Error('Incomplete installation');
|
|
54
|
+
await validatePlan(plan);
|
|
55
|
+
for (const file of plan.files) if (await readProjectFile(root, file.path) !== file.content) throw Error('Changed installation');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Typed public diagnostics only; raw planner/parser errors remain redacted. */
|
|
59
|
+
export async function preflightSetup(directory, options = {}) {
|
|
60
|
+
let phase = 'project', framework = 'Custom', installed = false;
|
|
61
|
+
let frameworkMessage, publicScope;
|
|
62
|
+
const checks = {}, plans = [];
|
|
63
|
+
const settings = { status: 'review-required', installed: false, liveVerified: false };
|
|
64
|
+
try {
|
|
65
|
+
const root = await projectRoot(directory);
|
|
66
|
+
phase = 'framework';
|
|
67
|
+
const analysis = await analyze(root);
|
|
68
|
+
framework = labels.has(analysis.framework) ? analysis.framework : 'Custom';
|
|
69
|
+
let detected;
|
|
70
|
+
try { detected = await inspectSetupFramework(root, { analysis }); }
|
|
71
|
+
catch (error) {
|
|
72
|
+
// The framework inspector constructs fixed guidance from allowlisted
|
|
73
|
+
// labels and reasons, never customer source, versions or script text.
|
|
74
|
+
if (error instanceof SetupError) frameworkMessage = error.message;
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
framework = detected.label;
|
|
78
|
+
checks.framework = 'planned';
|
|
79
|
+
phase = 'runtime';
|
|
80
|
+
await assertConnectorRuntime(root, detected.framework);
|
|
81
|
+
checks.runtime = 'planned';
|
|
82
|
+
phase = 'public-scope';
|
|
83
|
+
const { connection, config, publication: previousPublication } = await publicConnection(root, options);
|
|
84
|
+
publicScope = connection;
|
|
85
|
+
checks.publicScope = 'planned';
|
|
86
|
+
phase = 'installed-integration';
|
|
87
|
+
const settingsManifest = await readProjectFile(root, '.universalcms/website.integration.json');
|
|
88
|
+
const installedPublication = await planInstalledPublication(root);
|
|
89
|
+
if (previousPublication) await unchangedInstallation(root, installedPublication);
|
|
90
|
+
if (config) {
|
|
91
|
+
const demo = await planSetup(root, { ...config });
|
|
92
|
+
await unchangedInstallation(root, demo);
|
|
93
|
+
await assertRuntimeDependencies(root, detected.framework, connection.adminOrigin);
|
|
94
|
+
plans.push(demo);
|
|
95
|
+
}
|
|
96
|
+
if (settingsManifest !== null) {
|
|
97
|
+
if (!previousPublication) throw Error('Missing installed publication scope');
|
|
98
|
+
const source = await setupSource(root, { ...Object.fromEntries(fields.filter(name => name !== 'apiOrigin').map(name => [name, connection[name]])), publication: true });
|
|
99
|
+
if (source.retainedAdapter || source.files.some(file => file.state !== 'unchanged')) throw Error('Installed source needs review');
|
|
100
|
+
await validateSourcePlan(source);
|
|
101
|
+
plans.push(installedPublication, source);
|
|
102
|
+
installed = true;
|
|
103
|
+
checks.source = checks.publication = checks.content = 'preserved';
|
|
104
|
+
settings.status = 'preserved'; settings.installed = true;
|
|
105
|
+
} else {
|
|
106
|
+
if (!previousPublication && await readProjectFile(root, '.universalcms/content.bindings.json') !== null) throw Error('Missing installed publication scope');
|
|
107
|
+
phase = 'source';
|
|
108
|
+
const source = await setupSource(root, { ...Object.fromEntries(fields.filter(name => name !== 'apiOrigin').map(name => [name, connection[name]])), publication: true });
|
|
109
|
+
checks.source = 'planned';
|
|
110
|
+
phase = 'publication';
|
|
111
|
+
const publication = await planPublicationSetup(root, connection, source);
|
|
112
|
+
checks.publication = 'planned';
|
|
113
|
+
phase = 'content';
|
|
114
|
+
const content = await planContentSetup(root, publication, source);
|
|
115
|
+
checks.content = 'planned';
|
|
116
|
+
phase = 'settings';
|
|
117
|
+
await assertPlannedSettingsCompatibility(root, content, publication);
|
|
118
|
+
// Settings require the installed content registry. Do not pretend that a
|
|
119
|
+
// future layout/settings plan has been installed or visually verified.
|
|
120
|
+
phase = 'source'; await validateSourcePlan(source);
|
|
121
|
+
phase = 'publication'; await validatePlan(publication);
|
|
122
|
+
phase = 'content'; await validatePlan(content);
|
|
123
|
+
plans.push(source, publication, content);
|
|
124
|
+
}
|
|
125
|
+
const files = new Map();
|
|
126
|
+
for (const plan of plans) for (const file of plan.files) {
|
|
127
|
+
const prior = files.get(file.path);
|
|
128
|
+
if (!prior || prior === 'unchanged') files.set(file.path, file.state);
|
|
129
|
+
}
|
|
130
|
+
const counts = Object.fromEntries(['create', 'update', 'unchanged'].map(state => [state, [...files.values()].filter(value => value === state).length]));
|
|
131
|
+
return { status: 'planned', framework, checks, installed, settings, files: counts, liveVerified: false, renderedVerified: false, message: summary };
|
|
132
|
+
} catch (error) {
|
|
133
|
+
const known = setupReviewDiagnostic(error);
|
|
134
|
+
const message = known?.message ?? (phase === 'framework' && frameworkMessage ? frameworkMessage : guidance[phase]);
|
|
135
|
+
const blocker = known ?? { code: `${phase}-review-required`, stage: phase, message, paths: [], omittedPathCount: 0 };
|
|
136
|
+
return { status: 'review-required', framework, phase, checks, installed: false, settings, liveVerified: false, renderedVerified: false, message, blockers: [blocker], recovery: setupRecovery({ phase, framework, blocker, connection: publicScope }), limitation: summary };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded, symlink-safe project file access. Kept free of setup tooling so
|
|
3
|
+
* website runtimes (for example the Next source route) can import it without
|
|
4
|
+
* bundling framework compilers.
|
|
5
|
+
*/
|
|
6
|
+
import { constants } from 'node:fs';
|
|
7
|
+
import { lstat, realpath, open } from 'node:fs/promises';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
export class SetupError extends Error {}
|
|
11
|
+
const fail = message => { throw new SetupError(message); };
|
|
12
|
+
const maxFileBytes = 128 * 1024;
|
|
13
|
+
|
|
14
|
+
export async function projectRoot(directory) {
|
|
15
|
+
const requested = path.resolve(directory);
|
|
16
|
+
const stat = await lstat(requested);
|
|
17
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) fail('Choose an ordinary project directory, not a symbolic link.');
|
|
18
|
+
return realpath(requested);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Every segment is checked before reading or creating; external symlinks are never followed. */
|
|
22
|
+
export async function inspect(root, relative, directory = false) {
|
|
23
|
+
if (typeof relative !== 'string' || !relative || relative.includes('\\') || path.isAbsolute(relative) || relative.split('/').some(part => !part || part === '.' || part === '..')) fail('Unsafe generated path.');
|
|
24
|
+
let target = root;
|
|
25
|
+
const segments = relative.split('/');
|
|
26
|
+
for (let index = 0; index < segments.length; index++) {
|
|
27
|
+
target = path.join(target, segments[index]);
|
|
28
|
+
let stat;
|
|
29
|
+
try { stat = await lstat(target); }
|
|
30
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
31
|
+
if (stat.isSymbolicLink() || await realpath(target) !== target) fail('A setup path is a symbolic link. Use ordinary project files.');
|
|
32
|
+
const shouldBeDirectory = index < segments.length - 1 || directory;
|
|
33
|
+
if (shouldBeDirectory ? !stat.isDirectory() : !stat.isFile()) fail('A setup path has an incompatible file type.');
|
|
34
|
+
if (index === segments.length - 1) return { target, stat };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function readProjectFile(root, relative) {
|
|
39
|
+
const found = await inspect(root, relative);
|
|
40
|
+
if (!found) return null;
|
|
41
|
+
if (found.stat.size > maxFileBytes) fail('A setup file exceeds the read limit.');
|
|
42
|
+
const handle = await open(found.target, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
43
|
+
try {
|
|
44
|
+
const stat = await handle.stat();
|
|
45
|
+
if (!stat.isFile() || stat.ino !== found.stat.ino || stat.dev !== found.stat.dev || stat.size > maxFileBytes) fail('A project file changed during inspection. Retry setup.');
|
|
46
|
+
const buffer = Buffer.alloc(maxFileBytes + 1);
|
|
47
|
+
let bytes = 0;
|
|
48
|
+
while (bytes < buffer.length) {
|
|
49
|
+
const result = await handle.read(buffer, bytes, buffer.length - bytes, bytes);
|
|
50
|
+
if (!result.bytesRead) break;
|
|
51
|
+
bytes += result.bytesRead;
|
|
52
|
+
}
|
|
53
|
+
if (bytes > maxFileBytes) fail('A setup file exceeds the read limit.');
|
|
54
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, bytes));
|
|
55
|
+
} finally { await handle.close(); }
|
|
56
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { Inventory } from './react-publication.mjs';
|
|
2
|
+
export function bindingTextFingerprint(text: string): string;
|
|
3
|
+
export function publicBindingInventory(inventory: unknown): Inventory & { publicSource: 1; fingerprints: { title: string; excerpt: string; blocks: Record<string,string> } };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Public comparison metadata for proved literal React source bindings.
|
|
2
|
+
* These fingerprints select rendering behavior; they confer no authorization. */
|
|
3
|
+
const constants = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
|
|
4
|
+
const rotate=(x,n)=>(x>>>n)|(x<<(32-n));
|
|
5
|
+
export function bindingTextFingerprint(text) {
|
|
6
|
+
if(typeof text!=='string')throw Error('Invalid public binding text');
|
|
7
|
+
const bytes=new TextEncoder().encode(text);
|
|
8
|
+
if(bytes.length>2*1024*1024)throw Error('Public binding text exceeds its budget');
|
|
9
|
+
const buffer=new Uint8Array(Math.ceil((bytes.length+9)/64)*64);buffer.set(bytes);buffer[bytes.length]=0x80;
|
|
10
|
+
const view=new DataView(buffer.buffer);view.setUint32(buffer.length-4,bytes.length*8);
|
|
11
|
+
const hash=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19],words=new Uint32Array(64);
|
|
12
|
+
for(let offset=0;offset<buffer.length;offset+=64){
|
|
13
|
+
for(let i=0;i<16;i++)words[i]=view.getUint32(offset+i*4);
|
|
14
|
+
for(let i=16;i<64;i++){const x=words[i-15],y=words[i-2];words[i]=((rotate(x,7)^rotate(x,18)^(x>>>3))+words[i-16]+(rotate(y,17)^rotate(y,19)^(y>>>10))+words[i-7])>>>0;}
|
|
15
|
+
let [a,b,c,d,e,f,g,h]=hash;
|
|
16
|
+
for(let i=0;i<64;i++){const t1=(h+(rotate(e,6)^rotate(e,11)^rotate(e,25))+((e&f)^(~e&g))+constants[i]+words[i])>>>0,t2=((rotate(a,2)^rotate(a,13)^rotate(a,22))+((a&b)^(a&c)^(b&c)))>>>0;h=g;g=f;f=e;e=(d+t1)>>>0;d=c;c=b;b=a;a=(t1+t2)>>>0;}
|
|
17
|
+
for(const [i,value]of [a,b,c,d,e,f,g,h].entries())hash[i]=(hash[i]+value)>>>0;
|
|
18
|
+
}
|
|
19
|
+
return hash.map(word=>word.toString(16).padStart(8,'0')).join('');
|
|
20
|
+
}
|
|
21
|
+
export function publicBindingInventory(inventory) {
|
|
22
|
+
if(!inventory?.identity||!inventory.baseline||!Array.isArray(inventory.baseline.blocks)||!inventory.bindings)throw Error('Literal public bindings need reviewed inventory');
|
|
23
|
+
const baseline=inventory.baseline,blocks=baseline.blocks;
|
|
24
|
+
if(blocks.length>1000||blocks.some(block=>!['heading','paragraph','list','quote','code'].includes(block.type)||typeof block.text!=='string'||block.data&&Object.keys(block.data).some(name=>name!=='level'))||Object.values(inventory.bindings).some(slot=>!Array.isArray(slot.variants)||slot.variants.length!==1||slot.variants[0].values.length))throw Error('Data-driven or structured public bindings need explicit source review');
|
|
25
|
+
const known=new Set(blocks.map(block=>block.id));
|
|
26
|
+
const bindings=Object.fromEntries(Object.entries(inventory.bindings).map(([slot,value])=>{if(!known.has(value.variants[0].id))throw Error('Unknown public binding block');return [slot,{variants:[{values:[],id:value.variants[0].id}]}];}));
|
|
27
|
+
return {publicSource:1,identity:{kind:inventory.identity.kind,slug:inventory.identity.slug,locale:inventory.identity.locale},baseline:{schemaVersion:1,title:'',excerpt:'',blocks:blocks.map(block=>({id:block.id,version:block.version,type:block.type,text:'',...(block.type==='heading'&&block.data?.level?{data:{level:block.data.level}}:{})}))},titleBlockId:inventory.titleBlockId,excerptBlockId:inventory.excerptBlockId??blocks.find(block=>block.type==='paragraph')?.id,bindings,fingerprints:{title:bindingTextFingerprint(baseline.title),excerpt:bindingTextFingerprint(baseline.excerpt),blocks:Object.fromEntries(blocks.map(block=>[block.id,bindingTextFingerprint(block.text)]))}};
|
|
28
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const PUBLICATION_STATUS_PATH: '/api/universalcms/publication';
|
|
2
|
+
export const PUBLICATION_DOCUMENT_PATH: '/api/universalcms/document';
|
|
3
|
+
export const PUBLICATION_SCRIPT_PATH: '/.well-known/universalcms-publication-client.js';
|
|
4
|
+
/** Server-side generator. Output includes only reviewed public paths. */
|
|
5
|
+
export function createPublicationBrowserScript(configuration: unknown): string;
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { validatePublicationConfig } from './publication.mjs';
|
|
2
|
+
|
|
3
|
+
export const PUBLICATION_STATUS_PATH = '/api/universalcms/publication';
|
|
4
|
+
export const PUBLICATION_DOCUMENT_PATH = '/api/universalcms/document';
|
|
5
|
+
export const PUBLICATION_SCRIPT_PATH = '/.well-known/universalcms-publication-client.js';
|
|
6
|
+
|
|
7
|
+
/** This function is serialized, not imported into the customer's application. */
|
|
8
|
+
function publicationBrowser(paths, endpoint) {
|
|
9
|
+
'use strict';
|
|
10
|
+
const managed = new Set(paths);
|
|
11
|
+
const nativeFetch = window.fetch.bind(window);
|
|
12
|
+
const assign = window.location.assign.bind(window.location);
|
|
13
|
+
const reload = window.location.reload.bind(window.location);
|
|
14
|
+
let lastUrl = window.location.href, leaving = false, pending = null, baseline = null;
|
|
15
|
+
const validRevision = value => typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:\d{1,16}(?::[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})?$/i.test(value);
|
|
16
|
+
function canonical(pathname) {
|
|
17
|
+
try {
|
|
18
|
+
const value = decodeURIComponent(pathname);
|
|
19
|
+
if (/[\\\u0000-\u0020\u007f?#%]/.test(value) || value.includes('//') || value.split('/').some(part => part === '.' || part === '..')) return null;
|
|
20
|
+
const path = value.length > 1 ? value.replace(/\/$/, '') : value;
|
|
21
|
+
return path === '/index.html' && managed.has('/') ? '/' : path;
|
|
22
|
+
} catch { return null; }
|
|
23
|
+
}
|
|
24
|
+
const owns = pathname => managed.has(canonical(pathname));
|
|
25
|
+
async function smallJson(response, signal) {
|
|
26
|
+
if (response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase() !== 'application/json' ||
|
|
27
|
+
Number(response.headers.get('content-length') ?? 0) > 512 || !response.body) throw Error('Unavailable');
|
|
28
|
+
const reader = response.body.getReader(), decoder = new TextDecoder('utf-8', { fatal: true });
|
|
29
|
+
let text = '', bytes = 0;
|
|
30
|
+
try {
|
|
31
|
+
for (;;) {
|
|
32
|
+
const next = await new Promise((resolve, reject) => {
|
|
33
|
+
const abort = () => reject(Error('Unavailable'));
|
|
34
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
35
|
+
if (signal.aborted) abort();
|
|
36
|
+
reader.read().then(value => { signal.removeEventListener('abort', abort); resolve(value); }, error => { signal.removeEventListener('abort', abort); reject(error); });
|
|
37
|
+
});
|
|
38
|
+
if (next.done) break;
|
|
39
|
+
if ((bytes += next.value.byteLength) > 512) throw Error('Unavailable');
|
|
40
|
+
text += decoder.decode(next.value, { stream: true });
|
|
41
|
+
}
|
|
42
|
+
return JSON.parse(text + decoder.decode());
|
|
43
|
+
} finally { void reader.cancel().catch(() => {}); reader.releaseLock(); }
|
|
44
|
+
}
|
|
45
|
+
const publishedStatus = value => value && value.state === 'published' && Object.keys(value).every(key => key === 'state' || key === 'revision') && (!Object.hasOwn(value, 'revision') || validRevision(value.revision));
|
|
46
|
+
|
|
47
|
+
// Managed navigation uses the same publication authority as original URLs.
|
|
48
|
+
// Keep native anchor nodes, handlers and accessibility attributes intact.
|
|
49
|
+
let refreshLinks = () => {};
|
|
50
|
+
function installManagedLinks() {
|
|
51
|
+
if (typeof document.querySelectorAll !== 'function') return;
|
|
52
|
+
// A blocking bootstrap can run before React hydrates server-rendered links.
|
|
53
|
+
// CSSOM rules change visibility without changing that server DOM (including
|
|
54
|
+
// head children), so hydration keeps the original anchors and their handlers.
|
|
55
|
+
let sheet;
|
|
56
|
+
try {
|
|
57
|
+
sheet = new CSSStyleSheet();
|
|
58
|
+
document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
|
|
59
|
+
} catch { leaving = true; replaceDocument(503); return; }
|
|
60
|
+
const cache = new Map(), inFlight = new Map();
|
|
61
|
+
let active = 0, scheduled = false, latest = new Map(), overflow = new Set(), epoch = 0;
|
|
62
|
+
function destination(link) {
|
|
63
|
+
try { const url = new URL(link.getAttribute('href'), window.location.href); return url.origin === window.location.origin && owns(url.pathname) ? canonical(url.pathname) : null; }
|
|
64
|
+
catch { return null; }
|
|
65
|
+
}
|
|
66
|
+
function updateVisibility() {
|
|
67
|
+
if (leaving) return;
|
|
68
|
+
const hidden = new Set(overflow);
|
|
69
|
+
for (const [path, hrefs] of latest) {
|
|
70
|
+
const known = cache.get(path);
|
|
71
|
+
if (known?.published && known.until > Date.now()) continue;
|
|
72
|
+
for (const href of hrefs) hidden.add(href);
|
|
73
|
+
}
|
|
74
|
+
// Escape every code point as a CSS string token. A literal href may contain
|
|
75
|
+
// quotes, backslashes or CSS-looking query text; none can create a rule.
|
|
76
|
+
const quoted = value => '"' + Array.from(value, character => '\\' + character.codePointAt(0).toString(16) + ' ').join('') + '"';
|
|
77
|
+
try { sheet.replaceSync(Array.from(hidden, href => 'a[href=' + quoted(href) + ']{display:none!important}').join('\n')); }
|
|
78
|
+
catch { leaving = true; replaceDocument(503); }
|
|
79
|
+
}
|
|
80
|
+
function scan(force = false) {
|
|
81
|
+
scheduled = false;
|
|
82
|
+
if (leaving || document.visibilityState === 'hidden') return;
|
|
83
|
+
if (force) { cache.clear(); epoch++; for (const controller of inFlight.values()) controller.abort(); }
|
|
84
|
+
latest = new Map(); overflow = new Set();
|
|
85
|
+
let count = 0;
|
|
86
|
+
for (const link of document.querySelectorAll('a[href]')) {
|
|
87
|
+
const path = destination(link);
|
|
88
|
+
if (!path) continue;
|
|
89
|
+
const href = link.getAttribute('href');
|
|
90
|
+
// Identical hrefs share a selector. Conservatively keep their whole group
|
|
91
|
+
// hidden when any member exceeds the scan budget, without marking nodes.
|
|
92
|
+
if (++count > 200) { overflow.add(href); continue; }
|
|
93
|
+
if (!latest.has(path)) latest.set(path, new Set());
|
|
94
|
+
latest.get(path).add(href);
|
|
95
|
+
}
|
|
96
|
+
updateVisibility();
|
|
97
|
+
pump();
|
|
98
|
+
}
|
|
99
|
+
function pump() {
|
|
100
|
+
if (leaving) { for (const controller of inFlight.values()) controller.abort(); return; }
|
|
101
|
+
if (document.visibilityState === 'hidden') return;
|
|
102
|
+
for (const [path] of latest) {
|
|
103
|
+
if (active >= 4) break;
|
|
104
|
+
if (inFlight.has(path) || (cache.get(path)?.until ?? 0) > Date.now()) continue;
|
|
105
|
+
active++;
|
|
106
|
+
const controller = new AbortController(), started = epoch; inFlight.set(path, controller);
|
|
107
|
+
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
108
|
+
void (async () => {
|
|
109
|
+
let published = false;
|
|
110
|
+
try {
|
|
111
|
+
const response = await nativeFetch(endpoint + '?path=' + encodeURIComponent(path), { credentials: 'omit', cache: 'no-store', redirect: 'error', signal: controller.signal, headers: { Accept: 'application/json' } });
|
|
112
|
+
if (response.status === 200) published = publishedStatus(await smallJson(response, controller.signal));
|
|
113
|
+
else void response.body?.cancel().catch(() => {});
|
|
114
|
+
} catch { /* Missing, failed and malformed responses keep links hidden. */ }
|
|
115
|
+
finally {
|
|
116
|
+
clearTimeout(timeout); active--; inFlight.delete(path);
|
|
117
|
+
if (started === epoch) {
|
|
118
|
+
cache.set(path, { until: Date.now() + 15000, published });
|
|
119
|
+
updateVisibility();
|
|
120
|
+
}
|
|
121
|
+
pump();
|
|
122
|
+
}
|
|
123
|
+
})();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
refreshLinks = force => scan(force);
|
|
127
|
+
if (typeof MutationObserver === 'function') new MutationObserver(() => {
|
|
128
|
+
if (!scheduled && !leaving) { scheduled = true; setTimeout(() => scan(), 0); }
|
|
129
|
+
}).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['href'] });
|
|
130
|
+
scan();
|
|
131
|
+
}
|
|
132
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', installManagedLinks, { once: true });
|
|
133
|
+
else installManagedLinks();
|
|
134
|
+
function renderedRevision(pathname) {
|
|
135
|
+
// The binding provider writes this only after rendering that publication.
|
|
136
|
+
// It is read lazily because this blocking script runs before the page body.
|
|
137
|
+
const meta = document.querySelector('meta[name="universalcms-publication-revision"]');
|
|
138
|
+
return meta && canonical(meta.getAttribute('data-path')) === pathname && validRevision(meta.content) ? meta.content : null;
|
|
139
|
+
}
|
|
140
|
+
function replaceDocument(status) {
|
|
141
|
+
const missing = status === 404;
|
|
142
|
+
// Replacing the document removes native content, portals and framework event
|
|
143
|
+
// listeners. A CSS overlay alone would leave the old document accessible.
|
|
144
|
+
window.stop();
|
|
145
|
+
document.open();
|
|
146
|
+
document.write('<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>' + (missing ? '404 — Page not found' : status === 0 ? 'Loading page' : 'Content unavailable') + '</title></head><body><main style="max-width:42rem;margin:12vh auto;padding:2rem;font-family:system-ui,sans-serif"><h1>' + (missing ? '404 — Page not found' : status === 0 ? 'Loading page…' : 'Content unavailable') + '</h1><p>' + (missing ? 'This page is not published or does not exist.' : status === 0 ? 'Checking the current publication.' : 'Please try again shortly.') + '</p><a href="/">Home</a></main></body></html>');
|
|
147
|
+
document.close();
|
|
148
|
+
}
|
|
149
|
+
function navigate(destination, replace) {
|
|
150
|
+
if (leaving) return;
|
|
151
|
+
leaving = true;
|
|
152
|
+
if (pending) pending.abort();
|
|
153
|
+
replaceDocument(0);
|
|
154
|
+
if (replace) window.location.replace(destination); else assign(destination);
|
|
155
|
+
}
|
|
156
|
+
async function recheck() {
|
|
157
|
+
if (leaving || pending || document.visibilityState === 'hidden' || !owns(window.location.pathname)) return;
|
|
158
|
+
const url = window.location.href, controller = new AbortController();
|
|
159
|
+
pending = controller;
|
|
160
|
+
const deadline = setTimeout(() => controller.abort(), 10000);
|
|
161
|
+
try {
|
|
162
|
+
const response = await nativeFetch(endpoint + '?path=' + encodeURIComponent(canonical(window.location.pathname)), { credentials: 'omit', cache: 'no-store', redirect: 'error', signal: controller.signal, headers: { Accept: 'application/json', 'X-UniversalCMS-Include-Revision': '1' } });
|
|
163
|
+
// The endpoint has a tiny fixed envelope. Read text only within the same
|
|
164
|
+
// deadline; malformed/non-JSON/redirected responses cannot authorize HTML.
|
|
165
|
+
const result = await smallJson(response, controller.signal);
|
|
166
|
+
if (url !== window.location.href || leaving) return;
|
|
167
|
+
if (response.status === 200 && publishedStatus(result)) {
|
|
168
|
+
if (result.revision) {
|
|
169
|
+
const path = canonical(window.location.pathname), before = renderedRevision(path) ?? baseline;
|
|
170
|
+
if (before !== null && before !== result.revision) {
|
|
171
|
+
// A native React provider may update props in place to preserve
|
|
172
|
+
// cart/form/filter state. It must own errors and update the meta only
|
|
173
|
+
// after the new document is rendered. Unhandled changes reload once.
|
|
174
|
+
const event = new CustomEvent('universalcms:publication-change', { detail: { path, revision: result.revision }, cancelable: true });
|
|
175
|
+
if (window.dispatchEvent(event)) { leaving = true; replaceDocument(0); reload(); }
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
baseline = result.revision;
|
|
179
|
+
}
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
leaving = true;
|
|
183
|
+
replaceDocument(response.status === 404 && result?.state === 'missing' ? 404 : 503);
|
|
184
|
+
} catch {
|
|
185
|
+
if (url === window.location.href && !leaving) { leaving = true; replaceDocument(503); }
|
|
186
|
+
} finally {
|
|
187
|
+
clearTimeout(deadline);
|
|
188
|
+
if (pending === controller) pending = null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// Intercept before React/Next registers its router. A managed transition must
|
|
192
|
+
// pass the server boundary, including a previously prefetched/cached route.
|
|
193
|
+
document.addEventListener('click', event => {
|
|
194
|
+
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return;
|
|
195
|
+
const link = event.target instanceof Element ? event.target.closest('a[href]') : null;
|
|
196
|
+
if (!link || link.hasAttribute('download') || link.target && link.target !== '_self') return;
|
|
197
|
+
let destination;
|
|
198
|
+
try { destination = new URL(link.href, window.location.href); } catch { return; }
|
|
199
|
+
if (destination.origin !== window.location.origin || (!owns(destination.pathname) && !owns(window.location.pathname))) return;
|
|
200
|
+
if (destination.pathname === window.location.pathname && destination.search === window.location.search && destination.hash) return;
|
|
201
|
+
event.preventDefault(); event.stopImmediatePropagation();
|
|
202
|
+
navigate(destination.href, false);
|
|
203
|
+
}, true);
|
|
204
|
+
for (const method of ['pushState', 'replaceState']) {
|
|
205
|
+
const original = window.history[method].bind(window.history);
|
|
206
|
+
window.history[method] = function (state, unused, value) {
|
|
207
|
+
if (value === undefined || value === null) return original(state, unused, value);
|
|
208
|
+
const destination = new URL(value, window.location.href);
|
|
209
|
+
if (destination.origin === window.location.origin && (destination.pathname !== window.location.pathname || destination.search !== window.location.search) && (owns(destination.pathname) || owns(window.location.pathname))) {
|
|
210
|
+
navigate(destination.href, method === 'replaceState');
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const result = original(state, unused, value);
|
|
214
|
+
lastUrl = window.location.href;
|
|
215
|
+
return result;
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
window.addEventListener('popstate', event => {
|
|
219
|
+
if (window.location.href !== lastUrl && (owns(window.location.pathname) || owns(new URL(lastUrl).pathname))) {
|
|
220
|
+
event.stopImmediatePropagation();
|
|
221
|
+
if (!leaving) { leaving = true; replaceDocument(0); reload(); }
|
|
222
|
+
}
|
|
223
|
+
}, true);
|
|
224
|
+
// A BFCache snapshot must not retain the old page for a later restoration.
|
|
225
|
+
window.addEventListener('pagehide', event => {
|
|
226
|
+
if (event.persisted && owns(window.location.pathname)) {
|
|
227
|
+
if (pending) pending.abort();
|
|
228
|
+
document.body?.replaceChildren();
|
|
229
|
+
}
|
|
230
|
+
}, true);
|
|
231
|
+
window.addEventListener('pageshow', event => {
|
|
232
|
+
if (event.persisted && owns(window.location.pathname)) { leaving = true; replaceDocument(0); reload(); }
|
|
233
|
+
}, true);
|
|
234
|
+
window.addEventListener('focus', () => { void recheck(); refreshLinks(true); });
|
|
235
|
+
// A binding reader may fail after the availability check succeeded. Remove
|
|
236
|
+
// the entire application, including portals outside that reader's subtree.
|
|
237
|
+
window.addEventListener('universalcms:document-unavailable', event => {
|
|
238
|
+
if (leaving || !owns(window.location.pathname) || canonical(event.detail?.path) !== canonical(window.location.pathname)) return;
|
|
239
|
+
leaving = true;
|
|
240
|
+
if (pending) pending.abort();
|
|
241
|
+
replaceDocument(event.detail?.status === 404 ? 404 : 503);
|
|
242
|
+
});
|
|
243
|
+
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { void recheck(); refreshLinks(true); } });
|
|
244
|
+
setInterval(() => { void recheck(); refreshLinks(false); }, 15000);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Only public paths are serialized; scope and server configuration stay private. */
|
|
248
|
+
export function createPublicationBrowserScript(configuration) {
|
|
249
|
+
const config = validatePublicationConfig(configuration);
|
|
250
|
+
const paths = JSON.stringify(config.routes.map(route => route.path)).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
|
|
251
|
+
return `;(${publicationBrowser.toString()})(${paths},${JSON.stringify(PUBLICATION_STATUS_PATH)});\n`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Native PHP hosts supply only their reviewed public paths on a blocking script
|
|
255
|
+
* element. The guard itself remains the same tested publication client. */
|
|
256
|
+
export function createNativePublicationBrowserScript() {
|
|
257
|
+
return `;(function(){'use strict';let paths;try{paths=JSON.parse(document.currentScript.dataset.universalcmsPaths);if(!Array.isArray(paths)||paths.length<1||paths.length>1001||paths.some(path=>typeof path!=='string'||path.length>500||!path.startsWith('/')||/[\\\\\\u0000-\\u0020\\u007f?#%]/.test(path)||path.includes('//')||path.split('/').some(part=>part==='.'||part==='..')))throw Error();}catch{document.documentElement.style.visibility='hidden';document.addEventListener('DOMContentLoaded',()=>{document.body.replaceChildren();const message=document.createElement('p');message.textContent='Website content is temporarily unavailable.';document.body.append(message);document.documentElement.style.visibility='';},{once:true});return;}(${publicationBrowser.toString()})(paths,${JSON.stringify(PUBLICATION_STATUS_PATH)});})();\n`;
|
|
258
|
+
}
|