@universalcms/connect 0.1.0 → 0.13.1

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.
Files changed (130) hide show
  1. package/README.md +699 -37
  2. package/dist/angular-runtime.d.mts +4 -0
  3. package/dist/angular-runtime.mjs +7 -0
  4. package/dist/angular-setup.mjs +124 -0
  5. package/dist/astro-runtime.d.mts +6 -0
  6. package/dist/astro-runtime.mjs +255 -0
  7. package/dist/astro-setup.mjs +215 -0
  8. package/dist/astro-source.mjs +451 -0
  9. package/dist/astro-website.d.mts +5 -0
  10. package/dist/astro-website.mjs +42 -0
  11. package/dist/cli.mjs +418 -15
  12. package/dist/connector-runtime.mjs +130 -0
  13. package/dist/content-next-setup.mjs +207 -0
  14. package/dist/content-registry-validation.mjs +40 -0
  15. package/dist/content-registry.mjs +44 -0
  16. package/dist/content-setup.mjs +13 -0
  17. package/dist/content-storefront-setup.mjs +155 -0
  18. package/dist/content-vite-setup.mjs +107 -0
  19. package/dist/core.mjs +32 -51
  20. package/dist/framework-ownership.mjs +68 -0
  21. package/dist/framework.mjs +54 -0
  22. package/dist/guided.mjs +514 -0
  23. package/dist/native-adapters.mjs +31 -0
  24. package/dist/native-chrome-model.d.mts +3 -0
  25. package/dist/native-chrome-model.mjs +14 -0
  26. package/dist/native-chrome-review.mjs +49 -0
  27. package/dist/native-chrome-source.mjs +69 -0
  28. package/dist/native-route-review.mjs +152 -0
  29. package/dist/native-routes.mjs +27 -0
  30. package/dist/native-website-model.d.mts +18 -0
  31. package/dist/native-website-model.mjs +67 -0
  32. package/dist/native-website-server.d.mts +1 -0
  33. package/dist/native-website-server.mjs +35222 -0
  34. package/dist/native-website-server.mjs.LEGAL.txt +56 -0
  35. package/dist/native-website.d.mts +3 -0
  36. package/dist/native-website.mjs +120 -0
  37. package/dist/native-website.mjs.LEGAL.txt +56 -0
  38. package/dist/next-publication.d.mts +12 -0
  39. package/dist/next-publication.mjs +107 -0
  40. package/dist/next-settings-layout.mjs +233 -0
  41. package/dist/next-source.d.mts +8 -0
  42. package/dist/next-source.mjs +93 -0
  43. package/dist/page-discovery.mjs +236 -0
  44. package/dist/page-mapping-review.mjs +160 -0
  45. package/dist/preflight-recovery.mjs +130 -0
  46. package/dist/preflight.mjs +138 -0
  47. package/dist/project-files.mjs +56 -0
  48. package/dist/public-bindings.d.mts +3 -0
  49. package/dist/public-bindings.mjs +28 -0
  50. package/dist/publication-browser.d.mts +5 -0
  51. package/dist/publication-browser.mjs +258 -0
  52. package/dist/publication-check.mjs +141 -0
  53. package/dist/publication-document.d.mts +2 -0
  54. package/dist/publication-document.mjs +59 -0
  55. package/dist/publication-next-setup.mjs +134 -0
  56. package/dist/publication-setup.mjs +138 -0
  57. package/dist/publication-verification.mjs +85 -0
  58. package/dist/publication.css +6 -0
  59. package/dist/publication.d.mts +31 -0
  60. package/dist/publication.mjs +320 -0
  61. package/dist/react-publication.d.mts +22 -0
  62. package/dist/react-publication.mjs +301 -0
  63. package/dist/react-website.d.mts +15 -0
  64. package/dist/react-website.mjs +735 -0
  65. package/dist/reviewed-commerce-ownership.mjs +26 -0
  66. package/dist/reviewed-commerce-settings-setup.mjs +243 -0
  67. package/dist/reviewed-native-ownership.mjs +118 -0
  68. package/dist/reviewed-storefront-source.mjs +227 -0
  69. package/dist/runtime-dependencies.mjs +59 -0
  70. package/dist/settings-only-setup.mjs +100 -0
  71. package/dist/settings-only.d.mts +4 -0
  72. package/dist/settings-only.mjs +49 -0
  73. package/dist/setup-environment.mjs +70 -0
  74. package/dist/source-bindings.mjs +77 -0
  75. package/dist/source-check.mjs +149 -0
  76. package/dist/source-collect.mjs +80 -0
  77. package/dist/source-config.mjs +20 -0
  78. package/dist/source-extract.mjs +514 -0
  79. package/dist/source-finalize.mjs +52 -0
  80. package/dist/source-routes.mjs +65 -0
  81. package/dist/source-setup.mjs +490 -0
  82. package/dist/staged-setup.mjs +287 -0
  83. package/dist/static-runtime.mjs +67 -0
  84. package/dist/static-setup.mjs +136 -0
  85. package/dist/svelte-runtime.d.mts +27 -0
  86. package/dist/svelte-runtime.mjs +40 -0
  87. package/dist/svelte-setup.mjs +135 -0
  88. package/dist/svelte-template.mjs +48 -0
  89. package/dist/templates.mjs +167 -3
  90. package/dist/vendor/astro-compiler/LICENSE +53 -0
  91. package/dist/vendor/astro-compiler/astro.wasm +0 -0
  92. package/dist/vendor/astro-compiler/chunk-W5DTLHV4.js +1 -0
  93. package/dist/vendor/astro-compiler/node/index.js +1 -0
  94. package/dist/vendor/project-analysis.mjs +58 -4
  95. package/dist/vendor/react-LICENSE.txt +21 -0
  96. package/dist/vendor/react-document.mjs +124 -0
  97. package/dist/vendor/react-dom-LICENSE.txt +21 -0
  98. package/dist/vendor/react-form.mjs +63 -0
  99. package/dist/vendor/react-safety.mjs +47 -0
  100. package/dist/vendor/source-manifest-provider.mjs +104 -0
  101. package/dist/vendor/typescript-LICENSE.txt +55 -0
  102. package/dist/vendor/typescript.cjs +200276 -0
  103. package/dist/vite-publication-server.d.mts +3 -0
  104. package/dist/vite-publication-server.mjs +82 -0
  105. package/dist/vite-publication.d.mts +15 -0
  106. package/dist/vite-publication.mjs +241 -0
  107. package/dist/vite-source-discovery.mjs +315 -0
  108. package/dist/vite-source.d.mts +30 -0
  109. package/dist/vite-source.mjs +141 -0
  110. package/dist/vue-runtime.d.mts +10 -0
  111. package/dist/vue-runtime.mjs +50 -0
  112. package/dist/vue-server.d.mts +2 -0
  113. package/dist/vue-server.mjs +58 -0
  114. package/dist/vue-setup.mjs +127 -0
  115. package/dist/vue-source.mjs +168 -0
  116. package/dist/website-check.mjs +41 -0
  117. package/dist/website-http.mjs +149 -0
  118. package/dist/website-model.d.mts +20 -0
  119. package/dist/website-model.mjs +155 -0
  120. package/dist/website-next.d.mts +2 -0
  121. package/dist/website-next.mjs +29 -0
  122. package/dist/website-node.d.mts +3 -0
  123. package/dist/website-node.mjs +50 -0
  124. package/dist/website-runtime.d.mts +13 -0
  125. package/dist/website-runtime.mjs +404 -0
  126. package/dist/website-schema.mjs +22175 -0
  127. package/dist/website-setup.mjs +256 -0
  128. package/dist/website.css +49 -0
  129. package/dist/wizard.mjs +237 -38
  130. package/package.json +44 -7
