@sudodevstudio/astro-ai 0.1.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 (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -0
  3. package/dist/agent/agent-fallback.d.ts +50 -0
  4. package/dist/agent/agent-fallback.d.ts.map +1 -0
  5. package/dist/agent/agent-fallback.js +31 -0
  6. package/dist/agent/agent-fallback.js.map +1 -0
  7. package/dist/agent/cli-agent-fallback.d.ts +79 -0
  8. package/dist/agent/cli-agent-fallback.d.ts.map +1 -0
  9. package/dist/agent/cli-agent-fallback.js +1056 -0
  10. package/dist/agent/cli-agent-fallback.js.map +1 -0
  11. package/dist/integration/index.d.ts +28 -0
  12. package/dist/integration/index.d.ts.map +1 -0
  13. package/dist/integration/index.js +389 -0
  14. package/dist/integration/index.js.map +1 -0
  15. package/dist/resolver/astro-resolver.d.ts +28 -0
  16. package/dist/resolver/astro-resolver.d.ts.map +1 -0
  17. package/dist/resolver/astro-resolver.js +835 -0
  18. package/dist/resolver/astro-resolver.js.map +1 -0
  19. package/dist/resolver/types.d.ts +39 -0
  20. package/dist/resolver/types.d.ts.map +1 -0
  21. package/dist/resolver/types.js +2 -0
  22. package/dist/resolver/types.js.map +1 -0
  23. package/dist/shared/protocol.d.ts +150 -0
  24. package/dist/shared/protocol.d.ts.map +1 -0
  25. package/dist/shared/protocol.js +28 -0
  26. package/dist/shared/protocol.js.map +1 -0
  27. package/dist/shared/selection-context.d.ts +75 -0
  28. package/dist/shared/selection-context.d.ts.map +1 -0
  29. package/dist/shared/selection-context.js +2 -0
  30. package/dist/shared/selection-context.js.map +1 -0
  31. package/dist/shared/visual-components.d.ts +15 -0
  32. package/dist/shared/visual-components.d.ts.map +1 -0
  33. package/dist/shared/visual-components.js +2 -0
  34. package/dist/shared/visual-components.js.map +1 -0
  35. package/dist/toolbar/action-model.d.ts +20 -0
  36. package/dist/toolbar/action-model.d.ts.map +1 -0
  37. package/dist/toolbar/action-model.js +49 -0
  38. package/dist/toolbar/action-model.js.map +1 -0
  39. package/dist/toolbar/app.d.ts +3 -0
  40. package/dist/toolbar/app.d.ts.map +1 -0
  41. package/dist/toolbar/app.js +274 -0
  42. package/dist/toolbar/app.js.map +1 -0
  43. package/dist/toolbar/chat-drawer.d.ts +111 -0
  44. package/dist/toolbar/chat-drawer.d.ts.map +1 -0
  45. package/dist/toolbar/chat-drawer.js +1575 -0
  46. package/dist/toolbar/chat-drawer.js.map +1 -0
  47. package/dist/toolbar/chat-windows.d.ts +62 -0
  48. package/dist/toolbar/chat-windows.d.ts.map +1 -0
  49. package/dist/toolbar/chat-windows.js +287 -0
  50. package/dist/toolbar/chat-windows.js.map +1 -0
  51. package/dist/toolbar/contextual-actions.d.ts +24 -0
  52. package/dist/toolbar/contextual-actions.d.ts.map +1 -0
  53. package/dist/toolbar/contextual-actions.js +371 -0
  54. package/dist/toolbar/contextual-actions.js.map +1 -0
  55. package/dist/toolbar/diagnostic-actions.d.ts +22 -0
  56. package/dist/toolbar/diagnostic-actions.d.ts.map +1 -0
  57. package/dist/toolbar/diagnostic-actions.js +127 -0
  58. package/dist/toolbar/diagnostic-actions.js.map +1 -0
  59. package/dist/toolbar/layers.d.ts +26 -0
  60. package/dist/toolbar/layers.d.ts.map +1 -0
  61. package/dist/toolbar/layers.js +31 -0
  62. package/dist/toolbar/layers.js.map +1 -0
  63. package/dist/toolbar/overlay.d.ts +40 -0
  64. package/dist/toolbar/overlay.d.ts.map +1 -0
  65. package/dist/toolbar/overlay.js +619 -0
  66. package/dist/toolbar/overlay.js.map +1 -0
  67. package/dist/visual/capability-resolver.d.ts +11 -0
  68. package/dist/visual/capability-resolver.d.ts.map +1 -0
  69. package/dist/visual/capability-resolver.js +69 -0
  70. package/dist/visual/capability-resolver.js.map +1 -0
  71. package/dist/visual/command-engine.d.ts +10 -0
  72. package/dist/visual/command-engine.d.ts.map +1 -0
  73. package/dist/visual/command-engine.js +199 -0
  74. package/dist/visual/command-engine.js.map +1 -0
  75. package/dist/visual/commands.d.ts +36 -0
  76. package/dist/visual/commands.d.ts.map +1 -0
  77. package/dist/visual/commands.js +2 -0
  78. package/dist/visual/commands.js.map +1 -0
  79. package/dist/visual/patch-transactions.d.ts +57 -0
  80. package/dist/visual/patch-transactions.d.ts.map +1 -0
  81. package/dist/visual/patch-transactions.js +259 -0
  82. package/dist/visual/patch-transactions.js.map +1 -0
  83. package/dist/vite/build-ai-plugin.d.ts +11 -0
  84. package/dist/vite/build-ai-plugin.d.ts.map +1 -0
  85. package/dist/vite/build-ai-plugin.js +45 -0
  86. package/dist/vite/build-ai-plugin.js.map +1 -0
  87. package/package.json +85 -0
  88. package/src/agent/agent-fallback.ts +95 -0
  89. package/src/agent/cli-agent-fallback.ts +1382 -0
  90. package/src/integration/index.ts +475 -0
  91. package/src/resolver/astro-resolver.ts +1024 -0
  92. package/src/resolver/types.ts +47 -0
  93. package/src/shared/protocol.ts +181 -0
  94. package/src/shared/selection-context.ts +91 -0
  95. package/src/shared/visual-components.ts +16 -0
  96. package/src/toolbar/action-model.ts +67 -0
  97. package/src/toolbar/app.ts +294 -0
  98. package/src/toolbar/chat-drawer.ts +1742 -0
  99. package/src/toolbar/chat-windows.ts +331 -0
  100. package/src/toolbar/contextual-actions.ts +401 -0
  101. package/src/toolbar/diagnostic-actions.ts +151 -0
  102. package/src/toolbar/layers.ts +32 -0
  103. package/src/toolbar/overlay.ts +638 -0
  104. package/src/visual/capability-resolver.ts +83 -0
  105. package/src/visual/command-engine.ts +250 -0
  106. package/src/visual/commands.ts +37 -0
  107. package/src/visual/patch-transactions.ts +270 -0
  108. package/src/vite/build-ai-plugin.ts +46 -0
@@ -0,0 +1,1024 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { extname, relative, resolve, sep } from 'node:path';
3
+
4
+ import { parse as parseAstro } from '@astrojs/compiler-rs';
5
+ import { parse as parseJavaScript } from '@babel/parser';
6
+ import MagicString from 'magic-string';
7
+
8
+ import type {
9
+ DataProvenance,
10
+ RepeatContext,
11
+ SelectionContext,
12
+ SourceInsertionZone,
13
+ SourceLocation,
14
+ } from '../shared/selection-context.js';
15
+ import { VisualCapabilityResolver } from '../visual/capability-resolver.js';
16
+ import type { SourceNodeRecord, SourceProp } from './types.js';
17
+
18
+ const NODE_ATTRIBUTE = 'data-astro-ai-id';
19
+ const NAME_ATTRIBUTE = 'data-astro-ai-name';
20
+ const SOURCE_ATTRIBUTE = 'data-astro-ai-source';
21
+ const RESERVED_ATTRIBUTES = new Set([NODE_ATTRIBUTE, NAME_ATTRIBUTE, SOURCE_ATTRIBUTE]);
22
+ const NON_RENDERED_TAGS = new Set(['script', 'style']);
23
+
24
+ type AstNode = {
25
+ type?: string;
26
+ start?: number;
27
+ end?: number;
28
+ [key: string]: any;
29
+ };
30
+
31
+ type Declaration = {
32
+ kind: 'local' | 'prop' | 'import' | 'external';
33
+ location: SourceLocation;
34
+ sourceFile?: string;
35
+ sourceType?: DataProvenance['sourceType'];
36
+ };
37
+
38
+ type WalkState = {
39
+ parentNodeId?: string | undefined;
40
+ siblingGroupId?: string | undefined;
41
+ parentComponents: SourceNodeRecord['parentComponents'];
42
+ repeatContext?: RepeatContext | undefined;
43
+ structuralPath: string;
44
+ };
45
+
46
+ export type InstrumentedSource = {
47
+ code: string;
48
+ map: ReturnType<MagicString['generateMap']>;
49
+ };
50
+
51
+ export type InstrumentedAstroSource = InstrumentedSource;
52
+
53
+ export class AstroResolver {
54
+ readonly #projectRoot: string;
55
+ readonly #capabilities: VisualCapabilityResolver;
56
+ readonly #nodes = new Map<string, SourceNodeRecord>();
57
+ readonly #nodesByFile = new Map<string, Set<string>>();
58
+ readonly #indexCache = new Map<string, { sourceHash: string; nodes: SourceNodeRecord[] }>();
59
+ readonly #skillFiles: string[];
60
+ readonly #sourceLengths = new Map<string, number>();
61
+
62
+ constructor(
63
+ projectRoot: string,
64
+ capabilities = new VisualCapabilityResolver(),
65
+ skillFiles: string[] = [],
66
+ ) {
67
+ this.#projectRoot = resolve(projectRoot);
68
+ this.#capabilities = capabilities;
69
+ this.#skillFiles = [...skillFiles];
70
+ }
71
+
72
+ get projectRoot(): string {
73
+ return this.#projectRoot;
74
+ }
75
+
76
+ ownsFile(filePath: string): boolean {
77
+ try {
78
+ this.toProjectFile(filePath);
79
+ return true;
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ instrumentFile(filePath: string, source: string): InstrumentedSource {
86
+ const nodes = this.indexFile(filePath, source);
87
+ const output = new MagicString(source);
88
+
89
+ for (const node of nodes) {
90
+ if (!node.instrumentable) continue;
91
+
92
+ const insertionPoint = findAttributeInsertionPoint(source, node.openingRange);
93
+ const name = node.componentName ?? node.tagName ?? 'Astro node';
94
+ const sourceHint = `${node.source.file}:${node.source.start.line}`;
95
+ output.appendLeft(
96
+ insertionPoint,
97
+ ` ${NODE_ATTRIBUTE}="${escapeAttribute(node.nodeId)}"` +
98
+ ` ${NAME_ATTRIBUTE}="${escapeAttribute(name)}"` +
99
+ ` ${SOURCE_ATTRIBUTE}="${escapeAttribute(sourceHint)}"`,
100
+ );
101
+ }
102
+
103
+ return {
104
+ code: output.toString(),
105
+ map: output.generateMap({
106
+ source: this.toProjectPath(filePath),
107
+ includeContent: true,
108
+ hires: true,
109
+ }),
110
+ };
111
+ }
112
+
113
+ indexFile(filePath: string, source: string): SourceNodeRecord[] {
114
+ const absoluteFile = this.toProjectFile(filePath);
115
+ const projectFile = this.toProjectPath(absoluteFile);
116
+ const sourceHash = hash(source);
117
+ const cached = this.#indexCache.get(absoluteFile);
118
+ if (cached?.sourceHash === sourceHash) return cached.nodes;
119
+ const sourceLanguage = getSourceLanguage(absoluteFile);
120
+ const parsed = parseSource(source, sourceLanguage, projectFile);
121
+ this.#clearFileNodes(absoluteFile);
122
+ const lineStarts = getLineStarts(source);
123
+ const declarations = collectDeclarations(
124
+ parsed.declarationProgram,
125
+ source,
126
+ projectFile,
127
+ lineStarts,
128
+ );
129
+ const nodes: SourceNodeRecord[] = [];
130
+ const siblingGroups = new Map<string, SourceNodeRecord[]>();
131
+ const structuralOccurrences = new Map<string, number>();
132
+
133
+ const walk = (value: unknown, state: WalkState): void => {
134
+ if (Array.isArray(value)) {
135
+ for (const item of value) walk(item, { ...state, siblingGroupId: undefined });
136
+ return;
137
+ }
138
+
139
+ if (!isAstNode(value)) return;
140
+
141
+ if (value.type === 'JSXElement') {
142
+ const opening = value.openingElement as AstNode | undefined;
143
+ const name = getJsxName(opening?.name);
144
+ if (
145
+ opening === undefined ||
146
+ name === undefined ||
147
+ value.start === undefined ||
148
+ value.end === undefined ||
149
+ opening.start === undefined ||
150
+ opening.end === undefined
151
+ ) {
152
+ return;
153
+ }
154
+
155
+ const component = isComponentName(name);
156
+ const hydratedIsland =
157
+ sourceLanguage === 'astro' &&
158
+ component &&
159
+ hasHydrationDirective(opening.attributes);
160
+ const location = toLocation(projectFile, value.start, value.end, lineStarts);
161
+ const structuralKey = `${state.structuralPath}/${name}`;
162
+ const occurrence = structuralOccurrences.get(structuralKey) ?? 0;
163
+ structuralOccurrences.set(structuralKey, occurrence + 1);
164
+ const structuralPath = `${structuralKey}[${occurrence}]`;
165
+ const nodeId = createNodeId(projectFile, structuralPath);
166
+ const content = analyzeContent(
167
+ value,
168
+ source,
169
+ projectFile,
170
+ declarations,
171
+ lineStarts,
172
+ state.repeatContext,
173
+ sourceLanguage,
174
+ );
175
+ const literalProps = collectLiteralProps(opening.attributes);
176
+ const attributes = Array.isArray(opening.attributes) ? opening.attributes : [];
177
+ const hasSpread = attributes.some((attribute) => attribute?.type === 'JSXSpreadAttribute');
178
+ const hasReservedAttribute = attributes.some(
179
+ (attribute) => RESERVED_ATTRIBUTES.has(getJsxName(attribute?.name) ?? ''),
180
+ );
181
+ const nativeTag = component ? undefined : name;
182
+ const instrumentable =
183
+ !hasReservedAttribute &&
184
+ !NON_RENDERED_TAGS.has(name) &&
185
+ name !== 'Fragment' &&
186
+ // React component calls are not DOM nodes and may not forward data
187
+ // attributes. Their native JSX output is instrumented instead.
188
+ (sourceLanguage === 'astro' || !component) &&
189
+ // A native root with a spread can deliberately forward a component's
190
+ // selection id. Appending another id would overwrite that provenance.
191
+ !(nativeTag !== undefined && hasSpread);
192
+
193
+ const record: SourceNodeRecord = {
194
+ nodeId,
195
+ filePath: absoluteFile,
196
+ sourceLanguage,
197
+ sourceHash,
198
+ structuralPath,
199
+ source: location,
200
+ range: { start: value.start, end: value.end },
201
+ openingRange: { start: opening.start, end: opening.end },
202
+ ...(content.textRange === undefined ? {} : { textRange: content.textRange }),
203
+ ...(content.textValue === undefined ? {} : { textValue: content.textValue }),
204
+ ...(component ? { componentName: name } : { tagName: name }),
205
+ ...(state.parentNodeId === undefined ? {} : { parentNodeId: state.parentNodeId }),
206
+ ...(state.siblingGroupId === undefined
207
+ ? {}
208
+ : { siblingGroupId: state.siblingGroupId }),
209
+ parentComponents: state.parentComponents,
210
+ literalProps,
211
+ sourceKind: hydratedIsland ? 'hydrated-island' : component ? 'component' : content.sourceKind,
212
+ dataProvenance: content.dataProvenance,
213
+ ...(state.repeatContext === undefined
214
+ ? {}
215
+ : { repeatContext: state.repeatContext }),
216
+ hydratedIsland,
217
+ instrumentable,
218
+ selfClosing: opening.selfClosing === true,
219
+ };
220
+
221
+ nodes.push(record);
222
+ this.#nodes.set(nodeId, record);
223
+ if (state.siblingGroupId !== undefined) {
224
+ const group = siblingGroups.get(state.siblingGroupId) ?? [];
225
+ group.push(record);
226
+ siblingGroups.set(state.siblingGroupId, group);
227
+ }
228
+
229
+ const nextParents = component
230
+ ? [
231
+ ...state.parentComponents,
232
+ { name, source: location },
233
+ ]
234
+ : state.parentComponents;
235
+ const childGroupId = `${nodeId}:children`;
236
+ const children = Array.isArray(value.children) ? value.children : [];
237
+
238
+ for (const child of children) {
239
+ walk(child, {
240
+ parentNodeId: nodeId,
241
+ parentComponents: nextParents,
242
+ structuralPath,
243
+ ...(state.repeatContext === undefined
244
+ ? {}
245
+ : { repeatContext: state.repeatContext }),
246
+ ...(child?.type === 'JSXElement'
247
+ ? { siblingGroupId: childGroupId }
248
+ : {}),
249
+ });
250
+ }
251
+ return;
252
+ }
253
+
254
+ if (value.type === 'JSXFragment') {
255
+ const fragmentId = `fragment:${value.start ?? 0}`;
256
+ const children = Array.isArray(value.children) ? value.children : [];
257
+ for (const child of children) {
258
+ walk(child, {
259
+ ...state,
260
+ structuralPath: `${state.structuralPath}/fragment`,
261
+ ...(child?.type === 'JSXElement'
262
+ ? { siblingGroupId: fragmentId }
263
+ : { siblingGroupId: undefined }),
264
+ });
265
+ }
266
+ return;
267
+ }
268
+
269
+ const componentDefinition = sourceLanguage === 'astro'
270
+ ? undefined
271
+ : getReactComponentDefinition(value, projectFile, lineStarts);
272
+ if (componentDefinition !== undefined) {
273
+ for (const [key, child] of Object.entries(value)) {
274
+ if (key === 'start' || key === 'end' || key === 'type') continue;
275
+ walk(child, {
276
+ ...state,
277
+ parentComponents: [...state.parentComponents, componentDefinition],
278
+ structuralPath: `${state.structuralPath}/component:${componentDefinition.name}`,
279
+ });
280
+ }
281
+ return;
282
+ }
283
+
284
+ const nextRepeat = isMapCall(value)
285
+ ? {
286
+ kind: 'map' as const,
287
+ description: 'Rendered by a .map() template; edits affect every rendered instance.',
288
+ source: toLocation(
289
+ projectFile,
290
+ value.start ?? 0,
291
+ value.end ?? value.start ?? 0,
292
+ lineStarts,
293
+ ),
294
+ affectsAllInstances: true as const,
295
+ }
296
+ : state.repeatContext;
297
+
298
+ for (const [key, child] of Object.entries(value)) {
299
+ if (key === 'start' || key === 'end' || key === 'type') continue;
300
+ walk(child, {
301
+ parentNodeId: state.parentNodeId,
302
+ parentComponents: state.parentComponents,
303
+ structuralPath: state.structuralPath,
304
+ ...(nextRepeat === undefined ? {} : { repeatContext: nextRepeat }),
305
+ });
306
+ }
307
+ };
308
+
309
+ walk(parsed.templateRoot, { parentComponents: [], structuralPath: 'root' });
310
+ assignSafeSiblings(siblingGroups, source);
311
+
312
+ this.#nodesByFile.set(absoluteFile, new Set(nodes.map(({ nodeId }) => nodeId)));
313
+ this.#indexCache.set(absoluteFile, { sourceHash, nodes });
314
+ this.#sourceLengths.set(absoluteFile, source.length);
315
+ return nodes;
316
+ }
317
+
318
+ resolveSelection(nodeId: string, route: string): SelectionContext {
319
+ const node = this.requireNode(nodeId);
320
+ const capabilities = this.#capabilities.resolve(node);
321
+
322
+ return {
323
+ route,
324
+ selectedNode: {
325
+ nodeId,
326
+ ...(node.tagName === undefined ? {} : { tagName: node.tagName }),
327
+ ...(node.componentName === undefined
328
+ ? {}
329
+ : { componentName: node.componentName }),
330
+ ...(node.textValue === undefined ? {} : { literalText: node.textValue }),
331
+ source: node.source,
332
+ },
333
+ parentComponents: node.parentComponents,
334
+ capabilities,
335
+ relevantFiles: [...new Set([
336
+ node.source.file,
337
+ ...(node.dataProvenance.declaredAt === undefined ? [] : [node.dataProvenance.declaredAt.file]),
338
+ ...(node.dataProvenance.sourceFile === undefined ? [] : [node.dataProvenance.sourceFile]),
339
+ ])],
340
+ skillFiles: [...this.#skillFiles],
341
+ };
342
+ }
343
+
344
+ requireNode(nodeId: string): SourceNodeRecord {
345
+ const node = this.#nodes.get(nodeId);
346
+ if (node === undefined) throw new Error('The selected source node is no longer available.');
347
+ return node;
348
+ }
349
+
350
+ listNodes(filePath?: string): SourceNodeRecord[] {
351
+ if (filePath === undefined) return [...this.#nodes.values()];
352
+ const absoluteFile = this.toProjectFile(filePath);
353
+ const ids = this.#nodesByFile.get(absoluteFile);
354
+ return ids === undefined
355
+ ? []
356
+ : [...ids].flatMap((id) => {
357
+ const node = this.#nodes.get(id);
358
+ return node === undefined ? [] : [node];
359
+ });
360
+ }
361
+
362
+ findInsertionPoints(filePath: string): SourceInsertionZone[] {
363
+ const absoluteFile = this.toProjectFile(filePath);
364
+ const projectFile = this.toProjectPath(absoluteFile);
365
+ const zones: SourceInsertionZone[] = [{
366
+ id: `${projectFile}:root`,
367
+ file: projectFile,
368
+ offset: this.#sourceLengths.get(absoluteFile) ?? 0,
369
+ acceptedChildTypes: ['*'],
370
+ }];
371
+ for (const node of this.listNodes(absoluteFile)) {
372
+ if (node.selfClosing || node.hydratedIsland || node.componentName === undefined) continue;
373
+ for (const slot of this.#capabilities.slotsFor(node.componentName)) {
374
+ zones.push({
375
+ id: `${node.nodeId}:slot:${slot.name}`,
376
+ file: projectFile,
377
+ offset: node.range.end - `</${node.componentName}>`.length,
378
+ parentNodeId: node.nodeId,
379
+ slot: slot.name,
380
+ acceptedChildTypes: slot.accepts ?? ['*'],
381
+ });
382
+ }
383
+ }
384
+ return zones;
385
+ }
386
+
387
+ findInsertionPointsForRoute(route: string): SourceInsertionZone[] {
388
+ const pathname = route.split(/[?#]/, 1)[0] ?? '/';
389
+ const segments = pathname.split('/').filter(Boolean);
390
+ const stem = segments.length === 0 ? 'index' : segments.join('/');
391
+ const candidates = new Set([
392
+ `src/pages/${stem}.astro`,
393
+ `src/pages/${stem}/index.astro`,
394
+ ]);
395
+ for (const file of this.#sourceLengths.keys()) {
396
+ if (candidates.has(this.toProjectPath(file))) return this.findInsertionPoints(file);
397
+ }
398
+ return [];
399
+ }
400
+
401
+ acceptsSlot(parentComponent: string, slot: string, childType: string): boolean {
402
+ return this.#capabilities.acceptsSlot(parentComponent, slot, childType);
403
+ }
404
+
405
+ removeFile(filePath: string): void {
406
+ const absoluteFile = this.toProjectFile(filePath);
407
+ this.#clearFileNodes(absoluteFile);
408
+ this.#indexCache.delete(absoluteFile);
409
+ this.#sourceLengths.delete(absoluteFile);
410
+ }
411
+
412
+ #clearFileNodes(absoluteFile: string): void {
413
+ const ids = this.#nodesByFile.get(absoluteFile);
414
+ if (ids !== undefined) {
415
+ for (const id of ids) this.#nodes.delete(id);
416
+ }
417
+ this.#nodesByFile.delete(absoluteFile);
418
+ }
419
+
420
+ toProjectPath(filePath: string): string {
421
+ const absoluteFile = this.toProjectFile(filePath);
422
+ return relative(this.#projectRoot, absoluteFile).split(sep).join('/');
423
+ }
424
+
425
+ toAbsoluteProjectFile(projectFile: string): string {
426
+ return this.toProjectFile(resolve(this.#projectRoot, projectFile));
427
+ }
428
+
429
+ private toProjectFile(filePath: string): string {
430
+ const absoluteFile = resolve(filePath);
431
+ const projectRelative = relative(this.#projectRoot, absoluteFile);
432
+ if (
433
+ projectRelative === '..' ||
434
+ projectRelative.startsWith(`..${sep}`) ||
435
+ resolve(this.#projectRoot, projectRelative) !== absoluteFile
436
+ ) {
437
+ throw new Error('Source access outside the Astro project root is not allowed.');
438
+ }
439
+ return absoluteFile;
440
+ }
441
+ }
442
+
443
+ type SourceLanguage = SourceNodeRecord['sourceLanguage'];
444
+
445
+ function getSourceLanguage(filePath: string): SourceLanguage {
446
+ const extension = extname(filePath).toLowerCase();
447
+ if (extension === '.astro') return 'astro';
448
+ if (extension === '.tsx' || extension === '.ts') return 'tsx';
449
+ if (extension === '.jsx' || extension === '.js') return 'jsx';
450
+ throw new Error(`Unsupported source file type: ${extension || '(none)'}.`);
451
+ }
452
+
453
+ function parseSource(
454
+ source: string,
455
+ language: SourceLanguage,
456
+ projectFile: string,
457
+ ): { templateRoot: unknown; declarationProgram?: AstNode } {
458
+ if (language === 'astro') {
459
+ const parsed = parseAstro(source);
460
+ const error = parsed.diagnostics.find((diagnostic) => diagnostic.severity === 'error');
461
+ if (error !== undefined) throw new Error(`Cannot parse ${projectFile}: ${error.text}`);
462
+ return {
463
+ templateRoot: parsed.ast.body,
464
+ declarationProgram: parsed.ast.frontmatter?.program,
465
+ };
466
+ }
467
+
468
+ try {
469
+ const parsed = parseJavaScript(source, {
470
+ sourceType: 'unambiguous',
471
+ plugins: language === 'tsx' ? ['jsx', 'typescript'] : ['jsx'],
472
+ });
473
+ return {
474
+ templateRoot: parsed.program,
475
+ declarationProgram: parsed.program as AstNode,
476
+ };
477
+ } catch (error) {
478
+ const detail = error instanceof Error ? error.message : 'Unknown parser error.';
479
+ throw new Error(`Cannot parse ${projectFile}: ${detail}`);
480
+ }
481
+ }
482
+
483
+ function getReactComponentDefinition(
484
+ node: AstNode,
485
+ projectFile: string,
486
+ lineStarts: number[],
487
+ ): SourceNodeRecord['parentComponents'][number] | undefined {
488
+ let name: string | undefined;
489
+ if (
490
+ (node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression') &&
491
+ typeof node.id?.name === 'string'
492
+ ) {
493
+ name = node.id.name;
494
+ } else if (
495
+ node.type === 'VariableDeclarator' &&
496
+ typeof node.id?.name === 'string' &&
497
+ (node.init?.type === 'ArrowFunctionExpression' || node.init?.type === 'FunctionExpression')
498
+ ) {
499
+ name = node.id.name;
500
+ }
501
+ if (name === undefined || !isComponentName(name)) return undefined;
502
+ const source = nodeLocation(node, projectFile, lineStarts);
503
+ return { name, ...(source === undefined ? {} : { source }) };
504
+ }
505
+
506
+ function analyzeContent(
507
+ node: AstNode,
508
+ source: string,
509
+ projectFile: string,
510
+ declarations: Map<string, Declaration>,
511
+ lineStarts: number[],
512
+ repeatContext: RepeatContext | undefined,
513
+ sourceLanguage: SourceLanguage,
514
+ ): {
515
+ textRange?: { start: number; end: number };
516
+ textValue?: string;
517
+ sourceKind: SourceNodeRecord['sourceKind'];
518
+ dataProvenance: DataProvenance;
519
+ } {
520
+ const children = Array.isArray(node.children) ? node.children : [];
521
+ const meaningful = children.filter(
522
+ (child) => child?.type !== 'JSXText' || String(child.raw ?? child.value ?? '').trim() !== '',
523
+ );
524
+
525
+ if (
526
+ meaningful.length === 1 &&
527
+ meaningful[0]?.type === 'JSXText' &&
528
+ meaningful[0].start !== undefined &&
529
+ meaningful[0].end !== undefined
530
+ ) {
531
+ const raw = source.slice(meaningful[0].start, meaningful[0].end);
532
+ return {
533
+ textRange: { start: meaningful[0].start, end: meaningful[0].end },
534
+ textValue: raw.trim(),
535
+ sourceKind: repeatContext === undefined ? 'literal-source' : 'repeated-template',
536
+ dataProvenance: {
537
+ kind: 'literal',
538
+ description:
539
+ repeatContext === undefined
540
+ ? `Literal text in this ${sourceLanguage === 'astro' ? 'Astro' : 'React'} template.`
541
+ : `Literal text in a repeated ${sourceLanguage === 'astro' ? 'Astro' : 'React'} template.`,
542
+ readOnly: false,
543
+ },
544
+ };
545
+ }
546
+
547
+ if (meaningful.length === 1 && meaningful[0]?.type === 'JSXExpressionContainer') {
548
+ return classifyExpression(
549
+ meaningful[0].expression,
550
+ source,
551
+ declarations,
552
+ repeatContext,
553
+ );
554
+ }
555
+
556
+ return {
557
+ sourceKind: repeatContext === undefined ? 'generated-unknown' : 'repeated-template',
558
+ dataProvenance: {
559
+ kind: 'unknown',
560
+ description:
561
+ repeatContext === undefined
562
+ ? 'Mixed or generated content; use source navigation or the agent fallback.'
563
+ : 'Generated inside a repeated template; edits may affect every instance.',
564
+ readOnly: true,
565
+ },
566
+ };
567
+ }
568
+
569
+ function classifyExpression(
570
+ expression: AstNode | undefined,
571
+ source: string,
572
+ declarations: Map<string, Declaration>,
573
+ repeatContext: RepeatContext | undefined,
574
+ ): Pick<SourceNodeRecord, 'sourceKind' | 'dataProvenance'> {
575
+ const symbol = getRootSymbol(expression);
576
+ const declaration = symbol === undefined ? undefined : declarations.get(symbol);
577
+
578
+ if (isAstroPropsMember(expression)) {
579
+ const name = getJsxName(expression?.property) ?? 'Astro.props value';
580
+ return {
581
+ sourceKind: repeatContext === undefined ? 'prop' : 'repeated-template',
582
+ dataProvenance: {
583
+ kind: 'prop',
584
+ symbol: name,
585
+ description: `Prop “${name}”; edit it at the parent component call site.`,
586
+ readOnly: true,
587
+ },
588
+ };
589
+ }
590
+
591
+ if (symbol !== undefined && declaration !== undefined) {
592
+ const descriptions = {
593
+ local: `Local variable “${symbol}”; navigate to its declaration.`,
594
+ prop: `Prop “${symbol}”; trace it to the parent component call site.`,
595
+ import: `Imported value “${symbol}”; navigate to its definition.`,
596
+ external: `External/API-backed value “${symbol}”; read-only without an editable source integration.`,
597
+ } as const;
598
+ const kinds = {
599
+ local: 'local-variable',
600
+ prop: 'prop',
601
+ import: 'external',
602
+ external: 'external',
603
+ } as const;
604
+ const provenanceKinds = {
605
+ local: 'local',
606
+ prop: 'prop',
607
+ import: 'import',
608
+ external: 'external',
609
+ } as const;
610
+
611
+ return {
612
+ sourceKind: repeatContext === undefined ? kinds[declaration.kind] : 'repeated-template',
613
+ dataProvenance: {
614
+ kind: provenanceKinds[declaration.kind],
615
+ symbol,
616
+ description: descriptions[declaration.kind],
617
+ declaredAt: declaration.location,
618
+ ...(declaration.sourceFile === undefined ? {} : { sourceFile: declaration.sourceFile }),
619
+ ...(declaration.sourceType === undefined ? {} : { sourceType: declaration.sourceType }),
620
+ readOnly: true,
621
+ },
622
+ };
623
+ }
624
+
625
+ const expressionText =
626
+ expression?.start === undefined || expression.end === undefined
627
+ ? ''
628
+ : source.slice(expression.start, expression.end);
629
+ const sourceType = detectDataSource(expressionText);
630
+ const external = sourceType !== undefined;
631
+ return {
632
+ sourceKind: repeatContext === undefined
633
+ ? external
634
+ ? 'external'
635
+ : 'generated-unknown'
636
+ : 'repeated-template',
637
+ dataProvenance: {
638
+ kind: external ? 'external' : 'unknown',
639
+ description: external
640
+ ? `${dataSourceLabel(sourceType)} content; read-only without an editable source integration.`
641
+ : 'Generated or unresolved content; use source navigation or the agent fallback.',
642
+ ...(sourceType === undefined ? {} : { sourceType }),
643
+ readOnly: true,
644
+ },
645
+ };
646
+ }
647
+
648
+ function collectDeclarations(
649
+ program: AstNode | undefined,
650
+ source: string,
651
+ projectFile: string,
652
+ lineStarts: number[],
653
+ ): Map<string, Declaration> {
654
+ const declarations = new Map<string, Declaration>();
655
+ if (program === undefined) return declarations;
656
+
657
+ const visit = (value: unknown): void => {
658
+ if (Array.isArray(value)) {
659
+ for (const item of value) visit(item);
660
+ return;
661
+ }
662
+ if (!isAstNode(value)) return;
663
+
664
+ if (value.type === 'ImportDeclaration') {
665
+ const location = nodeLocation(value, projectFile, lineStarts);
666
+ const importSource = typeof value.source?.value === 'string' ? value.source.value : undefined;
667
+ const sourceType = importSource === undefined ? 'import' : detectImportSource(importSource);
668
+ const sourceFile = importSource?.startsWith('.') === true
669
+ ? resolveRelativeImport(projectFile, importSource)
670
+ : undefined;
671
+ for (const specifier of value.specifiers ?? []) {
672
+ const name = specifier?.local?.name;
673
+ if (typeof name === 'string' && location !== undefined) {
674
+ declarations.set(name, { kind: sourceType === 'import' ? 'import' : 'external', location, sourceType, ...(sourceFile === undefined ? {} : { sourceFile }) });
675
+ }
676
+ }
677
+ return;
678
+ }
679
+
680
+ if (value.type === 'VariableDeclarator') {
681
+ const location = nodeLocation(value, projectFile, lineStarts);
682
+ if (location === undefined) return;
683
+ const declarationText = source.slice(value.start ?? 0, value.end ?? 0);
684
+ const sourceType = detectDataSource(declarationText);
685
+ const external = sourceType !== undefined;
686
+ const fromProps = isAstroPropsMember(value.init) || isAstroPropsObject(value.init);
687
+
688
+ if (value.id?.type === 'Identifier' && typeof value.id.name === 'string') {
689
+ declarations.set(value.id.name, {
690
+ kind: external ? 'external' : fromProps ? 'prop' : 'local',
691
+ location,
692
+ ...(sourceType === undefined ? {} : { sourceType }),
693
+ });
694
+ } else if (value.id?.type === 'ObjectPattern') {
695
+ for (const property of value.id.properties ?? []) {
696
+ const name = property?.value?.name ?? property?.key?.name;
697
+ if (typeof name === 'string') {
698
+ declarations.set(name, {
699
+ kind: fromProps ? 'prop' : external ? 'external' : 'local',
700
+ location,
701
+ ...(sourceType === undefined ? {} : { sourceType }),
702
+ });
703
+ }
704
+ }
705
+ }
706
+ if (
707
+ value.init?.type === 'ArrowFunctionExpression' ||
708
+ value.init?.type === 'FunctionExpression'
709
+ ) {
710
+ for (const parameter of value.init.params ?? []) {
711
+ const parameterLocation = isAstNode(parameter)
712
+ ? nodeLocation(parameter, projectFile, lineStarts)
713
+ : undefined;
714
+ if (parameterLocation !== undefined) {
715
+ for (const name of collectBindingNames(parameter)) {
716
+ declarations.set(name, { kind: 'prop', location: parameterLocation });
717
+ }
718
+ }
719
+ }
720
+ }
721
+ }
722
+
723
+ if (
724
+ value.type === 'FunctionDeclaration' ||
725
+ value.type === 'FunctionExpression' ||
726
+ value.type === 'ArrowFunctionExpression'
727
+ ) {
728
+ for (const parameter of value.params ?? []) {
729
+ const location = isAstNode(parameter)
730
+ ? nodeLocation(parameter, projectFile, lineStarts)
731
+ : undefined;
732
+ if (location !== undefined) {
733
+ for (const name of collectBindingNames(parameter)) {
734
+ declarations.set(name, { kind: 'prop', location });
735
+ }
736
+ }
737
+ }
738
+ }
739
+
740
+ for (const [key, child] of Object.entries(value)) {
741
+ if (key !== 'start' && key !== 'end' && key !== 'type') visit(child);
742
+ }
743
+ };
744
+
745
+ visit(program.body);
746
+ return declarations;
747
+ }
748
+
749
+ function collectLiteralProps(attributes: unknown): SourceProp[] {
750
+ if (!Array.isArray(attributes)) return [];
751
+ const props: SourceProp[] = [];
752
+
753
+ for (const attribute of attributes) {
754
+ if (
755
+ attribute?.type !== 'JSXAttribute' ||
756
+ attribute.start === undefined ||
757
+ attribute.end === undefined
758
+ ) {
759
+ continue;
760
+ }
761
+ const name = getJsxName(attribute.name);
762
+ if (name === undefined || RESERVED_ATTRIBUTES.has(name) || name.includes(':')) continue;
763
+
764
+ if (attribute.value == null) {
765
+ props.push({
766
+ name,
767
+ type: 'boolean',
768
+ value: true,
769
+ range: { start: attribute.start, end: attribute.end },
770
+ syntax: 'shorthand',
771
+ });
772
+ continue;
773
+ }
774
+
775
+ if (
776
+ isLiteralNode(attribute.value) &&
777
+ typeof attribute.value.value === 'string' &&
778
+ attribute.value.start !== undefined &&
779
+ attribute.value.end !== undefined
780
+ ) {
781
+ props.push({
782
+ name,
783
+ type: 'string',
784
+ value: attribute.value.value,
785
+ range: { start: attribute.value.start, end: attribute.value.end },
786
+ syntax: 'quoted',
787
+ });
788
+ continue;
789
+ }
790
+
791
+ const expression = attribute.value.type === 'JSXExpressionContainer'
792
+ ? attribute.value.expression
793
+ : undefined;
794
+ if (
795
+ !isLiteralNode(expression) ||
796
+ !['string', 'number', 'boolean'].includes(typeof expression.value) ||
797
+ attribute.value.start === undefined ||
798
+ attribute.value.end === undefined
799
+ ) {
800
+ continue;
801
+ }
802
+
803
+ props.push({
804
+ name,
805
+ type: typeof expression.value as 'string' | 'number' | 'boolean',
806
+ value: expression.value,
807
+ range: { start: attribute.value.start, end: attribute.value.end },
808
+ syntax: 'expression',
809
+ });
810
+ }
811
+ return props;
812
+ }
813
+
814
+ function collectBindingNames(node: AstNode): string[] {
815
+ if (node.type === 'Identifier' && typeof node.name === 'string') return [node.name];
816
+ if (node.type === 'AssignmentPattern' || node.type === 'RestElement') {
817
+ return isAstNode(node.left ?? node.argument)
818
+ ? collectBindingNames((node.left ?? node.argument) as AstNode)
819
+ : [];
820
+ }
821
+ if (node.type === 'TSParameterProperty' && isAstNode(node.parameter)) {
822
+ return collectBindingNames(node.parameter);
823
+ }
824
+ if (node.type === 'ObjectPattern' || node.type === 'ArrayPattern') {
825
+ const candidates = node.type === 'ObjectPattern'
826
+ ? (node.properties ?? []).map((property: AstNode) => property?.value ?? property?.argument)
827
+ : node.elements ?? [];
828
+ return candidates.flatMap((candidate: unknown) =>
829
+ isAstNode(candidate) ? collectBindingNames(candidate) : []
830
+ );
831
+ }
832
+ return [];
833
+ }
834
+
835
+ function isLiteralNode(node: AstNode | undefined): boolean {
836
+ return (
837
+ node?.type === 'Literal' ||
838
+ node?.type === 'StringLiteral' ||
839
+ node?.type === 'NumericLiteral' ||
840
+ node?.type === 'BooleanLiteral'
841
+ );
842
+ }
843
+
844
+ function assignSafeSiblings(
845
+ groups: Map<string, SourceNodeRecord[]>,
846
+ source: string,
847
+ ): void {
848
+ for (const group of groups.values()) {
849
+ group.sort((left, right) => left.range.start - right.range.start);
850
+ for (let index = 0; index < group.length - 1; index += 1) {
851
+ const left = group[index];
852
+ const right = group[index + 1];
853
+ if (left === undefined || right === undefined) continue;
854
+ if (source.slice(left.range.end, right.range.start).trim() !== '') continue;
855
+ left.nextSiblingId = right.nodeId;
856
+ right.previousSiblingId = left.nodeId;
857
+ }
858
+ }
859
+ }
860
+
861
+ function findAttributeInsertionPoint(source: string, range: { start: number; end: number }): number {
862
+ const opening = source.slice(range.start, range.end);
863
+ const relativePoint = opening.endsWith('/>') ? opening.length - 2 : opening.length - 1;
864
+ return range.start + relativePoint;
865
+ }
866
+
867
+ function escapeAttribute(value: string): string {
868
+ return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;');
869
+ }
870
+
871
+ function getJsxName(node: AstNode | undefined): string | undefined {
872
+ if (node?.type === 'JSXIdentifier' && typeof node.name === 'string') return node.name;
873
+ if (node?.type === 'Identifier' && typeof node.name === 'string') return node.name;
874
+ if (node?.type === 'Literal' && typeof node.value === 'string') return node.value;
875
+ if (node?.type === 'JSXNamespacedName') {
876
+ const namespace = getJsxName(node.namespace);
877
+ const name = getJsxName(node.name);
878
+ return namespace === undefined || name === undefined ? undefined : `${namespace}:${name}`;
879
+ }
880
+ if (node?.type === 'JSXMemberExpression') {
881
+ const object = getJsxName(node.object);
882
+ const property = getJsxName(node.property);
883
+ return object === undefined || property === undefined ? undefined : `${object}.${property}`;
884
+ }
885
+ return undefined;
886
+ }
887
+
888
+ function getRootSymbol(expression: AstNode | undefined): string | undefined {
889
+ if (expression?.type === 'Identifier' && typeof expression.name === 'string') return expression.name;
890
+ if (expression?.type === 'MemberExpression') return getRootSymbol(expression.object);
891
+ if (expression?.type === 'ChainExpression') return getRootSymbol(expression.expression);
892
+ return undefined;
893
+ }
894
+
895
+ function isAstroPropsObject(node: AstNode | undefined): boolean {
896
+ return (
897
+ node?.type === 'MemberExpression' &&
898
+ node.object?.type === 'Identifier' &&
899
+ node.object.name === 'Astro' &&
900
+ node.property?.type === 'Identifier' &&
901
+ node.property.name === 'props'
902
+ );
903
+ }
904
+
905
+ function isAstroPropsMember(node: AstNode | undefined): boolean {
906
+ return node?.type === 'MemberExpression' && isAstroPropsObject(node.object);
907
+ }
908
+
909
+ function isMapCall(node: AstNode): boolean {
910
+ return (
911
+ node.type === 'CallExpression' &&
912
+ node.callee?.type === 'MemberExpression' &&
913
+ node.callee.property?.type === 'Identifier' &&
914
+ node.callee.property.name === 'map'
915
+ );
916
+ }
917
+
918
+ function hasHydrationDirective(attributes: unknown): boolean {
919
+ return (
920
+ Array.isArray(attributes) &&
921
+ attributes.some((attribute) => getJsxName(attribute?.name)?.startsWith('client:'))
922
+ );
923
+ }
924
+
925
+ function isComponentName(name: string): boolean {
926
+ return /^[A-Z]/.test(name) || name.includes('.');
927
+ }
928
+
929
+ function detectImportSource(source: string): NonNullable<DataProvenance['sourceType']> {
930
+ if (source === 'astro:content') return 'content-collection';
931
+ if (source === 'astro:actions' || source.includes('/actions')) return 'action';
932
+ if (/graphql|apollo|urql/i.test(source)) return 'graphql';
933
+ return 'import';
934
+ }
935
+
936
+ function detectDataSource(source: string): DataProvenance['sourceType'] | undefined {
937
+ if (/\b(?:getCollection|getEntry|getEntries|render)\s*\(/.test(source)) return 'content-collection';
938
+ if (/\b(?:Astro\.callAction|actions\.|defineAction)\b/.test(source)) return 'action';
939
+ if (/\b(?:gql|graphql|useQuery|client\.query)\b/.test(source)) return 'graphql';
940
+ if (/\b(?:fetch|axios\.|ky\.|request)\s*\(/.test(source)) return 'api';
941
+ return undefined;
942
+ }
943
+
944
+ function dataSourceLabel(sourceType: NonNullable<DataProvenance['sourceType']>): string {
945
+ const labels = {
946
+ api: 'API',
947
+ action: 'Astro Action',
948
+ 'content-collection': 'Astro Content Collection',
949
+ graphql: 'GraphQL',
950
+ import: 'Imported',
951
+ } as const;
952
+ return labels[sourceType];
953
+ }
954
+
955
+ function resolveRelativeImport(projectFile: string, source: string): string {
956
+ const segments = projectFile.split('/');
957
+ segments.pop();
958
+ for (const segment of source.split('/')) {
959
+ if (segment === '' || segment === '.') continue;
960
+ if (segment === '..') segments.pop();
961
+ else segments.push(segment);
962
+ }
963
+ return segments.join('/');
964
+ }
965
+
966
+ function createNodeId(projectFile: string, structuralPath: string): string {
967
+ return createHash('sha256').update(`${projectFile}:${structuralPath}`).digest('hex').slice(0, 16);
968
+ }
969
+
970
+ function hash(source: string): string {
971
+ return createHash('sha256').update(source).digest('hex');
972
+ }
973
+
974
+ function getLineStarts(source: string): number[] {
975
+ const starts = [0];
976
+ for (let index = 0; index < source.length; index += 1) {
977
+ if (source[index] === '\n') starts.push(index + 1);
978
+ }
979
+ return starts;
980
+ }
981
+
982
+ function toLocation(
983
+ file: string,
984
+ start: number,
985
+ end: number,
986
+ lineStarts: number[],
987
+ ): SourceLocation {
988
+ return {
989
+ file,
990
+ start: toPosition(start, lineStarts),
991
+ end: toPosition(end, lineStarts),
992
+ };
993
+ }
994
+
995
+ function nodeLocation(
996
+ node: AstNode,
997
+ file: string,
998
+ lineStarts: number[],
999
+ ): SourceLocation | undefined {
1000
+ return node.start === undefined || node.end === undefined
1001
+ ? undefined
1002
+ : toLocation(file, node.start, node.end, lineStarts);
1003
+ }
1004
+
1005
+ function toPosition(offset: number, lineStarts: number[]) {
1006
+ let low = 0;
1007
+ let high = lineStarts.length - 1;
1008
+ while (low <= high) {
1009
+ const middle = Math.floor((low + high) / 2);
1010
+ const lineStart = lineStarts[middle] ?? 0;
1011
+ if (lineStart <= offset) low = middle + 1;
1012
+ else high = middle - 1;
1013
+ }
1014
+ const lineIndex = Math.max(0, high);
1015
+ return {
1016
+ line: lineIndex + 1,
1017
+ column: offset - (lineStarts[lineIndex] ?? 0) + 1,
1018
+ offset,
1019
+ };
1020
+ }
1021
+
1022
+ function isAstNode(value: unknown): value is AstNode {
1023
+ return typeof value === 'object' && value !== null;
1024
+ }