@universalcms/connect 0.0.0-stage → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +779 -2
- package/dist/angular-runtime.d.mts +4 -0
- package/dist/angular-runtime.mjs +7 -0
- package/dist/angular-setup.mjs +124 -0
- package/dist/astro-runtime.d.mts +6 -0
- package/dist/astro-runtime.mjs +255 -0
- package/dist/astro-setup.mjs +215 -0
- package/dist/astro-source.mjs +451 -0
- package/dist/astro-website.d.mts +5 -0
- package/dist/astro-website.mjs +42 -0
- package/dist/cli.mjs +552 -0
- package/dist/connector-runtime.mjs +130 -0
- package/dist/content-next-setup.mjs +200 -0
- package/dist/content-registry-validation.mjs +40 -0
- package/dist/content-registry.mjs +44 -0
- package/dist/content-setup.mjs +13 -0
- package/dist/content-storefront-setup.mjs +155 -0
- package/dist/content-vite-setup.mjs +107 -0
- package/dist/core.mjs +205 -0
- package/dist/framework-ownership.mjs +68 -0
- package/dist/framework.mjs +54 -0
- package/dist/guided.mjs +335 -0
- package/dist/native-adapters.mjs +31 -0
- package/dist/native-chrome-model.d.mts +3 -0
- package/dist/native-chrome-model.mjs +14 -0
- package/dist/native-chrome-review.mjs +49 -0
- package/dist/native-chrome-source.mjs +69 -0
- package/dist/native-route-review.mjs +152 -0
- package/dist/native-routes.mjs +27 -0
- package/dist/native-website-model.d.mts +18 -0
- package/dist/native-website-model.mjs +67 -0
- package/dist/native-website-server.d.mts +1 -0
- package/dist/native-website-server.mjs +35222 -0
- package/dist/native-website-server.mjs.LEGAL.txt +56 -0
- package/dist/native-website.d.mts +3 -0
- package/dist/native-website.mjs +120 -0
- package/dist/native-website.mjs.LEGAL.txt +56 -0
- package/dist/next-publication.d.mts +12 -0
- package/dist/next-publication.mjs +107 -0
- package/dist/next-settings-layout.mjs +233 -0
- package/dist/next-source.d.mts +8 -0
- package/dist/next-source.mjs +93 -0
- package/dist/page-discovery.mjs +236 -0
- package/dist/page-mapping-review.mjs +160 -0
- package/dist/preflight-recovery.mjs +122 -0
- package/dist/preflight.mjs +138 -0
- package/dist/project-files.mjs +56 -0
- package/dist/public-bindings.d.mts +3 -0
- package/dist/public-bindings.mjs +28 -0
- package/dist/publication-browser.d.mts +5 -0
- package/dist/publication-browser.mjs +258 -0
- package/dist/publication-check.mjs +141 -0
- package/dist/publication-document.d.mts +2 -0
- package/dist/publication-document.mjs +59 -0
- package/dist/publication-next-setup.mjs +134 -0
- package/dist/publication-setup.mjs +138 -0
- package/dist/publication-verification.mjs +85 -0
- package/dist/publication.css +6 -0
- package/dist/publication.d.mts +31 -0
- package/dist/publication.mjs +320 -0
- package/dist/react-publication.d.mts +22 -0
- package/dist/react-publication.mjs +301 -0
- package/dist/react-website.d.mts +15 -0
- package/dist/react-website.mjs +735 -0
- package/dist/reviewed-commerce-ownership.mjs +26 -0
- package/dist/reviewed-commerce-settings-setup.mjs +243 -0
- package/dist/reviewed-native-ownership.mjs +118 -0
- package/dist/reviewed-storefront-source.mjs +227 -0
- package/dist/runtime-dependencies.mjs +59 -0
- package/dist/setup-environment.mjs +307 -0
- package/dist/source-bindings.mjs +77 -0
- package/dist/source-check.mjs +149 -0
- package/dist/source-collect.mjs +80 -0
- package/dist/source-config.mjs +20 -0
- package/dist/source-extract.mjs +514 -0
- package/dist/source-finalize.mjs +52 -0
- package/dist/source-routes.mjs +65 -0
- package/dist/source-setup.mjs +490 -0
- package/dist/staged-setup.mjs +287 -0
- package/dist/static-runtime.mjs +67 -0
- package/dist/static-setup.mjs +136 -0
- package/dist/svelte-runtime.d.mts +27 -0
- package/dist/svelte-runtime.mjs +40 -0
- package/dist/svelte-setup.mjs +135 -0
- package/dist/svelte-template.mjs +48 -0
- package/dist/templates.mjs +248 -0
- package/dist/vendor/analyze-project.mjs +82 -0
- package/dist/vendor/astro-compiler/LICENSE +53 -0
- package/dist/vendor/astro-compiler/astro.wasm +0 -0
- package/dist/vendor/astro-compiler/chunk-W5DTLHV4.js +1 -0
- package/dist/vendor/astro-compiler/node/index.js +1 -0
- package/dist/vendor/check-delivery.mjs +158 -0
- package/dist/vendor/dependency-diagnostics.mjs +72 -0
- package/dist/vendor/inspect-installed-dependencies.mjs +125 -0
- package/dist/vendor/project-analysis.mjs +498 -0
- package/dist/vendor/react-LICENSE.txt +21 -0
- package/dist/vendor/react-document.mjs +124 -0
- package/dist/vendor/react-dom-LICENSE.txt +21 -0
- package/dist/vendor/react-form.mjs +63 -0
- package/dist/vendor/react-safety.mjs +47 -0
- package/dist/vendor/source-manifest-provider.mjs +104 -0
- package/dist/vendor/typescript-LICENSE.txt +55 -0
- package/dist/vendor/typescript.cjs +200276 -0
- package/dist/vite-handler.mjs +54 -0
- package/dist/vite-publication-server.d.mts +3 -0
- package/dist/vite-publication-server.mjs +82 -0
- package/dist/vite-publication.d.mts +15 -0
- package/dist/vite-publication.mjs +241 -0
- package/dist/vite-source-discovery.mjs +315 -0
- package/dist/vite-source.d.mts +30 -0
- package/dist/vite-source.mjs +141 -0
- package/dist/vue-runtime.d.mts +10 -0
- package/dist/vue-runtime.mjs +50 -0
- package/dist/vue-server.d.mts +2 -0
- package/dist/vue-server.mjs +58 -0
- package/dist/vue-setup.mjs +127 -0
- package/dist/vue-source.mjs +168 -0
- package/dist/website-check.mjs +41 -0
- package/dist/website-http.mjs +149 -0
- package/dist/website-model.d.mts +20 -0
- package/dist/website-model.mjs +155 -0
- package/dist/website-next.d.mts +2 -0
- package/dist/website-next.mjs +29 -0
- package/dist/website-node.d.mts +3 -0
- package/dist/website-node.mjs +50 -0
- package/dist/website-runtime.d.mts +13 -0
- package/dist/website-runtime.mjs +396 -0
- package/dist/website-schema.mjs +22175 -0
- package/dist/website-setup.mjs +256 -0
- package/dist/website.css +49 -0
- package/dist/wizard.mjs +298 -0
- package/package.json +60 -4
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { NativeRuntimeOptions, NativePublicContext } from './svelte-runtime.mjs';
|
|
2
|
+
export function createAngularRuntime(options: NativeRuntimeOptions): {
|
|
3
|
+
handle(request: Request, render: (context: NativePublicContext | null) => Promise<Response | null>): Promise<Response>;
|
|
4
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Angular SSR composition; current public projection is request-local. */
|
|
2
|
+
import {createAstroRuntime} from './astro-runtime.mjs';
|
|
3
|
+
import {nativePublicContext,renderNativeResponse} from './svelte-runtime.mjs';
|
|
4
|
+
export function createAngularRuntime(options){
|
|
5
|
+
const runtime=createAstroRuntime(options);
|
|
6
|
+
return {async handle(request,render){const context={request,locals:{}};return runtime.onRequest(context,async()=>{const cms=nativePublicContext(context.locals.universalcms);const response=await render(cms);if(!response)return new Response('Not found',{status:404,headers:{'Cache-Control':'no-store'}});return renderNativeResponse(response,cms,options.renderWebsite);});}};
|
|
7
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/** Bounded single native Angular root-page setup with an ordinary SSR build. */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import ts from './vendor/typescript.cjs';
|
|
4
|
+
import {projectRoot,SetupError} from './core.mjs';
|
|
5
|
+
import {contentDigest,readContentRegistry,readFrozenContent,validateContentRegistry,CONTENT_BINDINGS_FILE} from './content-registry.mjs';
|
|
6
|
+
import {validatePublicationConfig} from './publication.mjs';
|
|
7
|
+
import {WEBSITE_SECTIONS} from './website-http.mjs';
|
|
8
|
+
import {svelteRead,svelteVersion,inspectSvelteAssets,supportsNativeNode} from './svelte-setup.mjs';
|
|
9
|
+
import {transformNativeTemplate} from './svelte-template.mjs';
|
|
10
|
+
const json=value=>JSON.stringify(value,null,2)+'\n',fail=message=>{throw new SetupError(`Angular automatic setup needs review: ${message}. Existing files were preserved.`);};
|
|
11
|
+
export const ANGULAR_MANIFEST='.universalcms/angular.integration.json';
|
|
12
|
+
import {readNativeChromeReview,reviewedNativeChrome,nativeChromeObservation} from './native-chrome-review.mjs';
|
|
13
|
+
const inspections=new WeakMap();
|
|
14
|
+
export const ANGULAR_DEFAULT_SERVER="import {\n AngularNodeAppEngine,\n createNodeRequestHandler,\n isMainModule,\n writeResponseToNodeResponse,\n} from '@angular/ssr/node';\nimport express from 'express';\nimport { join } from 'node:path';\n\nconst browserDistFolder = join(import.meta.dirname, '../browser');\n\nconst app = express();\nconst angularApp = new AngularNodeAppEngine();\n\n/**\n * Example Express Rest API endpoints can be defined here.\n * Uncomment and define endpoints as necessary.\n *\n * Example:\n * ```ts\n * app.get('/api/{*splat}', (req, res) => {\n * // Handle API request\n * });\n * ```\n */\n\n/**\n * Serve static files from /browser\n */\napp.use(\n express.static(browserDistFolder, {\n maxAge: '1y',\n index: false,\n redirect: false,\n }),\n);\n\n/**\n * Handle all other requests by rendering the Angular application.\n */\napp.use((req, res, next) => {\n angularApp\n .handle(req)\n .then((response) =>\n response ? writeResponseToNodeResponse(response, res) : next(),\n )\n .catch(next);\n});\n\n/**\n * Start the server if this module is the main entry point, or it is ran via PM2.\n * The server listens on the port defined by the `PORT` environment variable, or defaults to 4000.\n */\nif (isMainModule(import.meta.url) || process.env['pm_id']) {\n const port = process.env['PORT'] || 4000;\n app.listen(port, (error) => {\n if (error) {\n throw error;\n }\n\n console.log(`Node Express server listening on http://localhost:${port}`);\n });\n}\n\n/**\n * Request handler used by the Angular CLI (for dev-server and during build) or Firebase Cloud Functions.\n */\nexport const reqHandler = createNodeRequestHandler(app);\n";
|
|
15
|
+
function serverTokens(source){const scanner=ts.createScanner(ts.ScriptTarget.Latest,true,ts.LanguageVariant.Standard,source);const tokens=[];for(let token=scanner.scan();token!==ts.SyntaxKind.EndOfFileToken;token=scanner.scan())tokens.push([token,token===ts.SyntaxKind.StringLiteral?scanner.getTokenValue():scanner.getTokenText()]);return JSON.stringify(tokens.filter((item,index)=>!(item[0]===ts.SyntaxKind.CommaToken&&[ts.SyntaxKind.CloseBraceToken,ts.SyntaxKind.CloseBracketToken,ts.SyntaxKind.CloseParenToken].includes(tokens[index+1]?.[0]))));}
|
|
16
|
+
|
|
17
|
+
function component(source,filename){const file=ts.createSourceFile(filename,source,ts.ScriptTarget.Latest,true,ts.ScriptKind.TS);if(file.parseDiagnostics.length)fail('component syntax');let klass,metadata;
|
|
18
|
+
for(const statement of file.statements){if(ts.isImportDeclaration(statement)){if(!ts.isStringLiteral(statement.moduleSpecifier)||statement.moduleSpecifier.text!=='@angular/core')fail('root component imports require explicit review');continue;}if(ts.isClassDeclaration(statement)){if(klass)fail('multiple component classes');klass=statement;for(const decorator of ts.getDecorators(statement)??[])if(ts.isCallExpression(decorator.expression)&&decorator.expression.expression.getText(file)==='Component'&&decorator.expression.arguments.length===1&&ts.isObjectLiteralExpression(decorator.expression.arguments[0]))metadata=decorator.expression.arguments[0];continue;}if(!ts.isEmptyStatement(statement))fail('executable module statements');}
|
|
19
|
+
if(!klass||!metadata||klass.members.some(member=>member.name?.getText(file)==='cms'))fail('custom component ownership');
|
|
20
|
+
const props=new Map();for(const member of metadata.properties){if(!ts.isPropertyAssignment(member)||!ts.isIdentifier(member.name)||props.has(member.name.text))fail('dynamic component metadata');props.set(member.name.text,member.initializer);}
|
|
21
|
+
if([...props.keys()].some(key=>!['selector','templateUrl','styleUrl','styleUrls','standalone','imports','changeDetection'].includes(key))||!ts.isStringLiteral(props.get('selector'))||props.get('selector').text!=='app-root'||!ts.isStringLiteral(props.get('templateUrl'))||!/^\.\/[a-z.-]+\.html$/.test(props.get('templateUrl').text))fail('literal root selector and external template required');
|
|
22
|
+
const imports=props.get('imports');if(imports&&(!ts.isArrayLiteralExpression(imports)||imports.elements.length))fail('custom child components or routes');
|
|
23
|
+
const metadataEnd=metadata.end-1,classStart=source.indexOf('{',klass.members.pos-1);if(classStart<0)fail('class source range');
|
|
24
|
+
let transformed=source.slice(0,classStart+1)+`\n readonly cms=__cmsInject(UniversalCms);\n`+source.slice(classStart+1);
|
|
25
|
+
transformed=transformed.slice(0,metadataEnd)+`${metadata.properties.hasTrailingComma?'':','} schemas:[CUSTOM_ELEMENTS_SCHEMA]`+transformed.slice(metadataEnd);
|
|
26
|
+
transformed=`import { inject as __cmsInject, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';\nimport { UniversalCms } from './universalcms';\n`+transformed;
|
|
27
|
+
return{className:klass.name?.text,template:path.posix.join(path.posix.dirname(filename),props.get('templateUrl').text),transformed};
|
|
28
|
+
}
|
|
29
|
+
function inspectBootstrap(source,fileName,rootClass,componentFile,server){
|
|
30
|
+
if(typeof source!=='string'||!rootClass)fail('missing native bootstrap');
|
|
31
|
+
const file=ts.createSourceFile(fileName,source,ts.ScriptTarget.Latest,true,ts.ScriptKind.TS),imports=new Map(),body=[];
|
|
32
|
+
if(file.parseDiagnostics.length)fail('native bootstrap syntax');
|
|
33
|
+
const expected=new Map([['bootstrapApplication','@angular/platform-browser'],[rootClass,'./app/'+path.posix.basename(componentFile,'.ts')],[server?'config':'appConfig',server?'./app/app.config.server':'./app/app.config'],...(server?[['BootstrapContext','@angular/platform-browser']]:[])]);
|
|
34
|
+
for(const statement of file.statements){if(ts.isImportDeclaration(statement)){const clause=statement.importClause;if(!ts.isStringLiteral(statement.moduleSpecifier)||clause?.name||!clause?.namedBindings||!ts.isNamedImports(clause.namedBindings))fail('custom native bootstrap imports');for(const item of clause.namedBindings.elements){if(item.propertyName||imports.has(item.name.text)||expected.get(item.name.text)!==statement.moduleSpecifier.text)fail('custom native bootstrap imports');imports.set(item.name.text,statement.moduleSpecifier.text);}}else body.push(statement.getText(file));}
|
|
35
|
+
if(imports.size!==expected.size)fail('native bootstrap does not match the inspected root');
|
|
36
|
+
const call=`bootstrapApplication(${rootClass},${server?'config,context':'appConfig'})`;
|
|
37
|
+
const variants=server?[`const bootstrap=(context:BootstrapContext)=>${call};export default bootstrap;`,`export default (context:BootstrapContext)=>${call};`]:[`${call};`,`${call}.catch((err)=>console.error(err));`];
|
|
38
|
+
if(!variants.some(text=>serverTokens(text)===serverTokens(body.join('\n'))))fail('custom native bootstrap code');
|
|
39
|
+
}
|
|
40
|
+
export async function inspectAngularProject(directory){if(!supportsNativeNode('angular',process.versions.node))fail('Node.js 22.22.3+, 24.15+ or 26+ is required before setup');const root=await projectRoot(directory),manifest=JSON.parse(await svelteRead(root,'package.json')??'null'),deps={...manifest?.devDependencies,...manifest?.dependencies};
|
|
41
|
+
for(const name of ['@angular/core','@angular/common','@angular/compiler','@angular/compiler-cli','@angular/platform-browser','@angular/platform-server','@angular/router','@angular/ssr','@angular/build','@angular/cli']){if(!svelteVersion(deps[name],22,2,3))fail(`requires declared ${name} 22.2+`);const installed=JSON.parse(await svelteRead(root,`node_modules/${name}/package.json`)??'null');if(installed?.name!==name||!svelteVersion(installed.version,22,2,3))fail(`install compatible ${name}`);}
|
|
42
|
+
for(const [name,major,minor,patch]of [['express',5,1,0],['@types/express',5,0,1]]){const installed=JSON.parse(await svelteRead(root,`node_modules/${name}/package.json`)??'null');if(!svelteVersion(deps[name],major,minor,patch)||installed?.name!==name||!svelteVersion(installed.version,major,minor,patch))fail(`install compatible ${name}`);}
|
|
43
|
+
const typescript=JSON.parse(await svelteRead(root,'node_modules/typescript/package.json')??'null');if(!svelteVersion(deps.typescript,6,0,2)||!svelteVersion(typescript?.version,6,0,2)||!/^6\.0\./.test(typescript.version))fail('install compatible TypeScript 6.0.2');
|
|
44
|
+
if(manifest?.workspaces||['next','nuxt','astro','vue','svelte'].some(name=>deps[name]))fail('competing framework or workspace');
|
|
45
|
+
const angularText=await svelteRead(root,'angular.json'),angular=JSON.parse(angularText??'null');if(!angular||Object.keys(angular.projects??{}).length!==1)fail('one Angular application required');const [projectName,project]=Object.entries(angular.projects)[0],build=project.architect?.build;
|
|
46
|
+
if(project.projectType!=='application'||project.root!==''||project.sourceRoot!=='src'||build?.builder!=='@angular/build:application'||build.options?.browser!=='src/main.ts'||build.options?.server!=='src/main.server.ts'||build.options?.ssr?.entry!=='src/server.ts'||build.options?.outputMode==='static')fail('ordinary Angular Node SSR application required');
|
|
47
|
+
if(Object.keys(build.options).some(key=>!['browser','server','ssr','outputMode','polyfills','tsConfig','assets','styles','index','outputPath','inlineStyleLanguage'].includes(key))||Object.keys(build.options.ssr).some(key=>key!=='entry')||build.options.assets?.some(item=>typeof item!=='object'||item.glob!=='**/*'||!['public','public/'].includes(item.input)||item.output&&item.output!=='/'))fail('custom SSR build, asset or security options');
|
|
48
|
+
for(const configuration of Object.values(build.configurations??{}))if(Object.keys(configuration).some(key=>!['budgets','outputHashing','optimization','extractLicenses','sourceMap','namedChunks'].includes(key)))fail('custom build configurations');
|
|
49
|
+
const priorText=await svelteRead(root,ANGULAR_MANIFEST),prior=priorText?JSON.parse(priorText):null;let appFile;
|
|
50
|
+
for(const name of ['src/app/app.ts','src/app/app.component.ts'])if(await svelteRead(root,name)!==null){if(appFile)fail('multiple root components');appFile=name;}if(!appFile)fail('missing root component');
|
|
51
|
+
const current=await svelteRead(root,appFile),original=prior?.originalComponent??current,review=component(original,appFile);if(prior&&(prior.componentFile!==appFile||prior.componentHash!==contentDigest(original)||current!==review.transformed))fail('root component changed');
|
|
52
|
+
const routeSource=await svelteRead(root,'src/app/app.routes.ts');if(routeSource!==null&&!/^\s*import\s*\{\s*(?:type\s+)?Routes\s*\}\s*from\s*['"]@angular\/router['"];?\s*export\s+const\s+routes\s*:\s*Routes\s*=\s*\[\s*\];?\s*$/.test(routeSource))fail('only a single literal native root page is currently automatic; routed applications need reviewed mappings');
|
|
53
|
+
const observations=[];for(const file of ['src/main.ts','src/main.server.ts']){const content=await svelteRead(root,file);inspectBootstrap(content,file,review.className,appFile,file.endsWith('.server.ts'));observations.push({path:file,content,state:'unchanged'});}
|
|
54
|
+
const shell=await svelteRead(root,'src/index.html');if(build.options.index&&build.options.index!=='src/index.html'||!shell||/<script\b|\son\w+\s*=/i.test(shell)||!/^\s*<app-root>\s*<\/app-root>\s*$/.test(/<body\b[^>]*>([\s\S]*?)<\/body>/i.exec(shell)?.[1]??''))fail('custom native document shell');observations.push({path:'src/index.html',content:shell,state:'unchanged'});
|
|
55
|
+
await inspectSvelteAssets(root,'public');return{root,manifest,angular,angularText,projectName,appFile,current,original,review,prior,priorText,observations};
|
|
56
|
+
}
|
|
57
|
+
export async function inspectAngularSource(directory,{locale='en',websiteOrigin}={}){const project=await inspectAngularProject(directory),{root}=project,sourceFile=project.review.template,current=await svelteRead(root,sourceFile),registry=await readContentRegistry(root);
|
|
58
|
+
if(registry?.profile||registry&®istry.routes.length!==1||registry?.routes[0].sourceFile!==undefined&®istry.routes[0].sourceFile!==sourceFile)fail('content ownership belongs to another adapter');
|
|
59
|
+
const chromeReview=await readNativeChromeReview(root,'angular');
|
|
60
|
+
const installed=registry?.routes[0],original=installed?(await readFrozenContent(root,installed)).texts.original:current;
|
|
61
|
+
const allowNativeChrome=reviewedNativeChrome(chromeReview,sourceFile,original);
|
|
62
|
+
let extracted;
|
|
63
|
+
try {
|
|
64
|
+
extracted=await transformNativeTemplate({source:original,slug:'home',locale,websiteOrigin,framework:'angular',allowNativeChrome});
|
|
65
|
+
} catch(error) {
|
|
66
|
+
if(error instanceof SetupError)fail('automatic content binding requires a supported literal Angular template; review dynamic components, controls and content ownership before setup');
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
extracted.source=`<universalcms-website [cms]="cms.payload">@if(cms.hasInventory){${extracted.source}}</universalcms-website>\n`;
|
|
70
|
+
if(installed&&(current!==extracted.source||installed.locale!==locale||installed.path!=='/'))fail('native page changed');
|
|
71
|
+
const routes=[{path:'/',kind:'page',slug:'home',locale}],entries=[extracted.entry],result={entries,routes,snapshot:{version:1,entries,inventories:{'/':extracted.inventory}},sourceFiles:[{path:sourceFile,sha256:contentDigest(current)},...(chromeReview?[{path:chromeReview.path,sha256:chromeReview.sha256}]:[])],diagnostics:{discoveredPages:1,automaticPages:1,skippedPages:0,unsupportedRoutes:0,warnings:[]}};
|
|
72
|
+
inspections.set(result,{root,chromeReview,sourceFile,current,original,installed,extracted,digest:contentDigest(json(result))});return result;
|
|
73
|
+
}
|
|
74
|
+
export async function planAngularContent(directory,configuration,inspection){const root=await projectRoot(directory),config=validatePublicationConfig(configuration);inspection??=await inspectAngularSource(root,{locale:config.routes[0]?.locale,websiteOrigin:config.websiteOrigin});const state=inspections.get(inspection);if(!state||state.root!==root||state.digest!==contentDigest(json(inspection))||json(config.routes)!==json(inspection.routes)||await svelteRead(root,state.sourceFile)!==state.current)fail('source/publication inspection changed');
|
|
75
|
+
const files=await nativeChromeObservation(root,state.chromeReview);let row=state.installed;
|
|
76
|
+
if(row){const frozen=await readFrozenContent(root,row);for(const name of ['source','native','original','inventory'])files.push({path:row[name+'File'],content:frozen.texts[name],state:'unchanged'});}
|
|
77
|
+
else {const token=contentDigest('/'+config.routes[0].locale).slice(0,20),originalFile=`.universalcms/content/${token}.original.txt`,nativeFile=`.universalcms/content/${token}.native.txt`,inventoryFile=`.universalcms/content/${token}.json`,source=state.extracted.source,inventory=json(state.extracted.inventory);for(const name of [originalFile,nativeFile,inventoryFile])if(await svelteRead(root,name)!==null)fail('frozen content collision');
|
|
78
|
+
row={...config.routes[0],sourceFile:state.sourceFile,originalFile,nativeFile,inventoryFile,sourceHash:contentDigest(source),nativeHash:contentDigest(source),originalHash:contentDigest(state.original),inventoryHash:contentDigest(inventory)};
|
|
79
|
+
files.push({path:originalFile,content:state.original,state:'create'},{path:nativeFile,content:source,state:'create'},{path:inventoryFile,content:inventory,state:'create'},{path:state.sourceFile,content:source,state:'update',previous:state.current});}
|
|
80
|
+
const registry=validateContentRegistry({version:1,routes:[row]}),text=json(registry),before=await svelteRead(root,CONTENT_BINDINGS_FILE);if(before!==null&&before!==text)fail('registry changed');files.push({path:CONTENT_BINDINGS_FILE,content:text,state:before===null?'create':'unchanged'});return{root,framework:'angular',files,routes:[row],entries:inspection.entries,snapshot:inspection.snapshot};
|
|
81
|
+
}
|
|
82
|
+
function service(){return `import { Injectable, inject, REQUEST_CONTEXT, TransferState, makeStateKey } from '@angular/core';\nimport { defineNativeWebsite } from '@universalcms/connect/native-website';\nconst key=makeStateKey<any>('universalcms-public-page');\n@Injectable({providedIn:'root'})\nexport class UniversalCms {\n private state=inject(TransferState);\n private context:any=inject(REQUEST_CONTEXT,{optional:true});\n private value:any=this.context??this.state.get(key,null);\n constructor(){if(this.context)this.state.set(key,this.context);if(typeof window!=='undefined')defineNativeWebsite();}\n get hasInventory(){return !!this.value?.publicInventory;}\n get payload(){const v=this.value;return v?{pathname:v.pathname,scope:v.scope,initialResult:v.result,initialSettings:v.settings,...(v.publicInventory?{inventory:v.publicInventory}:{})}:null;}\n binding(id:string):any{return this.value?.bindings?.[id]??null;}\n}\n`;}
|
|
83
|
+
export async function planAngularIntegration(directory,{publication=false,sourceConfiguration}={}){const project=await inspectAngularProject(directory),{root,prior}=project;if(prior?.publication)publication=true;if(!publication)fail('source-only automatic setup is not supported; review full publication setup');
|
|
84
|
+
const server=`import { AngularNodeAppEngine, createNodeRequestHandler, createWebRequestFromNodeRequest, writeResponseToNodeResponse, isMainModule } from '@angular/ssr/node';\nimport express from 'express';\nimport { dirname, resolve } from 'node:path';\nimport { readFileSync } from 'node:fs';\nimport { createAngularRuntime } from '@universalcms/connect/angular-runtime';\nimport { renderNativeWebsite } from '@universalcms/connect/native-website-server';\nconst readConfiguration=(name:string)=>JSON.parse(readFileSync(resolve(process.cwd(),'.universalcms',name),'utf8'));\nconst configuration=readConfiguration('publication.config.json');\nconst sourceConfiguration=readConfiguration('source.config.json');\nconst contentBindings=readConfiguration('content.bindings.json');\nconst snapshot=readConfiguration('source.generated.json');\nconst app=express();const engine=new AngularNodeAppEngine();\nconst expected=new URL(sourceConfiguration.websiteOrigin);const assets=express.static(resolve(import.meta.dirname,'../browser'),{index:false,redirect:false,dotfiles:'deny'});\napp.use((req,res,next)=>{const url=new URL(req.originalUrl,expected);if(req.headers.host!==expected.host||url.origin!==expected.origin){res.status(400).end();return;}if(/\\.(?:js|mjs|css|map|svg|png|jpe?g|gif|webp|avif|ico|woff2?|ttf|pdf|txt|webmanifest|mp[34]|webm|ogg)$/.test(url.pathname)&&!/[\\\\%]/.test(url.pathname)&&!url.pathname.split('/').some(part=>part.startsWith('.')||/^(?:api|admin|private|internal|auth|login)$/.test(part))&&!['/robots.txt','/sitemap.xml'].includes(url.pathname)){assets(req,res,next);return;}next();});\nconst runtime=createAngularRuntime({configuration,sourceConfiguration,contentBindings,snapshot,sourceRoot:process.cwd(),renderWebsite:renderNativeWebsite});\napp.use(async(req,res,next)=>{try{const request=createWebRequestFromNodeRequest(req);const response=await runtime.handle(request,(cms)=>engine.handle(cms?new Request(new URL('/',request.url),{method:request.method,headers:request.headers}):request,cms));await writeResponseToNodeResponse(response,res);}catch{res.status(503).send('Website unavailable');}});\nexport const reqHandler=createNodeRequestHandler(app);\nif(isMainModule(import.meta.url)||process.env['UNIVERSALCMS_ANGULAR_START']==='1'){const port=Number(new URL(sourceConfiguration.websiteOrigin).port||(sourceConfiguration.websiteOrigin.startsWith('https:')?443:80));app.listen(port,'0.0.0.0');}\n`;
|
|
85
|
+
// Original server is replaced only after its ordinary generated shape is
|
|
86
|
+
// checked. Its bytes remain in the ownership manifest for review/recovery.
|
|
87
|
+
const serverBefore=await svelteRead(root,'src/server.ts'),serverOriginal=prior?.originalServer??serverBefore;
|
|
88
|
+
if(typeof serverOriginal!=='string'||serverTokens(serverOriginal)!==serverTokens(ANGULAR_DEFAULT_SERVER))fail('custom server entrypoint');
|
|
89
|
+
const serverRoutes=`import { RenderMode, ServerRoute } from '@angular/ssr';\nexport const serverRoutes: ServerRoute[] = [{ path:'**', renderMode:RenderMode.Server }];\n`;
|
|
90
|
+
const routesBefore=await svelteRead(root,'src/app/app.routes.server.ts'),routesOriginal=prior?.originalServerRoutes??routesBefore;
|
|
91
|
+
if(!routesOriginal||!/^\s*import\s*\{[^}]*\bRenderMode\b[^}]*\}\s*from\s*['"]@angular\/ssr['"];?\s*export\s+const\s+serverRoutes\s*:\s*ServerRoute\[\]\s*=\s*\[\s*\{\s*path\s*:\s*['"]\*\*['"]\s*,\s*renderMode\s*:\s*RenderMode\.(?:Prerender|Server)\s*,?\s*\}\s*,?\s*\];?\s*$/.test(routesOriginal))fail('custom server route modes');
|
|
92
|
+
const generated=[[project.appFile,project.review.transformed],['src/app/universalcms.ts',service()],['src/server.ts',server],['src/app/app.routes.server.ts',serverRoutes]];
|
|
93
|
+
const dev='node .universalcms/angular.dev.mjs';
|
|
94
|
+
const scripts={...project.manifest.scripts};if(scripts.build!=='ng build'||scripts.start!=='ng serve'&&scripts.start!=='node .universalcms/angular.serve.mjs'||scripts.dev&&scripts.dev!=='ng serve'&&scripts.dev!==dev)fail('ordinary Angular scripts required');scripts.dev=dev;scripts.start='node .universalcms/angular.serve.mjs';
|
|
95
|
+
for(const name of ['predev','prebuild','prestart']){if(scripts[name]&&scripts[name]!=='universalcms-connect sync-source')fail('custom lifecycle hooks');scripts[name]='universalcms-connect sync-source';}
|
|
96
|
+
const output=typeof project.angular.projects[project.projectName].architect.build.options.outputPath==='string'?project.angular.projects[project.projectName].architect.build.options.outputPath:`dist/${project.projectName}`;
|
|
97
|
+
if(!/^dist\/[a-z0-9-]+$/.test(output))fail('custom build output path');
|
|
98
|
+
generated.push(['.universalcms/angular.dev.mjs',`import {fileURLToPath} from 'node:url';\nimport {spawnSync} from 'node:child_process';\nprocess.chdir(fileURLToPath(new URL('../',import.meta.url)));\nconst result=spawnSync(process.execPath,['node_modules/@angular/cli/bin/ng.js','build'],{stdio:'inherit',env:process.env});\nif(result.error)throw result.error;\nif(result.status!==0)process.exit(result.status??1);\nawait import('./angular.serve.mjs');\n`]);
|
|
99
|
+
generated.push(['.universalcms/angular.serve.mjs',`import {fileURLToPath} from 'node:url';\nprocess.chdir(fileURLToPath(new URL('../',import.meta.url)));\nprocess.env.UNIVERSALCMS_ANGULAR_START='1';\nawait import(${JSON.stringify('../'+output+'/server/server.mjs')});\n`]);
|
|
100
|
+
const oldFiles=new Map((prior?.files??[]).map(row=>[row.path,row.sha256])),files=[];
|
|
101
|
+
for(const [file,content]of generated){const before=await svelteRead(root,file),known=[project.appFile,'src/server.ts','src/app/app.routes.server.ts'].includes(file);if(prior?before===null||contentDigest(before)!==oldFiles.get(file):before!==null&&!known)fail('runtime file ownership changed');files.push({path:file,content,state:before===content?'unchanged':before===null?'create':'update',...(before!==null&&before!==content?{previous:before}:{})});}
|
|
102
|
+
files.push({path:'angular.json',content:project.angularText,state:'unchanged'},...project.observations);
|
|
103
|
+
const packageBefore=await svelteRead(root,'package.json'),packageAfter=json({...project.manifest,scripts});files.push({path:'package.json',content:packageAfter,state:packageBefore===packageAfter?'unchanged':'update',previous:packageBefore});
|
|
104
|
+
const ownership={version:1,publication:true,componentFile:project.appFile,originalComponent:project.original,componentHash:contentDigest(project.original),originalServer:serverOriginal,originalServerRoutes:routesOriginal,angularHash:contentDigest(project.angularText),files:generated.map(([path,content])=>({path,sha256:contentDigest(content)}))};
|
|
105
|
+
if(prior&&prior.angularHash!==ownership.angularHash)fail('Angular build configuration changed');const text=json(ownership);files.push({path:ANGULAR_MANIFEST,content:text,state:project.priorText===text?'unchanged':project.priorText===null?'create':'update',...(project.priorText?{previous:project.priorText}:{})});return{root,framework:'angular',files,project};
|
|
106
|
+
}
|
|
107
|
+
export async function validateInstalledAngular(directory,configuration){
|
|
108
|
+
const plan=await planAngularIntegration(directory,{publication:true,sourceConfiguration:configuration});
|
|
109
|
+
if(plan.files.some(file=>file.state!=='unchanged'))fail('integration incomplete or changed');
|
|
110
|
+
const inspection=await inspectAngularSource(directory,{locale:configuration.routes[0]?.locale,websiteOrigin:configuration.websiteOrigin});
|
|
111
|
+
if(json(inspection.routes)!==json(configuration.routes)||await svelteRead(plan.root,'.universalcms/source.generated.json')!==json(inspection.snapshot))fail('published route or frozen source inventory changed');
|
|
112
|
+
const registry=await readContentRegistry(plan.root);if(!registry||registry.routes.length!==configuration.routes.length)fail('published-content bindings incomplete');
|
|
113
|
+
const content=await planAngularContent(directory,configuration,inspection);if(content.files.some(file=>file.state!=='unchanged'))fail('native content bindings incomplete');
|
|
114
|
+
plan.files.push(...content.files);return{...plan,config:configuration};
|
|
115
|
+
}
|
|
116
|
+
export async function planAngularSettings(directory,{config,source}){
|
|
117
|
+
const plan=await validateInstalledAngular(directory,config),name='.universalcms/website.integration.json';
|
|
118
|
+
const unique=new Map(plan.files.filter(file=>file.path!=='package.json').map(file=>[file.path,{path:file.path,sha256:contentDigest(file.content)}]));
|
|
119
|
+
const content=json({version:1,framework:'angular',siteId:config.siteId,environmentId:config.environmentId,apiOrigin:config.apiOrigin,websiteOrigin:config.websiteOrigin,adminOrigin:source.adminOrigin,sections:WEBSITE_SECTIONS,files:[...unique.values()]}),previous=await svelteRead(plan.root,name);
|
|
120
|
+
if(previous!==null&&previous!==content)fail('settings ownership or scope changed');
|
|
121
|
+
plan.files.push({path:name,content,state:previous===null?'create':'unchanged'});
|
|
122
|
+
for(const name of ['.universalcms/publication.config.json','.universalcms/source.config.json','.universalcms/source.generated.json'])plan.files.push({path:name,content:await svelteRead(plan.root,name),state:'unchanged'});
|
|
123
|
+
return{...plan,sections:[...WEBSITE_SECTIONS]};
|
|
124
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { PublicationConfiguration } from './publication.mjs';
|
|
2
|
+
import type { WebsiteResult, WebsiteSnapshot, WebsiteMetadata } from './website-runtime.mjs';
|
|
3
|
+
import type { Inventory } from './react-publication.mjs';
|
|
4
|
+
export type AstroWebsiteContext = { pathname: string; scope: string; result: WebsiteResult; settings: WebsiteSnapshot; metadata: WebsiteMetadata; inventory?: Inventory; publicInventory?: Inventory };
|
|
5
|
+
export function astroBound(context: AstroWebsiteContext, id: string, type: string): (Inventory['baseline']['blocks'][number] & { url?: string; alt?: string }) | null;
|
|
6
|
+
export function createAstroRuntime(options: { 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 }): { onRequest(context: { request: Request; locals: { universalcms?: AstroWebsiteContext; [key: string]: unknown } }, next: () => Promise<Response>): Promise<Response> };
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/** Astro Node middleware. Only validated public snapshots enter Astro.locals. */
|
|
2
|
+
import { createSourceManifestProvider } from './vendor/source-manifest-provider.mjs';
|
|
3
|
+
import { validateSourceConfiguration } from './source-config.mjs';
|
|
4
|
+
import { createWebsiteRuntime, websiteMetadata } from './website-runtime.mjs';
|
|
5
|
+
import { createWebsiteRequestHandler, websiteHtml } from './website-http.mjs';
|
|
6
|
+
import { validatePublicationConfig, normalizePublicationPath, isExcludedPublicationPath, publicationErrorHtml, publicationRevision } from './publication.mjs';
|
|
7
|
+
import { publicationVerification, verificationRequest, verificationBody, publicationRouteHeaders } from './publication-verification.mjs';
|
|
8
|
+
import { PUBLICATION_STATUS_PATH, PUBLICATION_SCRIPT_PATH, createPublicationBrowserScript } from './publication-browser.mjs';
|
|
9
|
+
import { readServerEnvironment } from './vite-publication.mjs';
|
|
10
|
+
import { nativeChromeDescriptor } from './native-chrome-model.mjs';
|
|
11
|
+
import { publicHref, experienceCopy } from './website-model.mjs';
|
|
12
|
+
|
|
13
|
+
const responseHeaders = { 'Cache-Control': 'private, no-store, max-age=0', 'CDN-Cache-Control': 'no-store', 'Vercel-CDN-Cache-Control': 'no-store', 'Surrogate-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer' };
|
|
14
|
+
const flatten = blocks => blocks.flatMap(block => [block, ...(block.type === 'section' && Array.isArray(block.data?.blocks) ? flatten(block.data.blocks) : block.type === 'columns' && Array.isArray(block.data?.columns) ? flatten(block.data.columns.flat()) : [])]);
|
|
15
|
+
const escape = value => String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
|
|
16
|
+
const fail = () => { throw Error('Astro CMS integration needs review.'); };
|
|
17
|
+
const MAX_HTML_BYTES = 8 * 1024 * 1024;
|
|
18
|
+
|
|
19
|
+
// Vite's reviewed dev server owns these paths, including colon-bearing virtual
|
|
20
|
+
// module IDs. Validate their shape separately from public editorial URLs, only
|
|
21
|
+
// after the exact website origin/Host check. Native Vite filesystem policy still
|
|
22
|
+
// decides whether an @fs request can be served.
|
|
23
|
+
function astroAssetPath(pathname) {
|
|
24
|
+
if (pathname.length > 4096 || /%(?![0-9a-f]{2})|%(?:2f|5c|2e|25|3f|23|0[0-9a-f]|1[0-9a-f]|7f)/i.test(pathname)) return false;
|
|
25
|
+
let path; try { path = decodeURIComponent(pathname); } catch { return false; }
|
|
26
|
+
if (!/^\/(?:@(?:id|fs|vite)\/|@react-refresh$|_astro\/|src\/|node_modules\/)/.test(path) || !/^[\p{L}\p{N}_./@:+ -]+$/u.test(path) || path.split('/').slice(1).some(part => !part || part === '.' || part === '..')) return false;
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function readHtml(response) {
|
|
31
|
+
const declared = response.headers.get('content-length');
|
|
32
|
+
if (!response.body || declared !== null && (!/^\d+$/.test(declared) || Number(declared) > MAX_HTML_BYTES)) { void response.body?.cancel().catch(() => {}); fail(); }
|
|
33
|
+
const reader = response.body.getReader(), decoder = new TextDecoder('utf-8', { fatal: true });
|
|
34
|
+
let bytes = 0, html = '', timer;
|
|
35
|
+
const deadline = new Promise((_, reject) => { timer = setTimeout(() => reject(Error('Astro rendering unavailable.')), 10_000); });
|
|
36
|
+
try {
|
|
37
|
+
for (;;) {
|
|
38
|
+
const chunk = await Promise.race([reader.read(), deadline]);
|
|
39
|
+
if (chunk.done) break;
|
|
40
|
+
if (!(chunk.value instanceof Uint8Array) || (bytes += chunk.value.byteLength) > MAX_HTML_BYTES) fail();
|
|
41
|
+
html += decoder.decode(chunk.value, { stream: true });
|
|
42
|
+
}
|
|
43
|
+
return html + decoder.decode();
|
|
44
|
+
} finally { clearTimeout(timer); void reader.cancel().catch(() => {}); reader.releaseLock(); }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Same-kind native slots retain their original layout. Changed kinds belong to
|
|
48
|
+
* the hydrated additional-content renderer; missing IDs never restore source. */
|
|
49
|
+
export function astroBound(context, id, type) {
|
|
50
|
+
const entry = context?.result?.state === 'published' ? context.result.entry : null;
|
|
51
|
+
const baseline = context?.inventory?.baseline;
|
|
52
|
+
if (!entry || !baseline || typeof id !== 'string' || typeof type !== 'string') return null;
|
|
53
|
+
const original = flatten(baseline.blocks).find(block => block.id === id);
|
|
54
|
+
const block = flatten(entry.document.blocks).find(block => block.id === id);
|
|
55
|
+
if (!original || !block || original.type !== type || block.type !== type) return null;
|
|
56
|
+
let text = block.text;
|
|
57
|
+
if (block.text === original.text && id === context.inventory.titleBlockId && entry.document.title !== baseline.title) text = entry.document.title;
|
|
58
|
+
const excerptBlockId = context.inventory.excerptBlockId ?? baseline.blocks.find(candidate => candidate.type === 'paragraph')?.id;
|
|
59
|
+
if (block.text === original.text && id === excerptBlockId && entry.document.excerpt !== baseline.excerpt) text = entry.document.excerpt;
|
|
60
|
+
const data = { ...block.data };
|
|
61
|
+
if (type === 'image') {
|
|
62
|
+
data.url = publicHref(data.url, { relative: false });
|
|
63
|
+
data.link = publicHref(data.link);
|
|
64
|
+
if (!data.url) return null;
|
|
65
|
+
data.alt = typeof data.alt === 'string' ? data.alt.slice(0, 300) : '';
|
|
66
|
+
}
|
|
67
|
+
return { ...block, text, data, ...(type === 'image' ? { url: data.url, alt: data.alt } : {}) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function sourceResponse(provider, request) {
|
|
71
|
+
let status = 404, headers = responseHeaders, body;
|
|
72
|
+
provider({ method: request.method, url: request.url, headers: Object.fromEntries(request.headers) }, {
|
|
73
|
+
writeHead(code, value) { status = code; headers = value; }, end(value) { body = value; },
|
|
74
|
+
});
|
|
75
|
+
return new Response(request.method === 'HEAD' || status === 204 ? null : body, { status, headers });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function validateSnapshot(snapshot, configuration) {
|
|
79
|
+
if (!snapshot || snapshot.version !== 1 || !Array.isArray(snapshot.entries) || !snapshot.inventories || typeof snapshot.inventories !== 'object' || Array.isArray(snapshot.inventories)) fail();
|
|
80
|
+
if (Object.keys(snapshot.inventories).length !== configuration.routes.length || snapshot.entries.length !== configuration.routes.length) fail();
|
|
81
|
+
for (const route of configuration.routes) {
|
|
82
|
+
const inventory = snapshot.inventories[route.path];
|
|
83
|
+
const entry = snapshot.entries.find(value => ['kind', 'slug', 'locale'].every(key => value[key] === route[key]));
|
|
84
|
+
if (!inventory || !entry || ['kind', 'slug', 'locale'].some(key => inventory.identity?.[key] !== route[key]) || JSON.stringify(entry.document) !== JSON.stringify(inventory.baseline) || !inventory.bindings || typeof inventory.bindings !== 'object' || Array.isArray(inventory.bindings)) fail();
|
|
85
|
+
const ids = new Set(flatten(inventory.baseline.blocks).map(block => block.id));
|
|
86
|
+
if (Object.values(inventory.bindings).some(slot => !Array.isArray(slot?.variants) || slot.variants.some(value => !ids.has(value.id) || !Array.isArray(value.values)))) fail();
|
|
87
|
+
}
|
|
88
|
+
return JSON.parse(JSON.stringify(snapshot));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Astro serializes client:load props into HTML. The frozen source document is
|
|
92
|
+
// server-only: the island needs only the IDs/types it must leave in native slots.
|
|
93
|
+
function publicInventory(inventory, entry) {
|
|
94
|
+
const current = new Map(flatten(entry.document.blocks).map(block => [block.id, block.type]));
|
|
95
|
+
const original = new Map(flatten(inventory.baseline.blocks).map(block => [block.id, block.type]));
|
|
96
|
+
const bindings = Object.fromEntries(Object.entries(inventory.bindings).map(([name, slot]) => [name, { variants: slot.variants.filter(value => current.has(value.id) && current.get(value.id) === original.get(value.id)).map(value => ({ id: value.id, values: [] })) }]).filter(([, slot]) => slot.variants.length));
|
|
97
|
+
const ids = new Set(Object.values(bindings).flatMap(slot => slot.variants.map(value => value.id)));
|
|
98
|
+
return { identity: { ...inventory.identity }, baseline: { schemaVersion: 1, title: '', excerpt: '', blocks: [...ids].map(id => ({ id, version: 1, type: original.get(id), text: '' })) }, bindings, ...(inventory.nativeChrome ? {nativeChrome:nativeChromeDescriptor(inventory.nativeChrome)} : {}) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A settings read and a page read share immutable upstream responses within
|
|
102
|
+
* this request only. No credential, authorization or publication cache survives it. */
|
|
103
|
+
function requestFetcher(fetcher) {
|
|
104
|
+
const responses = new Map();
|
|
105
|
+
let bytes = 0;
|
|
106
|
+
const buffer = async (input, init) => {
|
|
107
|
+
const response = await fetcher(input, init);
|
|
108
|
+
if (!(response instanceof Response) || response.redirected || ['opaque', 'opaqueredirect'].includes(response.type) || response.url && response.url !== String(input)) { void response?.body?.cancel?.().catch(() => {}); fail(); }
|
|
109
|
+
const options = { status: response.status, statusText: response.statusText, headers: new Headers(response.headers) };
|
|
110
|
+
// Failed reads need only their status. Never retain an upstream error body.
|
|
111
|
+
if (response.status !== 200) { void response.body?.cancel().catch(() => {}); return { body: null, options }; }
|
|
112
|
+
const declared = response.headers.get('content-length');
|
|
113
|
+
if (init.signal?.aborted || !response.body || declared !== null && (!/^\d+$/.test(declared) || Number(declared) > MAX_HTML_BYTES - bytes)) { void response.body?.cancel().catch(() => {}); fail(); }
|
|
114
|
+
const reader = response.body.getReader(), chunks = [];
|
|
115
|
+
let length = 0;
|
|
116
|
+
try {
|
|
117
|
+
for (;;) {
|
|
118
|
+
const chunk = await new Promise((resolve, reject) => {
|
|
119
|
+
const aborted = () => reject(Error('Astro configuration unavailable.'));
|
|
120
|
+
init.signal?.addEventListener('abort', aborted, { once: true });
|
|
121
|
+
if (init.signal?.aborted) aborted();
|
|
122
|
+
reader.read().then(value => { init.signal?.removeEventListener('abort', aborted); resolve(value); }, error => { init.signal?.removeEventListener('abort', aborted); reject(error); });
|
|
123
|
+
});
|
|
124
|
+
if (init.signal?.aborted) fail();
|
|
125
|
+
if (chunk.done) break;
|
|
126
|
+
if (!(chunk.value instanceof Uint8Array) || (bytes += chunk.value.byteLength) > MAX_HTML_BYTES) fail();
|
|
127
|
+
length += chunk.value.byteLength; chunks.push(chunk.value);
|
|
128
|
+
}
|
|
129
|
+
} finally { void reader.cancel().catch(() => {}); reader.releaseLock(); }
|
|
130
|
+
const body = new Uint8Array(length); let offset = 0;
|
|
131
|
+
for (const chunk of chunks) { body.set(chunk, offset); offset += chunk.byteLength; }
|
|
132
|
+
return { body, options };
|
|
133
|
+
};
|
|
134
|
+
return async (input, init) => {
|
|
135
|
+
const key = String(input);
|
|
136
|
+
let pending = responses.get(key);
|
|
137
|
+
if (!pending) { pending = buffer(input, init); responses.set(key, pending); }
|
|
138
|
+
// Runtime operations abort their own signal when complete. Fresh Responses
|
|
139
|
+
// from fully consumed bytes are independent of that now-aborted transport.
|
|
140
|
+
const saved = await pending;
|
|
141
|
+
return new Response(saved.body, saved.options);
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function createAstroRuntime({ configuration, sourceConfiguration, contentBindings, snapshot, sourceRoot, env, fetcher = globalThis.fetch } = {}) {
|
|
146
|
+
if (configuration === undefined) {
|
|
147
|
+
const source = validateSourceConfiguration(sourceConfiguration);
|
|
148
|
+
const provider = createSourceManifestProvider({ siteId: source.siteId, environmentId: source.environmentId, sourceOrigin: source.websiteOrigin, adminOrigin: source.adminOrigin, publicContent: true, manifestJson: JSON.stringify({ version: 1, entries: snapshot?.entries }) });
|
|
149
|
+
return { async onRequest(context, next) {
|
|
150
|
+
const request = context.request;
|
|
151
|
+
let url; try { url = new URL(request.url); } catch { return new Response(null, { status: 400 }); }
|
|
152
|
+
if (url.pathname !== '/api/cms-source') return next();
|
|
153
|
+
const response = sourceResponse(provider, request);
|
|
154
|
+
if (!source.exportClosed || response.status !== 200) return response;
|
|
155
|
+
const headers = new Headers(response.headers); headers.delete('Content-Length'); headers.delete('ETag');
|
|
156
|
+
return new Response(request.method === 'HEAD' ? null : JSON.stringify({ version: 1, siteId: source.siteId, environmentId: source.environmentId, sourceExport: 'closed' }), { status: 410, headers });
|
|
157
|
+
} };
|
|
158
|
+
}
|
|
159
|
+
const config = validatePublicationConfig(configuration), source = validateSourceConfiguration(sourceConfiguration);
|
|
160
|
+
const verification = publicationVerification(config, source, contentBindings);
|
|
161
|
+
if (!verification?.body.contentBindings || typeof sourceRoot !== 'string' || !sourceRoot) fail();
|
|
162
|
+
const frozen = validateSnapshot(snapshot, config);
|
|
163
|
+
const provider = createSourceManifestProvider({ siteId: config.siteId, environmentId: config.environmentId, sourceOrigin: config.websiteOrigin, adminOrigin: source.adminOrigin, publicContent: true, manifestJson: JSON.stringify({ version: 1, entries: frozen.entries }) });
|
|
164
|
+
const script = createPublicationBrowserScript(config), website = new URL(config.websiteOrigin);
|
|
165
|
+
const scope = `${config.siteId}:${config.environmentId}`;
|
|
166
|
+
let active = 0;
|
|
167
|
+
const reply = (request, body, status = 200, type = 'application/json', extra = {}) => new Response(request.method === 'HEAD' || status === 204 ? null : typeof body === 'string' ? body : JSON.stringify(body), { status, headers: { ...responseHeaders, 'Content-Type': type, ...extra } });
|
|
168
|
+
return { async onRequest(context, next) {
|
|
169
|
+
const request = context.request;
|
|
170
|
+
let url, pathname;
|
|
171
|
+
try {
|
|
172
|
+
url = new URL(request.url);
|
|
173
|
+
if (url.origin !== config.websiteOrigin || request.headers.get('host') && request.headers.get('host') !== website.host || url.username || url.password || url.hash) return reply(request, { error: 'Website origin mismatch' }, 403);
|
|
174
|
+
if (astroAssetPath(url.pathname) && url.search.length <= 4096 && ['GET', 'HEAD'].includes(request.method)) return next();
|
|
175
|
+
pathname = normalizePublicationPath(url.pathname);
|
|
176
|
+
if (url.pathname.includes('%')) return reply(request, publicationErrorHtml(404), 404, 'text/html; charset=utf-8');
|
|
177
|
+
} catch { return reply(request, publicationErrorHtml(404), 404, 'text/html; charset=utf-8'); }
|
|
178
|
+
if (url.pathname === '/api/cms-source') {
|
|
179
|
+
const response = sourceResponse(provider, request);
|
|
180
|
+
if (response.status !== 200 || !source.exportClosed) return response;
|
|
181
|
+
const headers = new Headers(response.headers); headers.delete('Content-Length'); headers.delete('ETag');
|
|
182
|
+
return new Response(request.method === 'HEAD' ? null : JSON.stringify({ version: 1, siteId: config.siteId, environmentId: config.environmentId, sourceExport: 'closed' }), { status: 410, headers });
|
|
183
|
+
}
|
|
184
|
+
if (url.pathname === PUBLICATION_SCRIPT_PATH) {
|
|
185
|
+
if (!['GET', 'HEAD'].includes(request.method) || url.search || request.headers.get('sec-fetch-site') === 'cross-site' || request.headers.get('origin') && request.headers.get('origin') !== config.websiteOrigin) return reply(request, { error: 'Request not allowed' }, 403);
|
|
186
|
+
return reply(request, script, 200, 'text/javascript; charset=utf-8');
|
|
187
|
+
}
|
|
188
|
+
const endpoint = ['/api/universalcms/settings', '/api/universalcms/page', '/api/universalcms/document', '/sitemap.xml', '/robots.txt'].includes(url.pathname);
|
|
189
|
+
if (!endpoint && url.pathname !== PUBLICATION_STATUS_PATH && isExcludedPublicationPath(pathname)) return next();
|
|
190
|
+
if (active >= 16) return reply(request, endpoint || url.pathname === PUBLICATION_STATUS_PATH ? { state: 'unavailable' } : publicationErrorHtml(503), 503, endpoint || url.pathname === PUBLICATION_STATUS_PATH ? 'application/json' : 'text/html; charset=utf-8');
|
|
191
|
+
active++;
|
|
192
|
+
try {
|
|
193
|
+
// Read private environment once, before composing either public projection.
|
|
194
|
+
const values = { ...(typeof env === 'function' ? await env() : env ?? await readServerEnvironment(sourceRoot)) };
|
|
195
|
+
const fetch = requestFetcher(fetcher);
|
|
196
|
+
const runtime = createWebsiteRuntime({ configuration: config, env: values, fetcher: fetch, nativePaths: verification.nativePaths, nativePatterns: verification.nativePatterns });
|
|
197
|
+
if (url.pathname === '/api/universalcms/document') {
|
|
198
|
+
if (!['GET', 'HEAD'].includes(request.method) || request.headers.get('sec-fetch-site') === 'cross-site' || request.headers.get('origin') && request.headers.get('origin') !== config.websiteOrigin) return reply(request, { error: 'Request not allowed' }, 403);
|
|
199
|
+
if (url.searchParams.size !== 1 || url.searchParams.getAll('path').length !== 1) return reply(request, { error: 'Invalid page request' }, 400);
|
|
200
|
+
let path; try { path = normalizePublicationPath(url.searchParams.get('path')); } catch { return reply(request, { error: 'Invalid page request' }, 400); }
|
|
201
|
+
const result = await runtime.page(path);
|
|
202
|
+
return result.state === 'published' ? reply(request, { ...result, revision: publicationRevision(result.entry) }) : reply(request, { state: result.state === 'unavailable' ? 'unavailable' : 'missing' }, result.state === 'unavailable' ? 503 : 404);
|
|
203
|
+
}
|
|
204
|
+
if (endpoint) {
|
|
205
|
+
const handler = createWebsiteRequestHandler({ configuration: config, sourceConfiguration: source, contentBindings, env: values, fetcher: fetch });
|
|
206
|
+
return await handler(request) ?? reply(request, { state: 'missing' }, 404);
|
|
207
|
+
}
|
|
208
|
+
if (url.pathname === PUBLICATION_STATUS_PATH) {
|
|
209
|
+
const probe = verificationRequest({ configuration: config, verification, url, method: request.method, headers: request.headers });
|
|
210
|
+
if (probe?.status) return reply(request, probe.status === 204 ? null : { error: 'Publication check unavailable' }, probe.status, 'application/json', probe.headers);
|
|
211
|
+
if (!probe && (!['GET', 'HEAD'].includes(request.method) || request.headers.get('sec-fetch-site') === 'cross-site' || request.headers.get('origin') && request.headers.get('origin') !== config.websiteOrigin)) return reply(request, { error: 'Request not allowed' }, 403);
|
|
212
|
+
if (!probe && (url.searchParams.size !== 1 || url.searchParams.getAll('path').length !== 1)) return reply(request, { error: 'Invalid publication request' }, 400);
|
|
213
|
+
let path; try { path = probe?.pathname ?? normalizePublicationPath(url.searchParams.get('path')); } catch { return reply(request, { error: 'Invalid publication request' }, 400); }
|
|
214
|
+
if (!config.routes.some(route => route.path === path)) return reply(request, { state: 'missing' }, 404);
|
|
215
|
+
const result = await runtime.page(path);
|
|
216
|
+
if (probe) {
|
|
217
|
+
const probeHeaders = { ...probe.headers, ...(result.reason ? { 'X-UniversalCMS-Delivery-Status': result.reason } : {}) };
|
|
218
|
+
return reply(request, verificationBody(verification, path, result), result.state === 'unavailable' ? 503 : 200, 'application/json', probeHeaders);
|
|
219
|
+
}
|
|
220
|
+
const extraHeaders = result.reason ? { 'X-UniversalCMS-Delivery-Status': result.reason } : {};
|
|
221
|
+
return reply(request, { state: result.state === 'published' ? 'published' : result.state === 'unavailable' ? 'unavailable' : 'missing', ...(result.state === 'published' && request.headers.get('x-universalcms-include-revision') === '1' ? { revision: publicationRevision(result.entry) } : {}) }, result.state === 'published' ? 200 : result.state === 'unavailable' ? 503 : 404, 'application/json', extraHeaders);
|
|
222
|
+
}
|
|
223
|
+
if (!['GET', 'HEAD'].includes(request.method)) return reply(request, { error: 'Method not allowed' }, 405, 'application/json', { Allow: 'GET, HEAD' });
|
|
224
|
+
const path = pathname === '/index.html' && config.routes.some(route => route.path === '/') ? '/' : pathname;
|
|
225
|
+
const result = await runtime.page(path);
|
|
226
|
+
if (result.state === 'unmanaged') return next();
|
|
227
|
+
if (result.state === 'redirect') return reply(request, '', result.status, 'text/plain', { Location: new URL(result.location, config.websiteOrigin).href });
|
|
228
|
+
if (result.state === 'gone') return reply(request, 'This page is no longer available.', 410, 'text/plain', { 'X-Robots-Tag': 'noindex' });
|
|
229
|
+
const settings = await runtime.settings();
|
|
230
|
+
const routeHeaders = config.routes.some(route => route.path === path) ? publicationRouteHeaders(config, path, result, verification, request.headers.get('origin'), request.headers.has('authorization') || request.headers.has('cookie')) : {};
|
|
231
|
+
if (settings.state === 'unavailable' || result.state === 'unavailable') return reply(request, publicationErrorHtml(503), 503, 'text/html; charset=utf-8', { ...routeHeaders, 'X-Robots-Tag': 'noindex', 'Retry-After': '30' });
|
|
232
|
+
if (!['published', 'archive'].includes(result.state)) {
|
|
233
|
+
const copy = experienceCopy(settings, source.locale), href = publicHref(copy?.notFoundAction?.url);
|
|
234
|
+
const body = copy ? `<!doctype html><html><head><title>${escape(copy.notFoundTitle || 'Page not found')}</title><meta name="robots" content="noindex,nofollow"></head><body><main><h1>${escape(copy.notFoundTitle || 'Page not found')}</h1><p>${escape(copy.notFoundMessage)}</p>${href ? `<a href="${escape(href)}">${escape(copy.notFoundAction.label)}</a>` : ''}</main></body></html>` : publicationErrorHtml(404);
|
|
235
|
+
return reply(request, body, 404, 'text/html; charset=utf-8', { ...routeHeaders, 'X-Robots-Tag': 'noindex' });
|
|
236
|
+
}
|
|
237
|
+
const editorial = result.state === 'published' ? result.entry : { document: { title: result.title, excerpt: result.description } };
|
|
238
|
+
const metadata = websiteMetadata(editorial, settings, path, config.websiteOrigin);
|
|
239
|
+
context.locals.universalcms = { pathname: path, scope, result, settings, metadata, ...(frozen.inventories[path] ? { inventory: frozen.inventories[path], publicInventory: publicInventory(frozen.inventories[path], result.entry) } : {}) };
|
|
240
|
+
const response = await next();
|
|
241
|
+
if (!(response instanceof Response) || response.status !== 200 || !response.headers.get('content-type')?.includes('text/html')) return reply(request, publicationErrorHtml(503), 503, 'text/html; charset=utf-8', { 'X-Robots-Tag': 'noindex' });
|
|
242
|
+
const headers = new Headers(response.headers); headers.delete('Content-Length'); headers.delete('ETag');
|
|
243
|
+
for (const [name, value] of Object.entries({ ...responseHeaders, ...routeHeaders })) headers.set(name, value);
|
|
244
|
+
if (request.method === 'HEAD') { void response.body?.cancel().catch(() => {}); return new Response(null, { status: 200, headers }); }
|
|
245
|
+
const html = await readHtml(response);
|
|
246
|
+
if (!/<head\b[^>]*>[\s\S]*?<\/head>/i.test(html)) fail();
|
|
247
|
+
const seo = settings.sections.seo.state === 'ready';
|
|
248
|
+
let body = websiteHtml(html, seo || !frozen.inventories[path] ? metadata : null, { seo, branding: settings.sections.branding.state === 'ready' ? settings.sections.branding.data : undefined });
|
|
249
|
+
// The blocking guard must run before hydrating the static native children.
|
|
250
|
+
body = body.replace(/<head\b[^>]*>/i, match => `${match}<script src="${PUBLICATION_SCRIPT_PATH}"></script>`);
|
|
251
|
+
return new Response(request.method === 'HEAD' ? null : body, { status: 200, headers });
|
|
252
|
+
} catch { return reply(request, endpoint || url.pathname === PUBLICATION_STATUS_PATH ? { state: 'unavailable' } : publicationErrorHtml(503), 503, endpoint || url.pathname === PUBLICATION_STATUS_PATH ? 'application/json' : 'text/html; charset=utf-8', { 'X-Robots-Tag': 'noindex' }); }
|
|
253
|
+
finally { active--; }
|
|
254
|
+
} };
|
|
255
|
+
}
|