@@ -0,0 +1,451 @@
1
+ /** Inert, bounded Astro source extraction and original-layout content bindings. */
2
+ import path from 'node:path';
3
+ import { lstat, readdir, realpath } from 'node:fs/promises';
4
+ import { parse } from './vendor/astro-compiler/node/index.js';
5
+ import ts from './vendor/typescript.cjs';
6
+ import { projectRoot, readProjectFile, SetupError } from './core.mjs';
7
+ import { CONTENT_BINDINGS_FILE, contentDigest, readContentRegistry, readFrozenContent, validateContentRegistry } from './content-registry.mjs';
8
+ import { isExcludedPublicationPath, validatePublicationConfig } from './publication.mjs';
9
+ import { publicHref } from './website-model.mjs';
10
+ import { inspectNativeChromeTemplate } from './native-chrome-source.mjs';
11
+
12
+ export const ASTRO_SOURCE_LIMITS = Object.freeze({ bytes: 128 * 1024, totalBytes: 8 * 1024 * 1024, nodes: 30000, depth: 80, files: 1000, directories: 256, blocks: 200, text: 30000 });
13
+ const fail = reason => { throw new SetupError(`Astro source needs reviewed integration: ${reason} Existing files were preserved.`); };
14
+ const json = value => JSON.stringify(value, null, 2) + '\n';
15
+ const normalize = value => value.replace(/\s+/g, ' ').trim();
16
+ const token = value => contentDigest(value).slice(0, 20);
17
+ const uuid = value => { const h = contentDigest(value); return `${h.slice(0,8)}-${h.slice(8,12)}-5${h.slice(13,16)}-a${h.slice(17,20)}-${h.slice(20,32)}`; };
18
+ const privateSegment = /^(?:api|admin|account|dashboard|profile|auth|login|logout|register|checkout|orders|settings|billing|portal|patient|patients|vault|records|private)(?:-|$)/;
19
+ const forbiddenTags = new Set(['header','footer','nav','form','input','textarea','select','option','iframe','object','embed','canvas','template','base']);
20
+ const inlineTags = new Set(['a','span','strong','em','small','b','i','u','s','del','sup','sub','code','mark','br']);
21
+ const safeUrl = value => !!publicHref(value) && !value.startsWith('#') && (value.startsWith('/') || /^https:\/\//i.test(value));
22
+ const significant = children => (children ?? []).filter(node => node.type !== 'comment' && !(node.type === 'text' && !node.value.trim()));
23
+ const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
24
+ const inspections = new WeakMap();
25
+
26
+ function imageOrigin(value) {
27
+ try {
28
+ const url = new URL(value);
29
+ if (typeof value !== 'string' || !publicHref(value, {relative:false}) || url.pathname !== '/' || url.search || url.hash || !(url.protocol === 'https:' || url.protocol === 'http:' && ['localhost','127.0.0.1','[::1]'].includes(url.hostname))) fail('relative images require an exact public website origin');
30
+ return url.origin;
31
+ } catch { fail('relative images require an exact public website origin'); }
32
+ }
33
+
34
+ // Only locate the end of a compiler-identified opening tag. Quotes make a
35
+ // literal `>` part of an attribute, never an editing boundary.
36
+ function openingTag(source,start) {
37
+ let quote=null;
38
+ for(let index=start+1;index<source.length;index++) {
39
+ const value=source[index];
40
+ if(quote){if(value===quote)quote=null;continue;}
41
+ if(value==='"'||value==="'"){quote=value;continue;}
42
+ if(value==='>')return {end:index+1,selfClosing:source.slice(start,index).trimEnd().endsWith('/')};
43
+ }
44
+ fail('native opening tag is incomplete');
45
+ }
46
+
47
+ function decodeText(value) {
48
+ return value.replace(/&(?:[a-zA-Z][a-zA-Z0-9]*|#\d+|#x[\da-fA-F]+);/g, entity => {
49
+ const named = { '&amp;':'&', '&lt;':'<', '&gt;':'>', '&quot;':'"', '&apos;':"'", '&nbsp;':' ' };
50
+ if (own(named, entity)) return named[entity];
51
+ const number = entity.startsWith('&#x') ? Number.parseInt(entity.slice(3,-1),16) : entity.startsWith('&#') ? Number(entity.slice(2,-1)) : NaN;
52
+ if (!Number.isInteger(number) || number <= 0 || number > 0x10ffff || number >= 0xd800 && number <= 0xdfff) fail('unsupported character reference');
53
+ return String.fromCodePoint(number);
54
+ });
55
+ }
56
+
57
+ /** The pinned compiler reports byte offsets. Validate them against source tokens,
58
+ * rather than applying unverified parser positions to JavaScript string slices. */
59
+ function sourceRanges(source) {
60
+ const bytes = Buffer.from(source), offsets = new Map([[0,0]]);
61
+ let byte = 0, character = 0;
62
+ for (const value of source) { byte += Buffer.byteLength(value); character += value.length; offsets.set(byte,character); }
63
+ return node => {
64
+ // The compiler omits the HTML element position when a doctype is present.
65
+ // This one outer shell is never inferred from a partial/duplicate match.
66
+ if(node.type==='element'&&node.name==='html'&&!node.position) {
67
+ const opens=[...source.matchAll(/<html(?=[\s/>])/g)],closes=[...source.matchAll(/<\/html>/g)];
68
+ if(opens.length!==1||closes.length!==1||opens[0].index>=closes[0].index)fail('HTML shell positions are ambiguous');
69
+ openingTag(source,opens[0].index);
70
+ return {start:opens[0].index,end:closes[0].index+7};
71
+ }
72
+ const start = node.position?.start?.offset, end = node.position?.end?.offset;
73
+ if (!Number.isSafeInteger(start) || start < 0 || !offsets.has(start)) fail('compiler source positions are incomplete');
74
+ const result = { start:offsets.get(start), end:offsets.get(end) };
75
+ if(node.type==='attribute') {
76
+ if(node.kind!=='quoted'||!['src','alt'].includes(node.name)||typeof node.raw!=='string'||!['"',"'"].includes(node.raw[0])||node.raw.at(-1)!==node.raw[0])fail('image attributes require quoted literal values');
77
+ // Attribute starts, like element starts, are byte offsets. Verify the
78
+ // exact token locally; searching a tag could match data-src or text in
79
+ // another attribute's quoted value instead of the owned image source.
80
+ const prefix = new RegExp(`^${node.name}\\s*=\\s*`).exec(source.slice(result.start));
81
+ if(!prefix || source.slice(result.start+prefix[0].length,result.start+prefix[0].length+node.raw.length)!==node.raw)fail('compiler attribute positions do not match the native source');
82
+ return {start:result.start,end:result.start+prefix[0].length+node.raw.length};
83
+ }
84
+ const tag=node.type==='element'||node.type==='component';
85
+ // Certain adjacent self-closing HTML tags start one byte after '<'. Accept
86
+ // only that exact local correction, verified against the compiler tag name.
87
+ if(tag&&source[result.start]!=='<'&&result.start>0&&source[result.start-1]==='<'&&source.slice(result.start,result.start+node.name.length)===node.name)result.start--;
88
+ const opening=tag ? openingTag(source,result.start):null;
89
+ const voidTag=['img','br','hr','meta','link','input','source','area','base','col','embed','param','track','wbr'].includes(node.name);
90
+ // Compiler 2.13.0 truncates void/self-closing end offsets. Its validated
91
+ // opening-tag start plus the bounded quote-aware delimiter is authoritative.
92
+ if(tag && (voidTag||opening.selfClosing))result.end=opening.end;
93
+ else if(!Number.isSafeInteger(end)||end<=start||end>bytes.length||!offsets.has(end))fail('compiler source positions are incomplete');
94
+ const text = source.slice(result.start,result.end);
95
+ if (node.type === 'element' || node.type === 'component') {
96
+ if (!text.startsWith('<' + node.name) || !/[\s/>]/.test(text[1+node.name.length]??'')) fail('compiler source positions do not match the native element');
97
+ if (!voidTag && !opening.selfClosing && !text.endsWith(`</${node.name}>`)) fail('implicitly closed elements require review');
98
+ } else if (node.type === 'text' && text !== node.value) fail('compiler text positions do not match the native source');
99
+ return result;
100
+ };
101
+ }
102
+
103
+ async function parsed(source) {
104
+ if (typeof source !== 'string' || Buffer.byteLength(source) > ASTRO_SOURCE_LIMITS.bytes || source.includes('\0')) fail('source exceeds its text budget');
105
+ let result;
106
+ try { result = await parse(source, { position:true }); } catch { fail('invalid Astro syntax'); }
107
+ if (!result?.ast || result.diagnostics?.some(item => item.severity === 1)) fail('invalid Astro syntax');
108
+ let count = 0;
109
+ const walk = (node, depth = 0) => {
110
+ if (++count > ASTRO_SOURCE_LIMITS.nodes || depth > ASTRO_SOURCE_LIMITS.depth) fail('template exceeds its AST budget');
111
+ for (const child of node.children ?? []) walk(child,depth+1);
112
+ };
113
+ walk(result.ast);
114
+ return { ast:result.ast, range:sourceRanges(source) };
115
+ }
116
+
117
+ function inspectFrontmatter(ast, source, { layout = false } = {}) {
118
+ const front = (ast.children ?? []).filter(node => node.type === 'frontmatter');
119
+ if (front.length > 1) fail('multiple frontmatter regions');
120
+ const imports = new Map(), constants = new Map();
121
+ let end = 0;
122
+ if (front.length) {
123
+ // Compiler frontmatter positions are not used for delimiter edits. The first
124
+ // bounded delimiter pair must agree exactly with the parsed script body.
125
+ const match = /^(?:\uFEFF)?---[ \t]*\r?\n([\s\S]*?)^---(?=\s|<|$)/m.exec(source);
126
+ if (!match || match.index !== 0 || match[1].trim() !== front[0].value.trim()) fail('frontmatter delimiters require review');
127
+ end = match[0].length;
128
+ const file = ts.createSourceFile('page.ts',front[0].value,ts.ScriptTarget.Latest,true,ts.ScriptKind.TS);
129
+ if (file.parseDiagnostics.length) fail('frontmatter syntax is invalid');
130
+ for (const statement of file.statements) {
131
+ if (ts.isImportDeclaration(statement)) {
132
+ const name = statement.importClause?.name;
133
+ if (layout || !name || statement.importClause.isTypeOnly || statement.importClause.namedBindings || statement.attributes || !ts.isStringLiteral(statement.moduleSpecifier) || !/^\.\.?\/[A-Za-z0-9_./-]+\.astro$/.test(statement.moduleSpecifier.text)) fail('only one inspected relative Astro layout import is supported');
134
+ if (imports.size) fail('multiple layout imports require review');
135
+ imports.set(name.text, statement.moduleSpecifier.text); continue;
136
+ }
137
+ if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement) || ts.isEmptyStatement(statement)) continue;
138
+ if (!ts.isVariableStatement(statement) || !(statement.declarationList.flags & ts.NodeFlags.Const) || statement.modifiers?.length) fail('executable frontmatter or route exports require review');
139
+ for (const declaration of statement.declarationList.declarations) {
140
+ const value = declaration.initializer;
141
+ if (layout && ts.isObjectBindingPattern(declaration.name) && value && ts.isPropertyAccessExpression(value) && ts.isIdentifier(value.expression) && value.expression.text === 'Astro' && value.name.text === 'props') {
142
+ for (const binding of declaration.name.elements) {
143
+ if (binding.dotDotDotToken || binding.propertyName || !ts.isIdentifier(binding.name) || binding.initializer && !ts.isStringLiteral(binding.initializer)) fail('layout props require literal defaults and names');
144
+ constants.set(binding.name.text,{ prop:true });
145
+ }
146
+ continue;
147
+ }
148
+ if (!ts.isIdentifier(declaration.name) || !value || !(ts.isStringLiteral(value) || ts.isNumericLiteral(value) || [ts.SyntaxKind.TrueKeyword,ts.SyntaxKind.FalseKeyword,ts.SyntaxKind.NullKeyword].includes(value.kind))) fail('frontmatter must contain only immutable primitive literals');
149
+ if (/^__(?:cms|universalcms)/i.test(declaration.name.text)) fail('reserved generated identifiers already exist');
150
+ constants.set(declaration.name.text,{ value:ts.isStringLiteral(value) ? value.text : value.getText(file) });
151
+ }
152
+ }
153
+ }
154
+ return { end, imports, constants };
155
+ }
156
+
157
+ function attributes(node, { layout = false, props = new Map(), pageLayout = false } = {}) {
158
+ const names = new Set();
159
+ for (const attribute of node.attributes ?? []) {
160
+ if (names.has(attribute.name)) fail('duplicate native attributes'); names.add(attribute.name);
161
+ if(node.name==='meta'&&attribute.name.toLowerCase()==='http-equiv')fail('HTTP-equivalent metadata requires explicit routing and security review');
162
+ if (/^(?:set:|client:|server:|on|data-cms-|data-universalcms)/i.test(attribute.name) || attribute.name === 'is:raw' || attribute.name === 'hidden') fail('dynamic, private or already-bound native attributes require review');
163
+ if (attribute.kind !== 'quoted' && attribute.kind !== 'empty') {
164
+ if (!(layout && attribute.kind === 'expression' && props.has(attribute.value.trim()))) fail('dynamic native attributes require review');
165
+ }
166
+ if (pageLayout && attribute.kind !== 'quoted') fail('layout arguments must be literal strings');
167
+ if (node.name === 'a' && attribute.name === 'href' && (attribute.kind !== 'quoted' || !publicHref(decodeText(attribute.value), {mail:true}))) fail('native links need a safe literal href');
168
+ }
169
+ }
170
+
171
+ async function inspectLayout(root, filename, source) {
172
+ const tree = await parsed(source), front = inspectFrontmatter(tree.ast,source,{layout:true});
173
+ const top = significant(tree.ast.children).filter(node => !['frontmatter','doctype'].includes(node.type) && !(node.type==='element'&&node.name==='style'));
174
+ if (top.length !== 1 || top[0].type !== 'element' || top[0].name !== 'html') fail('a layout must be one literal HTML document');
175
+ let slots = 0, bodies = 0;
176
+ const visit = (node, inBody = false, inHead = false) => {
177
+ if (node.type === 'frontmatter' || node.type === 'doctype' || node.type === 'comment' || node.type === 'text') return;
178
+ if (node.type === 'expression') {
179
+ const expression = (node.children ?? []).map(child => child.type === 'text' ? child.value : '').join('').trim();
180
+ if (!inHead || !front.constants.has(expression) || node.children.some(child => child.type !== 'text')) fail('layout expressions outside literal head props require review');
181
+ return;
182
+ }
183
+ if (node.type !== 'element' || forbiddenTags.has(node.name) || node.name === 'script' || node.name === 'svg') fail('custom layout components, scripts or native chrome require review');
184
+ attributes(node,{layout:true,props:front.constants});
185
+ tree.range(node);
186
+ if (node.name === 'body') { if (++bodies !== 1) fail('ambiguous layout body'); inBody = true; }
187
+ if (node.name === 'head') inHead = true;
188
+ if (node.name === 'slot') {
189
+ if (!inBody || node.attributes.length || significant(node.children).length || ++slots !== 1) fail('layout requires exactly one unconditional default slot');
190
+ return;
191
+ }
192
+ if (node.name === 'style') return;
193
+ for (const child of node.children ?? []) visit(child,inBody,inHead);
194
+ };
195
+ visit(top[0]);
196
+ for(const node of tree.ast.children)if(node.type==='element'&&node.name==='style'){attributes(node);tree.range(node);}
197
+ if (slots !== 1 || bodies !== 1) fail('layout requires one body and one unconditional default slot');
198
+ return { path:filename, content:source, sha256:contentDigest(source), state:'unchanged' };
199
+ }
200
+
201
+ async function layoutFor(root, filename, front, node) {
202
+ const imported = front.imports.get(node.name);
203
+ if (!root || !imported) fail('unresolved native layout or component');
204
+ const target = path.posix.normalize(path.posix.join(path.posix.dirname(filename),imported));
205
+ if (!/^src\/(?:layouts|components)\/[A-Za-z0-9_/-]+\.astro$/.test(target) || target.split('/').some(part => part.startsWith('.'))) fail('layout import must stay within ordinary public src layouts or components');
206
+ const content = await ordinary(root,target);
207
+ if (content === null) fail('layout import is missing');
208
+ return inspectLayout(root,target,content);
209
+ }
210
+
211
+ async function ordinary(root, filename) {
212
+ const content = await readProjectFile(root,filename);
213
+ if (content !== null && (await lstat(path.join(root,filename))).nlink !== 1) fail('linked source files require review');
214
+ return content;
215
+ }
216
+
217
+ /** Pure parsing apart from an optional, bounded relative layout read. */
218
+ export async function extractAstroPage({ source, slug, locale = 'en', filename = 'src/pages/index.astro', root, websiteOrigin, allowNativeChrome = false } = {}) {
219
+ if (typeof slug !== 'string' || !/^[a-z0-9]+(?:[-/][a-z0-9]+)*$/.test(slug) || slug.length > 160) fail('invalid public page identity');
220
+ try { if (Intl.getCanonicalLocales(locale)[0] !== locale) fail('noncanonical content locale'); } catch { fail('invalid content locale'); }
221
+ if (/\b(?:astroBound|AstroWebsite|__cms)\b|@universalcms\/connect\//.test(source ?? '')) fail('existing generated bindings cannot be wrapped twice');
222
+ const nativeChrome = allowNativeChrome ? (await inspectNativeChromeTemplate(source)).descriptor : undefined;
223
+ const tree = await parsed(source), front = inspectFrontmatter(tree.ast,source);
224
+ const top = significant(tree.ast.children).filter(node => !['frontmatter','doctype'].includes(node.type));
225
+ const styles = top.filter(node => node.type === 'element' && node.name === 'style');
226
+ const roots = top.filter(node => !styles.includes(node));
227
+ if (roots.length !== 1) fail('page needs one literal HTML document, main element or inspected layout');
228
+ const outer = roots[0], imports = [];
229
+ let body, scaffold = false;
230
+ if (outer.type === 'component') {
231
+ attributes(outer,{pageLayout:true});
232
+ imports.push(await layoutFor(root,filename,front,outer)); body = outer;
233
+ } else if (outer.type === 'element' && outer.name === 'html') {
234
+ const children = significant(outer.children), bodies = children.filter(node => node.type === 'element' && node.name === 'body');
235
+ if (bodies.length !== 1 || children.some(node => node.type !== 'element' || !['head','body'].includes(node.name)) || children.filter(node => node.name === 'head').length > 1) fail('page document needs one literal body');
236
+ body = bodies[0];
237
+ } else if (outer.type === 'element' && outer.name === 'main') { body = outer; scaffold = true; }
238
+ else fail('unsupported page root');
239
+ if (front.imports.size !== imports.length) fail('unused or unresolved component imports require review');
240
+ const blocks = [], edits = [], declarations = [], bindings = {};
241
+ let textBudget = 0, title;
242
+ const textNodes = (node, { nativeLink = false } = {}) => {
243
+ const result = [];
244
+ const visit = child => {
245
+ if (child.type === 'comment') return;
246
+ if (child.type === 'text') { result.push(child); return; }
247
+ if (child.type !== 'element' || !inlineTags.has(child.name)) fail('editorial blocks require literal text and inline native elements');
248
+ if (child.name === 'a' && !nativeLink) fail('links within editable text require explicit bindings');
249
+ attributes(child);
250
+ if (child.name === 'br') { result.push({break:true}); return; }
251
+ for (const nested of child.children ?? []) visit(nested);
252
+ };
253
+ for (const child of node.children ?? []) visit(child);
254
+ return result;
255
+ };
256
+ const emit = (node,type,text,data,innerEdits) => {
257
+ if (!text && type !== 'image') fail('empty editorial blocks require review');
258
+ textBudget += text.length;
259
+ if (blocks.length >= ASTRO_SOURCE_LIMITS.blocks || textBudget > ASTRO_SOURCE_LIMITS.text) fail('editorial content exceeds its budget');
260
+ const range = tree.range(node), id = uuid(`universalcms-astro-v1:${locale}:${slug}:${range.start}:${range.end}:${type}`), variable = `__cmsBlock${blocks.length}`;
261
+ const block = {id,version:1,type,text,...(data ? {data} : {})}; blocks.push(block);
262
+ const slot = `slot-${token(id)}`; bindings[slot] = {variants:[{values:[],id}]};
263
+ declarations.push(`const ${variable} = astroBound(__cms, ${JSON.stringify(id)}, ${JSON.stringify(type)});`);
264
+ let native = source.slice(range.start,range.end);
265
+ const replacements = innerEdits(variable,range);
266
+ for (const edit of replacements.sort((a,b) => b.start-a.start)) native = native.slice(0,edit.start-range.start)+edit.text+native.slice(edit.end-range.start);
267
+ edits.push({...range,text:`{${variable} && (${native})}`});
268
+ if (type === 'heading' && (!title || node.name === 'h1' && title.tag !== 'h1')) title = {text,id,tag:node.name};
269
+ };
270
+ const visit = (node, inBody = false) => {
271
+ if (node.type === 'frontmatter' || node.type === 'doctype' || node.type === 'comment') return;
272
+ if (node.type === 'text') { if (inBody && node.value.trim()) fail('uncontained native text needs an explicit editorial block'); return; }
273
+ if (node === outer && node.type === 'component') { for (const child of node.children) visit(child,true); return; }
274
+ if (node.type !== 'element') fail('template expressions or unresolved components require review');
275
+ if (nativeChrome && ['header','footer'].includes(node.name)) return;
276
+ attributes(node); tree.range(node);
277
+ if (forbiddenTags.has(node.name) || node.name === 'script' || node.name === 'svg' || node.name === 'slot') fail('native chrome, forms, scripts or opaque elements need explicit settings composition');
278
+ if (node.name === 'head') {
279
+ const checkHead = child => {
280
+ if (child.type === 'text' || child.type === 'comment') return;
281
+ if (child.type !== 'element' || !['title','meta','link','style'].includes(child.name)) fail('dynamic page metadata requires review');
282
+ attributes(child); tree.range(child);
283
+ if (child.name !== 'style' && child.children?.some(item => item.type !== 'text' && item.type !== 'comment')) fail('dynamic page metadata requires review');
284
+ };
285
+ for (const child of node.children ?? []) checkHead(child); return;
286
+ }
287
+ if (node.name === 'style') return;
288
+ if (node === body) inBody = true;
289
+ if (inBody && (/^h[1-6]$/.test(node.name) || node.name === 'p')) {
290
+ const nodes = textNodes(node), text = normalize(nodes.map(child => child.break ? ' ' : decodeText(child.value)).join(''));
291
+ let first = true;
292
+ emit(node,node.name === 'p' ? 'paragraph':'heading',text,node.name !== 'p' && Number(node.name[1]) > 1 ? {level:Math.min(4,Number(node.name[1]))}:undefined,(variable) => nodes.filter(child => !child.break && child.value.trim()).map(child => {
293
+ // Preserve the original inline text placement for the imported revision.
294
+ // Later editorial replacements occupy the first native text slot, while
295
+ // every original wrapper and its attributes remain owned by the website.
296
+ const range = tree.range(child), replacement = `{${variable}.text === ${JSON.stringify(text)} ? ${JSON.stringify(decodeText(child.value))} : ${first ? `${variable}.text` : "''"}}`; first = false;
297
+ return {...range,text:replacement};
298
+ }));
299
+ return;
300
+ }
301
+ if (inBody && ['ul','ol'].includes(node.name)) {
302
+ const items = significant(node.children);
303
+ if (!items.length || items.some(child => child.type !== 'element' || child.name !== 'li')) fail('lists need literal list items');
304
+ const values = items.map(child => { attributes(child); return normalize(textNodes(child).map(part => part.break ? ' ' : decodeText(part.value)).join('')); });
305
+ if (items.some(item => significant(item.children).some(child => child.type !== 'text'))) fail('formatted list items require explicit native bindings');
306
+ if (values.some(value => !value)) fail('empty list items require review');
307
+ const range = tree.range(node), opening = openingTag(source,range.start).end;
308
+ const closing = range.end - (`</${node.name}>`).length;
309
+ const itemRange = tree.range(items[0]), itemOpen = source.slice(itemRange.start,openingTag(source,itemRange.start).end);
310
+ // Every list item must have the same native attributes to avoid silently
311
+ // changing per-row layout while supporting CMS additions and deletions.
312
+ if (items.some(item => { const r=tree.range(item); return source.slice(r.start,openingTag(source,r.start).end)!==itemOpen; })) fail('distinct list item layouts require explicit bindings');
313
+ emit(node,'list',values.join('\n'),undefined,variable => [{start:opening,end:closing,text:`{${variable}.text.split('\\n').map((__cmsItem) => (${itemOpen}{__cmsItem}</li>))}`}]);
314
+ return;
315
+ }
316
+ if (inBody && node.name === 'img') {
317
+ const attrs = new Map(node.attributes.map(attribute => [attribute.name,attribute]));
318
+ const src = decodeText(attrs.get('src')?.value ?? ''), alt = decodeText(attrs.get('alt')?.value ?? '');
319
+ if (!safeUrl(src) || attrs.has('srcset') || !attrs.has('alt') || alt.length > 300) fail('images need a literal safe src and bounded alt without responsive source expressions');
320
+ const url = src.startsWith('/') ? new URL(src,imageOrigin(websiteOrigin)).href : src;
321
+ emit(node,'image','',{url,alt},(variable,range) => {
322
+ const updates = [];
323
+ for (const [name,value] of [['src',`${variable}.url`],['alt',`${variable}.alt`]]) {
324
+ updates.push({...tree.range(attrs.get(name)),text:`${name}={${value}}`});
325
+ }
326
+ return updates;
327
+ }); return;
328
+ }
329
+ if (inBody && node.name === 'a') {
330
+ // Navigation text is native behavior, not an editable editorial block.
331
+ if (!(node.attributes ?? []).some(attribute => attribute.name === 'href')) fail('native links need a safe literal href');
332
+ textNodes(node,{nativeLink:true}); return;
333
+ }
334
+ for (const child of node.children ?? []) visit(child,inBody);
335
+ };
336
+ visit(outer);
337
+ for (const style of styles) { attributes(style); tree.range(style); }
338
+ if (!title || !blocks.length || title.text.length > 200) fail('each public page needs a literal editorial heading');
339
+ const document = {schemaVersion:1,title:title.text,excerpt:(blocks.find(block => block.type === 'paragraph')?.text ?? '').slice(0,1000),fields:{},taxonomy:{categories:[],tags:[]},blocks};
340
+ const inventory = {identity:{kind:'page',slug,locale},baseline:document,titleBlockId:title.id,bindings,...(nativeChrome ? {nativeChrome} : {}),imports:imports.map(({path,sha256})=>({path,sha256}))};
341
+ const bodyRange = tree.range(body);
342
+ const openEnd = openingTag(source,bodyRange.start).end, closeStart = bodyRange.end - (`</${body.name}>`).length;
343
+ const island = '<AstroWebsite pathname={__cms.pathname} scope={__cms.scope} initialResult={__cms.result} initialSettings={__cms.settings} inventory={__cms.publicInventory} client:load>';
344
+ if (scaffold) {
345
+ edits.push({start:bodyRange.start,end:bodyRange.start,text:'<html><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width" /></head><body>'+island});
346
+ edits.push({start:bodyRange.end,end:bodyRange.end,text:'</AstroWebsite></body></html>'});
347
+ } else {
348
+ edits.push({start:openEnd,end:openEnd,text:island});
349
+ edits.push({start:closeStart,end:closeStart,text:'</AstroWebsite>'});
350
+ }
351
+ const generated = `\nimport { astroBound } from '@universalcms/connect/astro-runtime';\nimport { AstroWebsite } from '@universalcms/connect/astro-website';\nimport '@universalcms/connect/website.css';\nimport '@universalcms/connect/publication.css';\nconst __cms = Astro.locals.universalcms;\n${declarations.join('\n')}\n`;
352
+ if (front.end) { const close = source.lastIndexOf('---',front.end-1); edits.push({start:close,end:close,text:generated}); }
353
+ else edits.push({start:0,end:0,text:`---${generated}---\n`});
354
+ let transformed = source;
355
+ for (const edit of edits.sort((a,b)=>b.start-a.start || b.end-a.end)) transformed = transformed.slice(0,edit.start)+edit.text+transformed.slice(edit.end);
356
+ await parsed(transformed);
357
+ return {entry:{kind:'page',slug,locale,document},inventory,source:transformed,imports,complete:true,warnings:[]};
358
+ }
359
+
360
+ async function routeFiles(root) {
361
+ const result = [], paths = new Set(); let dirs=0, files=0;
362
+ const walk = async (directory,segments=[],depth=0) => {
363
+ if (++dirs > ASTRO_SOURCE_LIMITS.directories || depth > 12) fail('route inventory exceeds its directory budget');
364
+ const target=path.join(root,directory), info=await lstat(target);
365
+ if (!info.isDirectory() || info.isSymbolicLink() || await realpath(target)!==target) fail('route directories must be ordinary local directories');
366
+ for (const item of (await readdir(target,{withFileTypes:true})).sort((a,b)=>a.name.localeCompare(b.name))) {
367
+ if (++files > ASTRO_SOURCE_LIMITS.files) fail('route inventory exceeds its file budget');
368
+ if (item.isSymbolicLink()) fail('linked routes require review');
369
+ if (item.name.startsWith('_') || item.name.startsWith('.')) continue;
370
+ const filename=`${directory}/${item.name}`;
371
+ if (item.isDirectory()) {
372
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(item.name) || privateSegment.test(item.name)) fail('dynamic or private route ownership requires review');
373
+ await walk(filename,[...segments,item.name],depth+1); continue;
374
+ }
375
+ if (!item.isFile() || !/^[a-z0-9]+(?:-[a-z0-9]+)*\.astro$/.test(item.name)) fail('nonliteral Astro pages or endpoint routes require review');
376
+ const basename=item.name.slice(0,-6);
377
+ if (privateSegment.test(basename) || basename==='404' || basename==='500') fail('private or error routes require review');
378
+ const pathname='/'+[...segments,...(basename==='index'?[]:[basename])].join('/');
379
+ if (isExcludedPublicationPath(pathname)) fail('private or reserved route ownership requires review');
380
+ if (paths.has(pathname)) fail('duplicate Astro page paths'); paths.add(pathname);
381
+ result.push({path:pathname,sourceFile:filename,kind:'page',slug:pathname==='/'?'home':pathname.slice(1)});
382
+ }
383
+ };
384
+ for (const name of ['src/middleware.ts','src/middleware.js','src/middleware/index.ts','src/middleware/index.js']) if (await ordinary(root,name)!==null) fail('existing middleware requires an access review');
385
+ await walk('src/pages');
386
+ if (!result.length || result.some(row=>row.path==='/home') && result.some(row=>row.path==='/')) fail('missing pages or ambiguous home identity');
387
+ return result;
388
+ }
389
+
390
+ /** Read public pages and immutable originals; never execute Astro/project code. */
391
+ export async function inspectAstroSource(directory,{locale='en',websiteOrigin}={}) {
392
+ if(websiteOrigin !== undefined) websiteOrigin = imageOrigin(websiteOrigin);
393
+ const root=await projectRoot(directory), routes=await routeFiles(root), registry=await readContentRegistry(root);
394
+ if (registry?.profile || registry?.routes.some(row=>!row.sourceFile.endsWith('.astro'))) fail('existing content ownership belongs to another adapter');
395
+ const entries=[], inventories={}, prepared=[], observed=new Map(); let totalBytes=0;
396
+ for (const route of routes) {
397
+ const current=await ordinary(root,route.sourceFile); if(current===null)fail('page disappeared during inspection');
398
+ const installed=registry?.routes.find(row=>row.path===route.path);
399
+ let original=current;
400
+ if (installed) {
401
+ if (installed.sourceFile!==route.sourceFile || installed.slug!==route.slug || installed.locale!==locale || installed.kind!=='page') fail('saved Astro route identity changed');
402
+ original=(await readFrozenContent(root,installed)).texts.original;
403
+ }
404
+ const extracted = await extractAstroPage({source:original,slug:route.slug,locale,filename:route.sourceFile,root,websiteOrigin});
405
+ if (installed && extracted.source!==current) fail('generated Astro page changed');
406
+ totalBytes+=Buffer.byteLength(original);
407
+ observed.set(route.sourceFile,{path:route.sourceFile,content:current,state:'unchanged',sha256:contentDigest(current)});
408
+ for (const item of extracted.imports) { observed.set(item.path,item); totalBytes+=Buffer.byteLength(item.content); }
409
+ if(totalBytes>ASTRO_SOURCE_LIMITS.totalBytes)fail('source inventory exceeds its byte budget');
410
+ entries.push(extracted.entry); inventories[route.path]=extracted.inventory;
411
+ prepared.push({...route,locale,original,current,installed,extracted});
412
+ }
413
+ if(registry && registry.routes.length!==routes.length)fail('previously bound routes were removed');
414
+ const diagnostics={discoveredPages:routes.length,automaticPages:entries.length,skippedPages:0,unsupportedRoutes:0,warnings:[]};
415
+ const result={entries,snapshot:{version:1,entries,inventories},routes:prepared.map(({path,kind,slug,locale})=>({path,kind,slug,locale})),sourceFiles:[...observed.values()].map(({path,sha256})=>({path,sha256})),diagnostics};
416
+ inspections.set(result,{root,prepared,observed,registry,digest:contentDigest(json(result)),routeFiles:routes,websiteOrigin});
417
+ return result;
418
+ }
419
+
420
+ /** Core-compatible plan; originals and inventories precede all page replacement. */
421
+ export async function planAstroContent(directory,configuration,inspection) {
422
+ const root=await projectRoot(directory),config=validatePublicationConfig(configuration);
423
+ inspection ??= await inspectAstroSource(root,{locale:config.routes[0]?.locale,websiteOrigin:config.websiteOrigin});
424
+ const state=inspections.get(inspection);
425
+ if(!state || state.root!==root)fail('use the current inspection for this website');
426
+ if(state.websiteOrigin !== undefined && state.websiteOrigin !== config.websiteOrigin)fail('source and publication website origins must match');
427
+ if(contentDigest(json(inspection))!==state.digest)fail('the public source inspection changed before planning');
428
+ if(json(await routeFiles(root))!==json(state.routeFiles))fail('route inventory changed after inspection');
429
+ if(config.routes.length!==inspection.routes.length || config.routes.some(row=>!inspection.routes.some(other=>['path','kind','slug','locale'].every(key=>row[key]===other[key]))))fail('publication identities must match exact Astro source routes');
430
+ const files=[],rows=[];
431
+ for(const observed of state.observed.values()) if(await ordinary(root,observed.path)!==observed.content)fail('source changed after inspection');
432
+ for(const item of state.prepared) {
433
+ if(item.installed) {
434
+ const frozen=await readFrozenContent(root,item.installed);
435
+ if(json(frozen.inventory)!==json(item.extracted.inventory))fail('frozen Astro inventory or layout imports changed');
436
+ for(const name of ['original','native','inventory','source'])files.push({path:item.installed[`${name}File`],content:frozen.texts[name],state:'unchanged'});
437
+ rows.push(item.installed);continue;
438
+ }
439
+ const suffix=token(`${item.path}:${item.kind}:${item.slug}:${item.locale}`),nativeFile=`.universalcms/content/${suffix}.native.txt`,originalFile=`.universalcms/content/${suffix}.original.txt`,inventoryFile=`.universalcms/content/${suffix}.json`;
440
+ for(const filename of [nativeFile,originalFile,inventoryFile])if(await ordinary(root,filename)!==null)fail('a prospective frozen content file already exists');
441
+ const inventoryText=json(item.extracted.inventory),source=item.extracted.source;
442
+ const row={path:item.path,kind:item.kind,slug:item.slug,locale:item.locale,sourceFile:item.sourceFile,nativeFile,originalFile,inventoryFile,sourceHash:contentDigest(source),nativeHash:contentDigest(source),originalHash:contentDigest(item.original),inventoryHash:contentDigest(inventoryText)};
443
+ files.push({path:originalFile,content:item.original,state:'create'},{path:nativeFile,content:source,state:'create'},{path:inventoryFile,content:inventoryText,state:'create'},{path:item.sourceFile,content:source,state:'update',previous:item.current});rows.push(row);
444
+ }
445
+ for(const observed of state.observed.values())if(!files.some(file=>file.path===observed.path))files.push({path:observed.path,content:observed.content,state:'unchanged'});
446
+ const registry=validateContentRegistry({version:1,routes:rows}),text=json(registry),before=await ordinary(root,CONTENT_BINDINGS_FILE);
447
+ if(before!==null && before!==text)fail('saved Astro content registry changed');
448
+ files.push({path:CONTENT_BINDINGS_FILE,content:text,state:before===null?'create':'unchanged'});
449
+ if(files.some(file=>Buffer.byteLength(file.content)>ASTRO_SOURCE_LIMITS.bytes))fail('generated binding exceeds its per-file budget');
450
+ return {root,framework:'astro',files,routes:rows,entries:inspection.entries,snapshot:inspection.snapshot};
451
+ }
@@ -0,0 +1,5 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { WebsiteResult } from './website-runtime.mjs';
3
+ import type { Inventory } from './react-publication.mjs';
4
+ export type AstroWebsiteProps = { pathname: string; scope: string; initialResult: WebsiteResult; initialSettings: unknown; inventory?: Inventory; children?: ReactNode; formEndpoint?: string; chrome?: boolean };
5
+ export function AstroWebsite(props: AstroWebsiteProps): ReactNode;
@@ -0,0 +1,42 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import React, { useEffect } from 'react';
4
+ import { CmsWebsite, CmsWebsitePage } from './react-website.mjs';
5
+ import { PublishedDocumentProvider, CmsAdditionalContent } from './react-publication.mjs';
6
+ 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()) : [])]);
7
+ /** Astro renders native slots once on the server. Let the existing publication
8
+ * guard reload them when a revision changes instead of marking old static HTML
9
+ * as freshly rendered through React's document-only update handler. */
10
+ function NativeRevisionGuard({ pathname }) {
11
+ useEffect(() => {
12
+ const documentRoot = document.documentElement;
13
+ const changed = (event) => {
14
+ const detail = event.detail;
15
+ const currentPath = window.location.pathname.replace(/\/$/, '') || '/';
16
+ if (detail?.path !== pathname || document.documentElement !== documentRoot || currentPath !== pathname && !(pathname === '/' && currentPath === '/index.html'))
17
+ return;
18
+ // Keep the event uncanceled so the blocking publication bootstrap clears
19
+ // the old document and reloads. Stop React's document-only refresh handler
20
+ // from marking static Astro children as the newly rendered revision.
21
+ event.stopImmediatePropagation();
22
+ };
23
+ window.addEventListener('universalcms:publication-change', changed, { capture: true });
24
+ return () => window.removeEventListener('universalcms:publication-change', changed, { capture: true });
25
+ }, [pathname]);
26
+ return null;
27
+ }
28
+ /** One hydrated owner provides the existing all-section consent/settings logic.
29
+ * Astro's native children stay intact; only explicitly unbound blocks are React. */
30
+ export function AstroWebsite({ pathname, scope, initialResult, initialSettings, inventory, children, formEndpoint, chrome = true }) {
31
+ if (!inventory)
32
+ return _jsx(CmsWebsitePage, { scope: scope, pathname: pathname, initialResult: initialResult, initialSettings: initialSettings, chrome: chrome });
33
+ if (initialResult.state !== 'published')
34
+ return _jsx("main", { role: "alert", children: _jsx("h1", { children: "Page temporarily unavailable" }) });
35
+ const entry = initialResult.entry;
36
+ const original = new Map(flatten(inventory.baseline.blocks).map(block => [block.id, block.type]));
37
+ const current = new Map(flatten(entry.document.blocks).map(block => [block.id, block.type]));
38
+ // A changed kind is no longer present in an Astro-native slot. Removing that
39
+ // binding lets CmsAdditionalContent render it with the normal form policy.
40
+ const route = { ...inventory, bindings: Object.fromEntries(Object.entries(inventory.bindings).map(([name, slot]) => [name, { ...slot, variants: slot.variants.filter(value => current.get(value.id) === original.get(value.id)) }])) };
41
+ return _jsxs(CmsWebsite, { scope: scope, initialSettings: initialSettings, entry: entry, applyTemplate: true, chrome: chrome, children: [_jsx(NativeRevisionGuard, { pathname: pathname }), _jsxs(PublishedDocumentProvider, { initialEntry: entry, pathname: pathname, route: route, formEndpoint: formEndpoint, children: [children, _jsx(CmsAdditionalContent, { includeUnbound: true })] })] });
42
+ }