@pygmalionjs/pygmalion 0.8.1 → 0.9.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.
Files changed (129) hide show
  1. package/binding-source.d.ts +25 -0
  2. package/dist-lib/{CameraLayer-B2D29VXQ.js → CameraLayer-BdOko8XE.js} +17 -17
  3. package/dist-lib/pygmalion.js +24599 -12746
  4. package/dist-lib/{runtime-BAy8W1mJ.js → runtime-Dxb-IaLN.js} +12414 -11114
  5. package/dist-lib/style.css +1 -1
  6. package/dist-lib/testing.js +10 -10
  7. package/dist-lib/types/binding/catalog.d.ts +10 -0
  8. package/dist-lib/types/binding/contracts.d.ts +202 -0
  9. package/dist-lib/types/binding/documentCommands.d.ts +38 -0
  10. package/dist-lib/types/binding/index.d.ts +6 -0
  11. package/dist-lib/types/binding/preview.d.ts +37 -0
  12. package/dist-lib/types/binding/source.d.ts +109 -0
  13. package/dist-lib/types/binding/useDataProjection.d.ts +9 -0
  14. package/dist-lib/types/binding/validate.d.ts +8 -0
  15. package/dist-lib/types/core/runtime.d.ts +6 -0
  16. package/dist-lib/types/document/appearance.d.ts +112 -0
  17. package/dist-lib/types/document/assets.d.ts +204 -0
  18. package/dist-lib/types/document/components.d.ts +169 -0
  19. package/dist-lib/types/document/contracts.d.ts +171 -0
  20. package/dist-lib/types/document/engine.d.ts +20 -0
  21. package/dist-lib/types/document/exportSettings.d.ts +45 -0
  22. package/dist-lib/types/document/geometry.d.ts +11 -0
  23. package/dist-lib/types/document/layout.d.ts +53 -0
  24. package/dist-lib/types/document/prototype.d.ts +424 -0
  25. package/dist-lib/types/document/prototypeReferences.d.ts +3 -0
  26. package/dist-lib/types/document/richText.d.ts +185 -0
  27. package/dist-lib/types/document/styles.d.ts +216 -0
  28. package/dist-lib/types/document/svgImport.d.ts +17 -0
  29. package/dist-lib/types/document/vector.d.ts +222 -0
  30. package/dist-lib/types/editor/designCompiler.d.ts +7 -0
  31. package/dist-lib/types/editor/designImport.d.ts +7 -0
  32. package/dist-lib/types/editor/inspect.d.ts +8 -0
  33. package/dist-lib/types/editor/revisionCatalogInstall.d.ts +1 -1
  34. package/dist-lib/types/editor/screenInteractions.d.ts +3 -3
  35. package/dist-lib/types/editor/store.d.ts +1 -1
  36. package/dist-lib/types/host/project.d.ts +44 -0
  37. package/dist-lib/types/host/registryConnections.d.ts +11 -0
  38. package/dist-lib/types/host/sourceFilePlan.d.ts +15 -0
  39. package/dist-lib/types/host/sourceWriteback.d.ts +50 -0
  40. package/dist-lib/types/lib.d.ts +101 -3
  41. package/dist-lib/types/token-library/bindings.d.ts +62 -0
  42. package/dist-lib/types/token-library/contracts.d.ts +117 -0
  43. package/dist-lib/types/token-library/engine.d.ts +13 -0
  44. package/dist-lib/types/workspace/WorkspaceShell.d.ts +9 -0
  45. package/dist-lib/types/workspace/application/ApplicationWorkspace.d.ts +3 -0
  46. package/dist-lib/types/workspace/configuration.d.ts +39 -0
  47. package/dist-lib/types/workspace/contracts.d.ts +64 -0
  48. package/dist-lib/types/workspace/controller.d.ts +35 -0
  49. package/dist-lib/types/workspace/documentSessions.d.ts +42 -0
  50. package/dist-lib/types/workspace/edit/AppearancePanel.d.ts +23 -0
  51. package/dist-lib/types/workspace/edit/ArrangeLayers.d.ts +10 -0
  52. package/dist-lib/types/workspace/edit/AssetsPanel.d.ts +16 -0
  53. package/dist-lib/types/workspace/edit/CanvasGuides.d.ts +19 -0
  54. package/dist-lib/types/workspace/edit/ComponentPanel.d.ts +11 -0
  55. package/dist-lib/types/workspace/edit/DataPanel.d.ts +16 -0
  56. package/dist-lib/types/workspace/edit/DesignWorkspace.d.ts +10 -0
  57. package/dist-lib/types/workspace/edit/LayerTree.d.ts +14 -0
  58. package/dist-lib/types/workspace/edit/LayoutPanel.d.ts +13 -0
  59. package/dist-lib/types/workspace/edit/PrototypePanel.d.ts +12 -0
  60. package/dist-lib/types/workspace/edit/ScopeTransferDialog.d.ts +40 -0
  61. package/dist-lib/types/workspace/edit/SelectionHandles.d.ts +52 -0
  62. package/dist-lib/types/workspace/edit/StylesPanel.d.ts +8 -0
  63. package/dist-lib/types/workspace/edit/TextEditor.d.ts +50 -0
  64. package/dist-lib/types/workspace/edit/VectorEditor.d.ts +89 -0
  65. package/dist-lib/types/workspace/edit/VectorToolbar.d.ts +5 -0
  66. package/dist-lib/types/workspace/edit/canvasGuideModel.d.ts +102 -0
  67. package/dist-lib/types/workspace/edit/clipboard.d.ts +35 -0
  68. package/dist-lib/types/workspace/edit/duplicateMetadata.d.ts +2 -0
  69. package/dist-lib/types/workspace/edit/geometryHandles.d.ts +224 -0
  70. package/dist-lib/types/workspace/edit/layerArrangement.d.ts +39 -0
  71. package/dist-lib/types/workspace/edit/layerMoves.d.ts +44 -0
  72. package/dist-lib/types/workspace/edit/projection.d.ts +12 -0
  73. package/dist-lib/types/workspace/edit/scopeTransfer.d.ts +96 -0
  74. package/dist-lib/types/workspace/edit/textStyles.d.ts +75 -0
  75. package/dist-lib/types/workspace/edit/vectorTools.d.ts +136 -0
  76. package/dist-lib/types/workspace/legacyModeAdapter.d.ts +13 -0
  77. package/dist-lib/types/workspace/source/ResponsiveLayoutPanel.d.ts +20 -0
  78. package/dist-lib/types/workspace/source/SourceReview.d.ts +11 -0
  79. package/dist-lib/types/workspace/source/appearanceSource.d.ts +19 -0
  80. package/dist-lib/types/workspace/source/compiler.d.ts +5 -0
  81. package/dist-lib/types/workspace/source/contracts.d.ts +156 -0
  82. package/dist-lib/types/workspace/source/controller.d.ts +50 -0
  83. package/dist-lib/types/workspace/source/documentRevision.d.ts +2 -0
  84. package/dist-lib/types/workspace/source/draftPreviewClient.d.ts +13 -0
  85. package/dist-lib/types/workspace/source/draftRecipe.d.ts +30 -0
  86. package/dist-lib/types/workspace/source/flowContracts.d.ts +72 -0
  87. package/dist-lib/types/workspace/source/prototypeSource.d.ts +4 -0
  88. package/dist-lib/types/workspace/source/responsive.d.ts +13 -0
  89. package/dist-lib/types/workspace/source/responsiveContracts.d.ts +78 -0
  90. package/dist-lib/types/workspace/source/structure.d.ts +21 -0
  91. package/dist-lib/types/workspace/source/textSource.d.ts +28 -0
  92. package/dist-lib/types/workspace/tokens/TokenLibraryContext.d.ts +11 -0
  93. package/dist-lib/types/workspace/tokens/TokenLibraryWorkspace.d.ts +14 -0
  94. package/dist-lib/types/workspace/tokens/contracts.d.ts +74 -0
  95. package/dist-lib/types/workspace/tokens/controller.d.ts +44 -0
  96. package/dist-lib/types/workspace/view/PrototypePlayer.d.ts +17 -0
  97. package/dist-lib/types/workspace/view/StoryboardWorkspace.d.ts +3 -0
  98. package/dist-lib/types/workspace/view/prototypePlayback.d.ts +46 -0
  99. package/draft-preview.d.ts +54 -0
  100. package/inspect.d.ts +2 -0
  101. package/node/design-session.mjs +3 -0
  102. package/node/inspect-plugin.mjs +3 -1
  103. package/node/inspect-writeback.mjs +18 -2
  104. package/node/preview-artifact-store.mjs +10 -7
  105. package/node/source-archive.mjs +88 -0
  106. package/node/source-bindings.mjs +65 -0
  107. package/node/source-file-lock.mjs +15 -0
  108. package/node/source-file-plan.mjs +162 -0
  109. package/node/source-file-proofs.mjs +44 -0
  110. package/node/source-responsive-codec.d.mts +17 -0
  111. package/node/source-responsive-codec.mjs +133 -0
  112. package/node/source-responsive.mjs +189 -0
  113. package/node/source-structure-reconcile.mjs +186 -0
  114. package/node/source-structure-wrapper-codec.d.mts +20 -0
  115. package/node/source-structure-wrapper-codec.mjs +205 -0
  116. package/node/source-structure.mjs +404 -0
  117. package/node/token-source-references.mjs +136 -0
  118. package/node/workspace-draft-preview-plugin.mjs +70 -0
  119. package/node/workspace-draft-preview.mjs +231 -0
  120. package/node/workspace-source-identity.d.mts +1 -0
  121. package/node/workspace-source-identity.mjs +21 -0
  122. package/node/workspace-source-plan.mjs +195 -0
  123. package/package.json +52 -6
  124. package/source-files.d.ts +35 -0
  125. package/source-responsive.d.ts +10 -0
  126. package/source-structure.d.ts +155 -0
  127. package/workspace-source-plan.d.ts +44 -0
  128. package/docs/coverage-contracts.md +0 -444
  129. package/docs/screen-state-contract.md +0 -252
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Pure wrapper codec shared by the browser document compiler and the Node source compiler.
3
+ *
4
+ * A source group with a layout compiles to one intrinsic element carrying a generated CSS
5
+ * module class. This module owns the deterministic class name, the layout-to-declaration
6
+ * mapping, and the exact managed block syntax. Spacing spellings come from the host
7
+ * contract; nothing here reads files, evaluates Sass, or knows a product token.
8
+ */
9
+ export const VOID_ELEMENTS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
10
+ export const WRAPPER_ALIGNMENTS = Object.freeze(['start', 'center', 'end', 'stretch']);
11
+ const FLEX_ALIGN = { start: 'flex-start', center: 'center', end: 'flex-end', stretch: 'stretch' };
12
+ const FLEX_DIRECTION = { horizontal: 'row', vertical: 'column' };
13
+ const MEASURES = { crossGap: null, paddingTop: 'padding-top', paddingRight: 'padding-right', paddingBottom: 'padding-bottom', paddingLeft: 'padding-left', minWidth: 'min-width', maxWidth: 'max-width', minHeight: 'min-height', maxHeight: 'max-height' };
14
+ const DISTRIBUTION = { start: 'flex-start', center: 'center', end: 'flex-end', 'space-between': 'space-between' };
15
+ const SOURCE_FIELDS = ['direction', 'gap', 'padding', 'align', 'wrap', 'crossGap', 'distribute', 'columns', ...Object.keys(MEASURES).filter(key => key !== 'crossGap')];
16
+ const MANAGED_PROPERTIES = ['display', 'flex-direction', 'grid-template-columns', 'flex-wrap', 'gap', 'row-gap', 'column-gap', 'padding', 'align-items', 'justify-content', 'justify-items', ...Object.values(MEASURES).filter(Boolean)];
17
+
18
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
19
+ const identifier = value => typeof value === 'string' && /^[A-Za-z_$][\w$]{0,63}$/.test(value);
20
+ const cssValue = value => typeof value === 'string' && value.length > 0 && value.length <= 160 && value === value.trim()
21
+ && !/[;{}\0\r\n]/.test(value) && !/\/\*|\*\/|\/\//.test(value);
22
+ const stylesheetPath = value => typeof value === 'string' && value.length > 0 && value.length <= 512 && /\.s?css$/i.test(value)
23
+ && !value.startsWith('/') && !/^[a-z]:/i.test(value) && !/[\\\x00-\x1f\x7f?#]/.test(value)
24
+ && !value.split('/').some(part => !part || ['.', '..', '.git'].includes(part));
25
+ const escape = value => value.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
26
+ const lineEnding = source => source.includes('\r\n') ? '\r\n' : '\n';
27
+ const problem = (code, message) => Object.assign(new Error(message), { code });
28
+ const stale = message => problem('structure-wrapper-stale', message);
29
+ const masked = source => source.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, value => value.replace(/[^\n]/g, ' '));
30
+
31
+ /** Returns null when the contract is usable, otherwise one explicit reason. */
32
+ export function wrapperContractProblem(contract) {
33
+ if (!record(contract) || Object.keys(contract).some(key => !['stylesheet', 'styleAlias', 'tagName', 'spacing', 'align'].includes(key))) {
34
+ return 'A wrapper contract declares a stylesheet, a style alias, permitted spacing, and optionally a tag name and alignments.';
35
+ }
36
+ if (!stylesheetPath(contract.stylesheet)) return 'The wrapper stylesheet must be a normalized relative CSS or SCSS path.';
37
+ if (!identifier(contract.styleAlias)) return 'The wrapper style alias must be the JSX identifier of the imported stylesheet module.';
38
+ if (contract.tagName !== undefined && (typeof contract.tagName !== 'string' || !/^[a-z][a-z0-9]*$/.test(contract.tagName) || VOID_ELEMENTS.has(contract.tagName))) {
39
+ return 'The wrapper tag must be an intrinsic non-void element.';
40
+ }
41
+ if (!Array.isArray(contract.spacing) || !contract.spacing.length || contract.spacing.length > 200) return 'A wrapper contract lists at least one permitted spacing value.';
42
+ const px = new Set(); const css = new Set();
43
+ for (const entry of contract.spacing) {
44
+ if (!record(entry) || Object.keys(entry).some(key => !['px', 'css'].includes(key)) || typeof entry.px !== 'number' || !Number.isFinite(entry.px)
45
+ || entry.px < 0 || !cssValue(entry.css) || px.has(entry.px) || css.has(entry.css)) {
46
+ return 'Each permitted spacing pairs one nonnegative pixel value with one unique CSS spelling.';
47
+ }
48
+ px.add(entry.px); css.add(entry.css);
49
+ }
50
+ if (contract.align !== undefined && (!Array.isArray(contract.align) || !contract.align.length || new Set(contract.align).size !== contract.align.length
51
+ || contract.align.some(value => !WRAPPER_ALIGNMENTS.includes(value)))) {
52
+ return 'Permitted wrapper alignments must be a unique subset of start, center, end, and stretch.';
53
+ }
54
+ return null;
55
+ }
56
+
57
+ export const wrapperTagName = contract => contract.tagName ?? 'div';
58
+ export const wrapperOpening = (contract, className) => `<${wrapperTagName(contract)} className={${contract.styleAlias}.${className}}>`;
59
+ export const wrapperClosing = contract => `</${wrapperTagName(contract)}>`;
60
+ export const wrapperMarker = groupId => `/* Pygmalion wrapper: ${groupId} */`;
61
+
62
+ /** Deterministic CSS module identifier for one stable group ID; null when the ID cannot derive one. */
63
+ export function wrapperClassName(groupId) {
64
+ if (typeof groupId !== 'string' || groupId.length > 120 || !/^[A-Za-z0-9][\w:./-]*$/.test(groupId)) return null;
65
+ const words = groupId.split(/[^A-Za-z0-9]+/).filter(Boolean);
66
+ const name = `wrap${words.map(word => word[0].toUpperCase() + word.slice(1)).join('')}`;
67
+ return name.length <= 64 ? name : null;
68
+ }
69
+
70
+ /** Returns null for a layout the contract can express, otherwise one explicit reason. */
71
+ export function wrapperLayoutProblem(layout, contract) {
72
+ if (!record(layout) || Object.keys(layout).some(key => ![...SOURCE_FIELDS, 'sizing', 'crossSizing'].includes(key))) return 'A wrapper layout uses only supported source layout fields.';
73
+ if (!Object.hasOwn(FLEX_DIRECTION, layout.direction) && layout.direction !== 'grid') return 'A wrapper layout direction is horizontal, vertical, or grid.';
74
+ for (const property of ['gap', 'padding', ...Object.keys(MEASURES)]) {
75
+ if ((property === 'gap' || property === 'padding' || layout[property] !== undefined) && !contract.spacing.some(entry => entry.px === layout[property])) return `The wrapper ${property} must be one of the permitted spacing values.`;
76
+ }
77
+ if (layout.align !== undefined && !(contract.align ?? WRAPPER_ALIGNMENTS).includes(layout.align)) return 'The wrapper alignment must be one of the permitted alignments.';
78
+ if (['sizing', 'crossSizing'].some(key => layout[key] !== undefined && layout[key] !== 'fixed')) return 'A wrapper element sizes by the application layout; hug sizing is not a source value.';
79
+ if (layout.wrap !== undefined && (typeof layout.wrap !== 'boolean' || layout.direction === 'grid')) return 'Wrapping is a boolean for flex wrappers only.';
80
+ if (layout.columns !== undefined && (layout.direction !== 'grid' || !Number.isInteger(layout.columns) || layout.columns < 1 || layout.columns > 1000)) return 'Grid columns must be an integer from 1 to 1000 on a grid wrapper.';
81
+ if (layout.distribute !== undefined && (!Object.hasOwn(DISTRIBUTION, layout.distribute) || layout.direction === 'grid' && layout.distribute === 'space-between')) return 'Invalid wrapper distribution.';
82
+ for (const axis of ['Width', 'Height']) if (layout[`min${axis}`] !== undefined && layout[`max${axis}`] !== undefined && layout[`min${axis}`] > layout[`max${axis}`]) return 'A wrapper minimum cannot exceed its maximum.';
83
+ return null;
84
+ }
85
+
86
+ /** The source-relevant fields; fixed authoring sizing is not emitted. */
87
+ export function wrapperLayout(layout) {
88
+ return Object.fromEntries(SOURCE_FIELDS.filter(key => layout[key] !== undefined || key === 'columns' && layout.direction === 'grid').map(key => [key, key === 'columns' ? layout.columns ?? 2 : layout[key]]));
89
+ }
90
+
91
+ export function wrapperDeclarations(layout, contract) {
92
+ const reason = wrapperLayoutProblem(layout, contract);
93
+ if (reason) throw problem('structure-layout-invalid', reason);
94
+ const spacing = px => contract.spacing.find(entry => entry.px === px).css;
95
+ const grid = layout.direction === 'grid';
96
+ const declarations = { display: grid ? 'grid' : 'flex', ...(grid ? { 'grid-template-columns': `repeat(${layout.columns ?? 2}, minmax(0, 1fr))` } : { 'flex-direction': FLEX_DIRECTION[layout.direction] }), gap: spacing(layout.gap), padding: spacing(layout.padding), ...(layout.align === undefined ? {} : { 'align-items': FLEX_ALIGN[layout.align] }) };
97
+ if (layout.wrap !== undefined) declarations['flex-wrap'] = layout.wrap ? 'wrap' : 'nowrap';
98
+ if (layout.distribute !== undefined) declarations[grid ? 'justify-items' : 'justify-content'] = DISTRIBUTION[layout.distribute];
99
+ for (const [key, css] of Object.entries(MEASURES)) if (layout[key] !== undefined) declarations[css ?? (layout.direction === 'vertical' ? 'column-gap' : 'row-gap')] = spacing(layout[key]);
100
+ return declarations;
101
+ }
102
+
103
+ /** Every accepted source value must decode through the host's exact token spelling. */
104
+ export function wrapperLayoutFromDeclarations(declarations, contract) {
105
+ const keys = Object.keys(declarations);
106
+ const grid = declarations.display === 'grid';
107
+ if ((!grid && declarations.display !== 'flex') || keys.some(key => !MANAGED_PROPERTIES.includes(key)) || !['gap', 'padding', grid ? 'grid-template-columns' : 'flex-direction'].every(key => keys.includes(key))) throw stale('A managed wrapper block needs its supported display, direction, gap, and padding.');
108
+ if (grid && ('flex-direction' in declarations || 'flex-wrap' in declarations || 'justify-content' in declarations) || !grid && ('grid-template-columns' in declarations || 'justify-items' in declarations)) throw stale('Managed wrapper display and alignment fields must agree.');
109
+ const direction = grid ? 'grid' : Object.keys(FLEX_DIRECTION).find(key => FLEX_DIRECTION[key] === declarations['flex-direction']);
110
+ const spacing = css => contract.spacing.find(entry => entry.css === css)?.px;
111
+ const result = { direction, gap: spacing(declarations.gap), padding: spacing(declarations.padding) };
112
+ if (declarations['align-items'] !== undefined) result.align = Object.keys(FLEX_ALIGN).find(key => FLEX_ALIGN[key] === declarations['align-items']);
113
+ if ('align-items' in declarations && result.align === undefined) throw stale('Unknown managed alignment.');
114
+ if (grid) {
115
+ const match = /^repeat\(([1-9][0-9]*), minmax\(0, 1fr\)\)$/.exec(declarations['grid-template-columns']);
116
+ if (!match) throw stale('Grid tracks must use the generated equal-column form.');
117
+ result.columns = Number(match[1]);
118
+ }
119
+ if ('flex-wrap' in declarations) {
120
+ if (!['wrap', 'nowrap'].includes(declarations['flex-wrap'])) throw stale('Unknown managed wrapping.');
121
+ result.wrap = declarations['flex-wrap'] === 'wrap';
122
+ }
123
+ const justify = grid ? 'justify-items' : 'justify-content';
124
+ if (justify in declarations) {
125
+ result.distribute = Object.keys(DISTRIBUTION).find(key => DISTRIBUTION[key] === declarations[justify]);
126
+ if (!result.distribute) throw stale('Unknown managed distribution.');
127
+ }
128
+ const crossGap = direction === 'vertical' ? 'column-gap' : 'row-gap';
129
+ if (declarations[direction === 'vertical' ? 'row-gap' : 'column-gap'] !== undefined) throw stale('The cross gap must match the wrapper direction.');
130
+ for (const [key, css] of Object.entries(MEASURES)) {
131
+ const name = css ?? crossGap;
132
+ if (name in declarations) {
133
+ result[key] = spacing(declarations[name]);
134
+ if (result[key] === undefined) throw stale('A managed measure must use the exact permitted spacing spelling.');
135
+ }
136
+ }
137
+ const reason = wrapperLayoutProblem(result, contract);
138
+ if (reason) throw stale(reason);
139
+ return wrapperLayout(result);
140
+ }
141
+
142
+ export function wrapperBlock(groupId, className, layout, contract, eol = '\n') {
143
+ const lines = Object.entries(wrapperDeclarations(layout, contract)).map(([property, value]) => ` ${property}: ${value};`);
144
+ return [wrapperMarker(groupId), `.${className} {`, ...lines, '}'].join(eol);
145
+ }
146
+
147
+ /** Locates the one managed block for a group. Null when absent; throws when present but not canonical. */
148
+ export function findWrapperBlock(source, groupId, className) {
149
+ const marker = wrapperMarker(groupId);
150
+ const start = source.indexOf(marker);
151
+ if (start < 0) return null;
152
+ if (source.indexOf(marker, start + marker.length) >= 0) throw stale(`The managed wrapper block for ${groupId} must be unique.`);
153
+ if (start > 0 && source[start - 1] !== '\n') throw stale(`The managed wrapper block for ${groupId} must start its own line.`);
154
+ const pattern = new RegExp(`^\\r?\\n\\.${escape(className)} \\{\\r?\\n((?: [a-z-]+: [^;{}\\r\\n]+;\\r?\\n){1,20})\\}(?=\\r?\\n|$)`);
155
+ const match = pattern.exec(source.slice(start + marker.length));
156
+ if (!match) throw stale(`The managed wrapper block for ${groupId} was edited outside its generated form.`);
157
+ const declarations = {};
158
+ for (const line of match[1].split(/\r?\n/).filter(Boolean)) {
159
+ const [, property, value] = /^ {2}([a-z-]+): (.+);$/.exec(line);
160
+ if (Object.hasOwn(declarations, property)) throw stale(`The managed wrapper block for ${groupId} repeats a declaration.`);
161
+ declarations[property] = value;
162
+ }
163
+ return { start, end: start + marker.length + match[0].length, declarations };
164
+ }
165
+
166
+ /** True when a selector outside the ignored ranges references the class. Comments and strings are masked. */
167
+ export function wrapperClassReferenced(source, className, ignore = []) {
168
+ let text = masked(source);
169
+ for (const range of ignore) text = text.slice(0, range.start) + ' '.repeat(range.end - range.start) + text.slice(range.end);
170
+ return new RegExp(`\\.${escape(className)}(?![\\w-])`).test(text);
171
+ }
172
+
173
+ /** Layout of the managed block for a group, or null when the stylesheet has none. Throws on any unowned edit. */
174
+ export function readWrapperLayout(source, groupId, className, contract) {
175
+ const block = findWrapperBlock(source, groupId, className);
176
+ if (!block) return null;
177
+ if (wrapperClassReferenced(source, className, [block])) throw stale(`Another rule also styles .${className}; the wrapper block is not the only owner.`);
178
+ return wrapperLayoutFromDeclarations(block.declarations, contract);
179
+ }
180
+
181
+ /** Applies removals (null) and in-place or appended blocks in order; the input string is never mutated. */
182
+ export function applyWrapperEdits(source, edits, contract) {
183
+ const eol = lineEnding(source);
184
+ let content = source;
185
+ for (const [groupId, layout] of edits) {
186
+ const className = wrapperClassName(groupId);
187
+ if (!className) throw problem('structure-group-invalid', 'A wrapper group identity must derive a CSS module class name.');
188
+ const existing = findWrapperBlock(content, groupId, className);
189
+ if (layout === null) {
190
+ if (!existing) throw stale(`The managed wrapper block for ${groupId} is missing.`);
191
+ let { start, end } = existing;
192
+ if (content.startsWith(eol, end)) end += eol.length;
193
+ if (start >= 2 * eol.length && content.slice(start - 2 * eol.length, start) === eol + eol) start -= eol.length;
194
+ content = content.slice(0, start) + content.slice(end);
195
+ continue;
196
+ }
197
+ if (wrapperClassReferenced(content, className, existing ? [existing] : [])) {
198
+ throw problem('structure-wrapper-class-collision', `The stylesheet already styles .${className}; choose another group identity.`);
199
+ }
200
+ const block = wrapperBlock(groupId, className, layout, contract, eol);
201
+ if (existing) { content = content.slice(0, existing.start) + block + content.slice(existing.end); continue; }
202
+ content = content.length ? `${content}${content.endsWith(eol) ? '' : eol}${eol}${block}${eol}` : `${block}${eol}`;
203
+ }
204
+ return content;
205
+ }
@@ -0,0 +1,404 @@
1
+ import { createHash } from 'node:crypto';
2
+ import ts from 'typescript';
3
+ import { VOID_ELEMENTS as VOID, applyWrapperEdits, readWrapperLayout, wrapperClassName, wrapperClosing, wrapperContractProblem,
4
+ wrapperLayout, wrapperLayoutProblem, wrapperOpening, wrapperTagName } from './source-structure-wrapper-codec.mjs';
5
+ export { reconcileJsxStructurePlan } from './source-structure-reconcile.mjs';
6
+
7
+ const fingerprint = source => createHash('sha256').update(source, 'utf8').digest('hex');
8
+ const relative = value => typeof value === 'string' && value.length > 0 && !/^[a-z]:/i.test(value)
9
+ && !/[\\\x00-\x1f\x7f?#]/.test(value) && !value.split('/').some(part => !part || ['.', '..', '.git'].includes(part));
10
+ const identity = value => typeof value === 'string' && value.trim() === value && value.length > 0 && !['__proto__', 'constructor', 'prototype'].includes(value);
11
+ const boundedText = value => typeof value === 'string' && !value.includes('\0') && Buffer.byteLength(value, 'utf8') <= 32 * 1024 * 1024
12
+ && Buffer.from(value, 'utf8').toString('utf8') === value;
13
+ const container = node => ts.isJsxElement(node) || ts.isJsxFragment(node);
14
+ const selectable = node => container(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxExpression(node) || ts.isJsxText(node);
15
+ // A same-line whitespace child is rendered text; blank multiline indentation is not.
16
+ const blankText = value => value.length === 0 || /^[ \t\r\n]*$/.test(value) && /[\r\n]/.test(value);
17
+ function fail(code, message, location = {}) { throw Object.assign(new Error(message), { diagnostic: { code, message, ...location } }); }
18
+ function parse(file, source) {
19
+ if (!relative(file) || typeof source !== 'string' || !/\.(?:[cm]?[jt]s|[jt]sx)$/i.test(file)) fail('structure-file-invalid', 'A JSX source requires a normalized JavaScript or TypeScript path.', { file });
20
+ const kind = /\.tsx$/i.test(file) ? ts.ScriptKind.TSX : /\.(?:[cm]?ts)$/i.test(file) ? ts.ScriptKind.TS : ts.ScriptKind.JSX;
21
+ const ast = /** @type {import('typescript').SourceFile & { parseDiagnostics: readonly import('typescript').Diagnostic[] }} */ (ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind));
22
+ if (ast.parseDiagnostics.length) fail('structure-syntax-invalid', 'Source must parse before and after structural changes.', { file });
23
+ return ast;
24
+ }
25
+ function bounds(node, ast) { return { start: ts.isJsxText(node) ? node.pos : node.getStart(ast), end: node.end }; }
26
+ function tag(node, ast) {
27
+ return ts.isJsxElement(node) ? node.openingElement.tagName.getText(ast) : ts.isJsxSelfClosingElement(node) ? node.tagName.getText(ast) : undefined;
28
+ }
29
+ function island(node) {
30
+ while (container(node.parent) && node.parent.children.includes(node)) node = node.parent;
31
+ return node;
32
+ }
33
+ /** Host wrapper evidence for identity and reimport: the contract plus the complete stylesheet bytes. */
34
+ function wrapperEvidence(wrapper, file) {
35
+ if (wrapper === undefined) return undefined;
36
+ if (!wrapper || typeof wrapper !== 'object' || Array.isArray(wrapper) || Object.keys(wrapper).some(key => !['contract', 'source'].includes(key)) || !boundedText(wrapper.source)) {
37
+ fail('structure-wrapper-invalid', 'Wrapper evidence requires the host contract and the complete stylesheet source.', { file });
38
+ }
39
+ const problem = wrapperContractProblem(wrapper.contract);
40
+ if (problem) fail('structure-wrapper-invalid', problem, { file });
41
+ return wrapper;
42
+ }
43
+ /** A generated wrapper is exactly the canonical element and its managed stylesheet block; anything else is a plain element. */
44
+ function generatedWrapper(text, nodeId, contract, stylesheet) {
45
+ const className = wrapperClassName(nodeId);
46
+ if (!className || !text.startsWith(wrapperOpening(contract, className)) || !text.endsWith(wrapperClosing(contract))) return null;
47
+ let layout;
48
+ try { layout = readWrapperLayout(stylesheet, nodeId, className, contract); }
49
+ catch (error) { fail(error.code ?? 'structure-wrapper-stale', error.message, { file: contract.stylesheet, nodeId }); }
50
+ return layout ? { className, layout } : null;
51
+ }
52
+
53
+ /** Read a host-selected JSX root without evaluating props, handlers, or expressions. */
54
+ export function inspectJsxStructureSource({ file, source, rootStart, opaqueNodeStarts = [] }) {
55
+ const ast = parse(file, source); const candidates = [];
56
+ const visit = node => {
57
+ if ((container(node) || ts.isJsxSelfClosingElement(node)) && node.getStart(ast) === rootStart) candidates.push(node);
58
+ ts.forEachChild(node, visit);
59
+ };
60
+ visit(ast);
61
+ if (candidates.length !== 1) fail('structure-root-invalid', 'The selected root must identify one complete JSX element or fragment.', { file });
62
+ if (!Array.isArray(opaqueNodeStarts) || opaqueNodeStarts.some(start => !Number.isSafeInteger(start) || start < 0)
63
+ || new Set(opaqueNodeStarts).size !== opaqueNodeStarts.length) fail('structure-opaque-invalid', 'Opaque roots require unique source offsets.', { file });
64
+ const opaque = new Set(opaqueNodeStarts); const seen = new Set(); const anchors = []; const nodes = [];
65
+ const project = (node, parentId, runtimePath) => {
66
+ const { start, end } = bounds(node, ast); const nodeId = `${file}:${start}`;
67
+ const kind = ts.isJsxFragment(node) ? 'fragment' : ts.isJsxExpression(node) ? 'expression' : ts.isJsxText(node) ? 'text' : 'element';
68
+ const projected = { nodeId, parentId, children: [], kind, runtimePath, start, end };
69
+ const tagName = tag(node, ast); if (tagName) projected.tagName = tagName;
70
+ const attributes = ts.isJsxElement(node) ? node.openingElement.attributes : ts.isJsxSelfClosingElement(node) ? node.attributes : undefined;
71
+ const attribute = attributes?.properties.find(item => ts.isJsxAttribute(item) && item.name.getText(ast) === 'className');
72
+ const literal = attribute && ts.isJsxAttribute(attribute) ? attribute.initializer : undefined;
73
+ if (literal && ts.isStringLiteral(literal)) projected.className = literal.text;
74
+ else if (literal && ts.isJsxExpression(literal) && literal.expression && ts.isStringLiteral(literal.expression)) projected.className = literal.expression.text;
75
+ anchors.push({ nodeId, file, start, end, expected: source.slice(start, end) }); nodes.push(projected);
76
+ if (opaque.has(start)) { seen.add(start); return nodeId; }
77
+ if (container(node)) {
78
+ let index = 0;
79
+ for (const child of node.children) {
80
+ if (ts.isJsxText(child) && blankText(source.slice(child.pos, child.end))) continue;
81
+ if (ts.isJsxExpression(child) && !child.expression) continue;
82
+ projected.children.push(project(child, nodeId, [...runtimePath, index++]));
83
+ }
84
+ }
85
+ return nodeId;
86
+ };
87
+ project(candidates[0], null, []);
88
+ if (seen.size !== opaque.size) fail('structure-opaque-invalid', 'Every opaque offset must identify a visited source node.', { file });
89
+ return { anchors, nodes };
90
+ }
91
+
92
+ /** Capture stable IDs for exact emitted source; persist with that source in the same reviewed plan. */
93
+ export function createJsxStructureIdentity({ file, source, rootId, anchors, opaqueNodeIds = [], wrapper }) {
94
+ if (typeof source !== 'string' || !Array.isArray(anchors) || !Array.isArray(opaqueNodeIds)
95
+ || anchors.some(anchor => anchor?.file !== file || source.slice(anchor.start, anchor.end) !== anchor.expected)) {
96
+ fail('structure-identity-invalid', 'Identity evidence must describe the complete emitted source ranges.', { file });
97
+ }
98
+ const result = { version: 1, file, sourceSha256: fingerprint(source), rootId,
99
+ opaqueNodeIds: [...opaqueNodeIds], anchors: anchors.map(({ nodeId, start, end }) => ({ nodeId, start, end })) };
100
+ // Generated wrappers are recorded from the emitted element and its managed block, then proved again as a reimport.
101
+ const { wrappers } = restore({ file, source, evidence: result, wrapper, detect: true });
102
+ if (wrappers.length) result.wrappers = wrappers;
103
+ restore({ file, source, evidence: result, wrapper, detect: false });
104
+ return result;
105
+ }
106
+
107
+ /** Rebuild hierarchy from the AST, retaining only IDs proved against the exact source bytes. */
108
+ export function reimportJsxStructureSource({ file, source, identity, wrapper }) {
109
+ const { anchors, nodes } = restore({ file, source, evidence: identity, wrapper, detect: false });
110
+ return { anchors, nodes };
111
+ }
112
+
113
+ function restore({ file, source, evidence, wrapper: wrapperInput, detect }) {
114
+ const wrapper = wrapperEvidence(wrapperInput, file);
115
+ if (!evidence || typeof evidence !== 'object' || Array.isArray(evidence) || evidence.version !== 1
116
+ || Object.keys(evidence).some(key => !['version', 'file', 'sourceSha256', 'rootId', 'opaqueNodeIds', 'anchors', 'wrappers'].includes(key))
117
+ || !relative(file) || evidence.file !== file || !boundedText(source)
118
+ || !/^[a-f0-9]{64}$/.test(evidence.sourceSha256 ?? '') || !identity(evidence.rootId)
119
+ || !Array.isArray(evidence.anchors) || !evidence.anchors.length || evidence.anchors.length > 5000
120
+ || !Array.isArray(evidence.opaqueNodeIds) || evidence.opaqueNodeIds.length > evidence.anchors.length) {
121
+ fail('structure-identity-invalid', 'Source identity requires a bounded, exact file, root and complete anchor table.', { file });
122
+ }
123
+ if (fingerprint(source) !== evidence.sourceSha256) fail('structure-identity-stale', 'The source bytes differ from their persisted identity. Reconcile source before importing.', { file });
124
+ const byId = new Map(); const byRange = new Map();
125
+ for (const anchor of evidence.anchors) {
126
+ if (!anchor || typeof anchor !== 'object' || Array.isArray(anchor)
127
+ || Object.keys(anchor).some(key => !['nodeId', 'start', 'end'].includes(key))
128
+ || !identity(anchor.nodeId) || byId.has(anchor.nodeId) || !Number.isSafeInteger(anchor.start) || !Number.isSafeInteger(anchor.end)
129
+ || anchor.start < 0 || anchor.end <= anchor.start || anchor.end > source.length || byRange.has(`${anchor.start}:${anchor.end}`)) {
130
+ fail('structure-identity-invalid', 'Every persisted identity must name a unique, complete source range.', { file });
131
+ }
132
+ byId.set(anchor.nodeId, anchor); byRange.set(`${anchor.start}:${anchor.end}`, anchor.nodeId);
133
+ }
134
+ const recorded = evidence.wrappers === undefined ? [] : evidence.wrappers;
135
+ if (!Array.isArray(recorded) || recorded.length > byId.size || new Set(recorded.map(entry => entry?.nodeId)).size !== recorded.length
136
+ || recorded.some(entry => !entry || typeof entry !== 'object' || Array.isArray(entry) || Object.keys(entry).some(key => !['nodeId', 'className'].includes(key))
137
+ || !identity(entry.nodeId) || !byId.has(entry.nodeId) || entry.className !== wrapperClassName(entry.nodeId))) {
138
+ fail('structure-identity-invalid', 'Recorded wrapper groups must name persisted nodes with their derived class names.', { file });
139
+ }
140
+ if (recorded.length && !wrapper) fail('structure-identity-invalid', 'Recorded wrapper groups require the host wrapper contract and stylesheet.', { file });
141
+ const root = byId.get(evidence.rootId);
142
+ if (!root || new Set(evidence.opaqueNodeIds).size !== evidence.opaqueNodeIds.length
143
+ || evidence.opaqueNodeIds.some(id => !byId.has(id))) fail('structure-identity-invalid', 'Root and opaque boundaries must name distinct persisted source nodes.', { file });
144
+ const projected = inspectJsxStructureSource({ file, source, rootStart: root.start,
145
+ opaqueNodeStarts: evidence.opaqueNodeIds.map(id => byId.get(id).start) });
146
+ const ids = new Map(projected.nodes.map(node => [node.nodeId, byRange.get(`${node.start}:${node.end}`)]));
147
+ if (ids.size !== byId.size || [...ids.values()].some(id => id === undefined) || ids.get(projected.nodes[0].nodeId) !== evidence.rootId) {
148
+ fail('structure-identity-mismatch', 'The persisted identity must cover exactly the complete projected AST tree.', { file });
149
+ }
150
+ const nodes = projected.nodes.map(node => ({ ...node, nodeId: ids.get(node.nodeId),
151
+ parentId: node.parentId === null ? null : ids.get(node.parentId), children: node.children.map(id => ids.get(id)) }));
152
+ const detected = new Map();
153
+ if (wrapper) {
154
+ for (const node of nodes) {
155
+ if (node.kind !== 'element' || node.tagName !== wrapperTagName(wrapper.contract)) continue;
156
+ const box = generatedWrapper(source.slice(node.start, node.end), node.nodeId, wrapper.contract, wrapper.source);
157
+ if (box) detected.set(node.nodeId, box);
158
+ }
159
+ }
160
+ if (!detect && (detected.size !== recorded.length || recorded.some(entry => detected.get(entry.nodeId)?.className !== entry.className))) {
161
+ fail('structure-identity-mismatch', 'Recorded wrapper groups must match the generated elements and their stylesheet blocks exactly.', { file });
162
+ }
163
+ for (const node of nodes) {
164
+ const box = detected.get(node.nodeId);
165
+ if (box) { node.layout = box.layout; node.wrapper = { className: box.className }; }
166
+ }
167
+ return {
168
+ anchors: projected.anchors.map(anchor => ({ ...anchor, nodeId: ids.get(anchor.nodeId) })),
169
+ nodes,
170
+ wrappers: [...detected].map(([nodeId, box]) => ({ nodeId, className: box.className })),
171
+ };
172
+ }
173
+
174
+ /** Compile source-owned hierarchy operations to an immutable-source file plan; never write files. */
175
+ export function compileJsxStructurePlan(input) {
176
+ try { return compile(input); }
177
+ catch (error) {
178
+ if (error?.diagnostic) return { status: 'blocked', diagnostics: [error.diagnostic] };
179
+ throw error;
180
+ }
181
+ }
182
+
183
+ function compile(input) {
184
+ const { sourceRevision, files, anchors, operations, allowedFiles, excludedSourcePrefixes = [], wrapper } = input ?? {};
185
+ if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(sourceRevision ?? '') || !Array.isArray(files) || !files.length || files.length > 1000
186
+ || !Array.isArray(anchors) || !anchors.length || anchors.length > 5000 || !Array.isArray(operations) || operations.length > 100
187
+ || !Array.isArray(allowedFiles) || !Array.isArray(excludedSourcePrefixes)) fail('structure-input-invalid', 'Structural compilation requires an exact revision, bounded source evidence, and explicit file policy.');
188
+ if (wrapper !== undefined) { const problem = wrapperContractProblem(wrapper); if (problem) fail('structure-wrapper-invalid', problem); }
189
+ const allowed = new Set(allowedFiles); const excluded = excludedSourcePrefixes.map(value => typeof value === 'string' ? value.replace(/\/$/, '') : value);
190
+ if ([...allowed, ...excluded].some(value => !relative(value))) fail('structure-policy-invalid', 'Source policies require normalized relative paths.');
191
+ const sources = new Map(); let bytes = 0;
192
+ for (const entry of files) {
193
+ if (!entry || !relative(entry.file) || sources.has(entry.file) || typeof entry.source !== 'string' || !/^[a-f0-9]{64}$/.test(entry.sha256 ?? '')) fail('structure-file-invalid', 'Source files require unique paths and complete hashes.');
194
+ const encoded = Buffer.from(entry.source, 'utf8'); bytes += encoded.length;
195
+ if (encoded.toString('utf8') !== entry.source || bytes > 32 * 1024 * 1024 || entry.source.includes('\0')) fail('structure-file-invalid', 'Source files require bounded UTF-8 content.', { file: entry.file });
196
+ if (fingerprint(entry.source) !== entry.sha256) fail('structure-source-stale', 'The complete source file no longer matches its hash.', { file: entry.file });
197
+ sources.set(entry.file, { ...entry, ast: null, matches: null, roots: [] });
198
+ }
199
+ const models = new Map(); const ranges = new Map(); const byNode = new Map();
200
+ for (const anchor of anchors) {
201
+ const source = sources.get(anchor?.file);
202
+ if (!anchor || !identity(anchor.nodeId) || models.has(anchor.nodeId) || !source || !Number.isSafeInteger(anchor.start) || !Number.isSafeInteger(anchor.end)
203
+ || anchor.start < 0 || anchor.end <= anchor.start || anchor.end > source.source.length || typeof anchor.expected !== 'string') fail('structure-anchor-invalid', 'Source anchors require unique identities and complete JSX ranges.');
204
+ if (source.source.slice(anchor.start, anchor.end) !== anchor.expected) fail('structure-source-stale', 'The source anchor no longer matches its exact snippet.', { file: anchor.file, nodeId: anchor.nodeId });
205
+ if (!source.ast) {
206
+ source.ast = parse(anchor.file, source.source); source.matches = new Map();
207
+ const visit = node => { if (selectable(node)) { const range = bounds(node, source.ast); source.matches.set(`${range.start}:${range.end}`, node); } ts.forEachChild(node, visit); };
208
+ visit(source.ast);
209
+ }
210
+ const key = `${anchor.file}:${anchor.start}:${anchor.end}`; const node = source.matches.get(`${anchor.start}:${anchor.end}`);
211
+ if (!node || ranges.has(key)) fail('structure-anchor-invalid', 'Each anchor must identify a distinct complete JSX node.', { file: anchor.file, nodeId: anchor.nodeId });
212
+ ranges.set(key, anchor.nodeId); byNode.set(node, anchor.nodeId);
213
+ models.set(anchor.nodeId, { ...anchor, node, source, island: island(node), parentId: null, nested: [], children: null, tail: '', active: true });
214
+ }
215
+ for (const source of sources.values()) {
216
+ const ordered = [...models.values()].filter(model => model.source === source).sort((left, right) => left.start - right.start || right.end - left.end);
217
+ const stack = [];
218
+ for (const model of ordered) {
219
+ while (stack.length && stack.at(-1).end <= model.start) stack.pop();
220
+ if (stack.length && model.end > stack.at(-1).end) fail('structure-overlap', 'Source anchors must have disjoint or fully nested ranges.', { file: model.file });
221
+ if (stack.length) stack.at(-1).nested.push(model.nodeId); else source.roots.push(model.nodeId);
222
+ stack.push(model);
223
+ if (container(model.node.parent) && model.node.parent.children.includes(model.node)) model.parentId = byNode.get(model.node.parent) ?? null;
224
+ }
225
+ }
226
+ const changedParents = new Set();
227
+ const get = nodeId => { const model = models.get(nodeId); if (!model?.active) fail('structure-node-missing', 'The operation targets an absent source-owned node.', { nodeId }); return model; };
228
+ const writableFile = (file, nodeId) => { if (!allowed.has(file) || excluded.some(prefix => file === prefix || file.startsWith(`${prefix}/`))) fail('structure-file-excluded', 'The host has not allowed structural edits in this source file.', { file, nodeId }); };
229
+ const writable = model => writableFile(model.file, model.nodeId);
230
+ const stylesheet = wrapper ? sources.get(wrapper.stylesheet) : undefined;
231
+ // Null removes a managed block; a layout writes it in place or appends it. Applied once after JSX rendering.
232
+ const wrapperEdits = new Map(); const removedOriginals = new Set();
233
+ const requireStylesheet = nodeId => {
234
+ if (!stylesheet) fail('structure-wrapper-stylesheet-missing', 'Wrapper groups require the declared stylesheet source in the same plan.', { file: wrapper.stylesheet, nodeId });
235
+ writableFile(wrapper.stylesheet, nodeId);
236
+ };
237
+ const managedWrapper = model => {
238
+ if (!wrapper || !model.node || !ts.isJsxElement(model.node) || tag(model.node, model.source.ast) !== wrapperTagName(wrapper)) return null;
239
+ const className = wrapperClassName(model.nodeId);
240
+ if (!className || !model.expected.startsWith(wrapperOpening(wrapper, className)) || !model.expected.endsWith(wrapperClosing(wrapper))) return null;
241
+ requireStylesheet(model.nodeId);
242
+ return generatedWrapper(model.expected, model.nodeId, wrapper, stylesheet.source);
243
+ };
244
+ const children = model => {
245
+ writable(model);
246
+ if (model.children) return model.children;
247
+ const node = model.node; const name = tag(node, model.source.ast);
248
+ if (!container(node) && !ts.isJsxSelfClosingElement(node) || name && (!/^[a-z][a-z0-9]*$/.test(name) || VOID.has(name))) fail('structure-receiver-unsupported', 'Only intrinsic non-void JSX elements and fragments can receive structural changes.', { nodeId: model.nodeId });
249
+ const attributes = ts.isJsxElement(node) ? node.openingElement.attributes : ts.isJsxSelfClosingElement(node) ? node.attributes : null;
250
+ if (attributes?.properties.some(attribute => ts.isJsxSpreadAttribute(attribute) || ['children', 'dangerouslySetInnerHTML'].includes(attribute.name.getText(model.source.ast)))) fail('structure-receiver-unsupported', 'A source receiver cannot have spread, children, or inner-HTML attributes.', { nodeId: model.nodeId });
251
+ model.children = [];
252
+ if (ts.isJsxSelfClosingElement(node)) return model.children;
253
+ const opening = ts.isJsxElement(node) ? node.openingElement : node.openingFragment;
254
+ const closing = ts.isJsxElement(node) ? node.closingElement : node.closingFragment;
255
+ let cursor = opening.end;
256
+ for (const child of node.children) {
257
+ if (ts.isJsxText(child) && blankText(model.source.source.slice(child.pos, child.end))) continue;
258
+ if (ts.isJsxText(child) || ts.isJsxExpression(child) && !child.expression) fail('structure-trivia-ambiguous', 'Rendered JSX text and standalone comments cannot be reassigned by a hierarchy edit.', { nodeId: model.nodeId });
259
+ const id = byNode.get(child);
260
+ if (!id) fail('structure-child-unowned', 'Every direct JSX child of an edited parent requires an exact source anchor.', { nodeId: model.nodeId });
261
+ model.children.push({ id, leading: model.source.source.slice(cursor, child.getStart(model.source.ast)) }); cursor = child.end;
262
+ }
263
+ model.tail = model.source.source.slice(cursor, closing.getStart(model.source.ast));
264
+ return model.children;
265
+ };
266
+ const parentOf = model => { if (!model.parentId) fail('structure-root-unsupported', 'A source root cannot be removed or moved without its owned JSX parent.', { nodeId: model.nodeId }); return get(model.parentId); };
267
+ const position = (list, id) => { const index = list.findIndex(item => item.id === id); if (index < 0) fail('structure-parent-mismatch', 'The source parent does not contain the selected node.', { nodeId: id }); return index; };
268
+ for (const operation of operations) {
269
+ if (!operation || !['reorder', 'reparent', 'group', 'ungroup'].includes(operation.kind)) fail('structure-operation-invalid', 'The requested structural operation is unsupported.');
270
+ const keys = { reorder: ['kind', 'parentId', 'children'], reparent: ['kind', 'nodeId', 'parentId', 'index'], group: ['kind', 'nodeIds', 'groupId', 'layout'], ungroup: ['kind', 'nodeId'] }[operation.kind];
271
+ if (Object.keys(operation).some(key => !keys.includes(key))) fail('structure-operation-invalid', 'A structural operation contains an unsupported field.');
272
+ if (operation.kind === 'reorder') {
273
+ const parent = get(operation.parentId); const list = children(parent);
274
+ if (!Array.isArray(operation.children) || operation.children.length !== list.length || new Set(operation.children).size !== list.length || operation.children.some(id => !list.some(item => item.id === id))) fail('structure-children-mismatch', 'A reorder must name every current direct child exactly once.', { nodeId: parent.nodeId });
275
+ parent.children = operation.children.map(id => list.find(item => item.id === id)); changedParents.add(parent.nodeId);
276
+ } else if (operation.kind === 'reparent') {
277
+ const node = get(operation.nodeId); const from = parentOf(node); const parent = get(operation.parentId);
278
+ if (node.file !== parent.file || node.island !== parent.island) fail('structure-scope-crossing', 'Nodes cannot cross source files, expression, function, or conditional render boundaries.', { nodeId: node.nodeId });
279
+ for (let cursor = parent; cursor; cursor = cursor.parentId ? get(cursor.parentId) : null) if (cursor === node) fail('structure-cycle', 'A node cannot become its own descendant.', { nodeId: node.nodeId });
280
+ const sourceChildren = children(from); const targetChildren = children(parent); const oldIndex = position(sourceChildren, node.nodeId);
281
+ const maximum = targetChildren.length - (from === parent ? 1 : 0);
282
+ if (!Number.isSafeInteger(operation.index) || operation.index < 0 || operation.index > maximum) fail('structure-index-invalid', 'The destination index must address the final direct-child list.', { nodeId: parent.nodeId });
283
+ const [entry] = sourceChildren.splice(oldIndex, 1); targetChildren.splice(operation.index, 0, entry); node.parentId = parent.nodeId;
284
+ changedParents.add(from.nodeId); changedParents.add(parent.nodeId);
285
+ } else if (operation.kind === 'group') {
286
+ const previousGroup = models.get(operation.groupId);
287
+ if (!Array.isArray(operation.nodeIds) || !operation.nodeIds.length || new Set(operation.nodeIds).size !== operation.nodeIds.length || !identity(operation.groupId) || previousGroup?.active) fail('structure-group-invalid', 'A fragment group requires distinct children and an available stable identity.');
288
+ let box = null;
289
+ if (operation.layout !== undefined) {
290
+ if (!wrapper) fail('structure-wrapper-undeclared', 'A group with a layout requires a host wrapper declaration.', { nodeId: operation.groupId });
291
+ const problem = wrapperLayoutProblem(operation.layout, wrapper);
292
+ if (problem) fail('structure-layout-invalid', problem, { nodeId: operation.groupId });
293
+ const className = wrapperClassName(operation.groupId);
294
+ if (!className) fail('structure-group-invalid', 'A wrapper group identity must derive a CSS module class name.', { nodeId: operation.groupId });
295
+ requireStylesheet(operation.groupId);
296
+ box = { className, layout: wrapperLayout(operation.layout) };
297
+ }
298
+ const selected = operation.nodeIds.map(get); const parent = parentOf(selected[0]); const list = children(parent);
299
+ if (previousGroup && (previousGroup.file !== parent.file || previousGroup.island !== parent.island
300
+ || previousGroup.node && !ts.isJsxFragment(previousGroup.node) && !previousGroup.removedWrapper)) fail('structure-group-invalid', 'A removed fragment identity can only be reused within its original source island.');
301
+ const first = position(list, selected[0].nodeId);
302
+ if (selected.some((node, index) => node.parentId !== parent.nodeId || list[first + index]?.id !== node.nodeId)) fail('structure-group-invalid', 'A fragment group requires one contiguous, ordered sibling range.');
303
+ const entries = list.splice(first, selected.length); const leading = entries[0].leading; entries[0] = { ...entries[0], leading: '' };
304
+ const group = { nodeId: operation.groupId, file: parent.file, source: parent.source, node: null, island: parent.island, parentId: parent.nodeId, children: entries, nested: [], tail: '', active: true, wrapper: box };
305
+ models.set(group.nodeId, group); selected.forEach(node => { node.parentId = group.nodeId; });
306
+ if (box) wrapperEdits.set(group.nodeId, box.layout);
307
+ list.splice(first, 0, { id: group.nodeId, leading }); changedParents.add(parent.nodeId);
308
+ } else {
309
+ const group = get(operation.nodeId); const parent = parentOf(group);
310
+ let removed = null;
311
+ if (group.node && !ts.isJsxFragment(group.node)) {
312
+ removed = managedWrapper(group);
313
+ if (!removed) fail('structure-ungroup-unsupported', 'Only anonymous JSX fragments and generated wrapper elements can be removed without discarding element behavior.', { nodeId: group.nodeId });
314
+ }
315
+ const list = children(parent); const members = children(group); const index = position(list, group.nodeId); const entry = list[index];
316
+ const promoted = members.map(member => ({ ...member }));
317
+ if (promoted.length) promoted[0].leading = entry.leading + promoted[0].leading;
318
+ const leftover = (promoted.length ? '' : entry.leading) + group.tail;
319
+ list.splice(index, 1, ...promoted);
320
+ if (list[index + promoted.length]) list[index + promoted.length].leading = leftover + list[index + promoted.length].leading;
321
+ else parent.tail = leftover + parent.tail;
322
+ promoted.forEach(member => { get(member.id).parentId = parent.nodeId; }); group.active = false; changedParents.add(parent.nodeId);
323
+ if (removed) { group.removedWrapper = removed; removedOriginals.add(group.nodeId); wrapperEdits.set(group.nodeId, null); }
324
+ // A wrapper created earlier in this batch leaves no block; an original block already removed stays removed.
325
+ else if (group.wrapper) { if (removedOriginals.has(group.nodeId)) wrapperEdits.set(group.nodeId, null); else wrapperEdits.delete(group.nodeId); }
326
+ }
327
+ }
328
+ const modified = [...changedParents].map(id => models.get(id)).filter(model => model.active && model.node);
329
+ for (const left of modified) for (const right of modified) {
330
+ if (left !== right && left.file === right.file && left.start < right.end && right.start < left.end && left.island !== right.island) fail('structure-overlap', 'One batch cannot edit both an opaque expression and a render tree inside it.', { file: left.file });
331
+ }
332
+ const part = text => ({ text, positions: new Map() });
333
+ const join = parts => {
334
+ let text = ''; const positions = new Map();
335
+ for (const item of parts) {
336
+ for (const [id, range] of item.positions) {
337
+ if (positions.has(id)) fail('structure-anchor-duplicate', 'A source-owned node would be emitted more than once.', { nodeId: id });
338
+ positions.set(id, { start: text.length + range.start, end: text.length + range.end });
339
+ }
340
+ text += item.text;
341
+ }
342
+ return { text, positions };
343
+ };
344
+ const rendering = new Set();
345
+ const region = (source, start, end, nested) => {
346
+ let cursor = start; const parts = [];
347
+ for (const id of nested) {
348
+ const child = models.get(id);
349
+ // Recreated groups are emitted by their current parent, never by an old source range.
350
+ if (!child.node || child.start < start || child.end > end) continue;
351
+ parts.push(part(source.source.slice(cursor, child.start)), render(child)); cursor = child.end;
352
+ }
353
+ parts.push(part(source.source.slice(cursor, end))); return join(parts);
354
+ };
355
+ const render = model => {
356
+ if (!model.active) fail('structure-anchor-loss', 'An inactive source anchor remained in the emitted hierarchy.', { nodeId: model.nodeId });
357
+ if (rendering.has(model.nodeId)) fail('structure-cycle', 'A structural edit created a source cycle.', { nodeId: model.nodeId });
358
+ rendering.add(model.nodeId);
359
+ let result;
360
+ if (!model.children) result = region(model.source, model.start, model.end, model.nested);
361
+ else {
362
+ let opening; let closing;
363
+ if (!model.node) {
364
+ opening = part(model.wrapper ? wrapperOpening(wrapper, model.wrapper.className) : '<>');
365
+ closing = part(model.wrapper ? wrapperClosing(wrapper) : '</>');
366
+ } else if (ts.isJsxSelfClosingElement(model.node)) {
367
+ opening = part(model.expected.replace(/\/>$/, '>')); closing = part(`</${tag(model.node, model.source.ast)}>`);
368
+ if (!model.children.length) { opening = part(model.expected); closing = part(''); }
369
+ } else {
370
+ const open = ts.isJsxElement(model.node) ? model.node.openingElement : model.node.openingFragment;
371
+ const close = ts.isJsxElement(model.node) ? model.node.closingElement : model.node.closingFragment;
372
+ opening = region(model.source, model.start, open.end, model.nested); closing = part(model.source.source.slice(close.getStart(model.source.ast), model.end));
373
+ }
374
+ result = join([opening, ...model.children.flatMap(child => [part(child.leading), render(get(child.id))]), part(model.tail), closing]);
375
+ }
376
+ rendering.delete(model.nodeId); result.positions.set(model.nodeId, { start: 0, end: result.text.length }); return result;
377
+ };
378
+ const updatedAnchors = []; const changes = [];
379
+ for (const source of sources.values()) {
380
+ const result = region(source, 0, source.source.length, source.roots);
381
+ if (result.text !== source.source) {
382
+ parse(source.file, result.text);
383
+ changes.push({ file: source.file, beforeSha256: source.sha256, content: result.text });
384
+ }
385
+ for (const [nodeId, range] of result.positions) updatedAnchors.push({ nodeId, file: source.file, ...range, expected: result.text.slice(range.start, range.end) });
386
+ }
387
+ if (wrapperEdits.size) {
388
+ let content;
389
+ try { content = applyWrapperEdits(stylesheet.source, wrapperEdits, wrapper); }
390
+ catch (error) { fail(error.code ?? 'structure-wrapper-stale', error.message, { file: wrapper.stylesheet }); }
391
+ for (const [nodeId, layout] of wrapperEdits) {
392
+ if (layout === null) continue;
393
+ let restored;
394
+ try { restored = readWrapperLayout(content, nodeId, wrapperClassName(nodeId), wrapper); }
395
+ catch (error) { fail(error.code ?? 'structure-wrapper-stale', error.message, { file: wrapper.stylesheet, nodeId }); }
396
+ if (!restored || JSON.stringify(restored) !== JSON.stringify(layout)) fail('structure-wrapper-stale', 'The generated wrapper block does not read back to its layout.', { file: wrapper.stylesheet, nodeId });
397
+ }
398
+ if (content !== stylesheet.source) changes.push({ file: wrapper.stylesheet, beforeSha256: stylesheet.sha256, content });
399
+ }
400
+ if (updatedAnchors.length !== [...models.values()].filter(model => model.active).length) fail('structure-anchor-loss', 'The emitted source does not contain every retained source anchor exactly once.');
401
+ updatedAnchors.sort((left, right) => left.file.localeCompare(right.file) || left.start - right.start || right.end - left.end);
402
+ if (!changes.length) return { status: 'unchanged', anchors: updatedAnchors };
403
+ return { status: 'ready', payload: { version: 1, sourceRevision, proofs: files.map(({ file, sha256 }) => ({ file, sha256 })), changes }, anchors: updatedAnchors };
404
+ }