editor-ts 0.0.10 → 0.0.12

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.
@@ -1,27 +1,78 @@
1
1
  import type { PageBody, Component, ComponentQuery } from '../types';
2
+ import { sanitizeHTML, cssStringToObject } from '../utils/helpers';
2
3
 
3
4
  /**
4
5
  * Manager for handling component operations
5
6
  */
7
+ export type DomAdapter = {
8
+ createTemplate(): HTMLTemplateElement;
9
+ };
10
+
6
11
  export class ComponentManager {
12
+ private static readonly voidTags = new Set([
13
+ 'area',
14
+ 'base',
15
+ 'br',
16
+ 'col',
17
+ 'embed',
18
+ 'hr',
19
+ 'img',
20
+ 'input',
21
+ 'link',
22
+ 'meta',
23
+ 'param',
24
+ 'source',
25
+ 'track',
26
+ 'wbr',
27
+ ]);
28
+
7
29
  private body: PageBody;
8
30
  private parsedComponents: Component[];
31
+ private dom: DomAdapter | null;
9
32
 
10
- constructor(body: PageBody) {
33
+ constructor(body: PageBody, options?: { dom?: DomAdapter | null }) {
11
34
  this.body = body;
12
35
  this.parsedComponents = this.parse();
36
+
37
+ this.dom = options?.dom ?? (typeof document !== 'undefined'
38
+ ? {
39
+ createTemplate: () => document.createElement('template'),
40
+ }
41
+ : null);
42
+
43
+ // If we were given HTML without components, derive components from HTML.
44
+ if (this.parsedComponents.length === 0 && typeof this.body.html === 'string' && this.body.html.trim() !== '') {
45
+ this.setFromHTML(this.body.html);
46
+ }
47
+
48
+ // Keep html in sync when components are available.
49
+ if (this.parsedComponents.length > 0) {
50
+ this.syncHtmlFromComponents();
51
+ }
13
52
  }
14
53
 
15
54
  /**
16
55
  * Parse components from JSON string
17
56
  */
18
57
  private parse(): Component[] {
19
- try {
20
- return JSON.parse(this.body.components) as Component[];
21
- } catch (error) {
22
- console.error('Failed to parse components:', error);
23
- return [];
58
+ const raw = this.body.components;
59
+
60
+ if (Array.isArray(raw)) {
61
+ return raw;
62
+ }
63
+
64
+ if (typeof raw === 'string') {
65
+ try {
66
+ return JSON.parse(raw) as Component[];
67
+ } catch (error: unknown) {
68
+ const message = error instanceof Error ? error.message : String(error);
69
+ console.error('Failed to parse components:', message);
70
+ return [];
71
+ }
72
+
24
73
  }
74
+
75
+ return [];
25
76
  }
26
77
 
27
78
  /**
@@ -163,6 +214,47 @@ export class ComponentManager {
163
214
  return false;
164
215
  }
165
216
 
217
+ /**
218
+ * Update a component's text content
219
+ */
220
+ updateTextContent(id: string, content: string): boolean {
221
+ const component = this.findById(id);
222
+ if (component) {
223
+ component.content = content;
224
+ return true;
225
+ }
226
+ return false;
227
+ }
228
+
229
+ /**
230
+ * Update image src for a component (handles img tags and components with nested images)
231
+ */
232
+ updateImageSrc(id: string, src: string): boolean {
233
+ const component = this.findById(id);
234
+ if (component) {
235
+ // If component is an image type or has tagName img
236
+ if (component.tagName === 'img' || component.type === 'image') {
237
+ component.attributes = component.attributes || {};
238
+ component.attributes.src = src;
239
+ return true;
240
+ }
241
+ // Check if component has nested image in its content
242
+ if (component.content && component.content.includes('<img')) {
243
+ // Update src in content HTML
244
+ component.content = component.content.replace(
245
+ /(<img[^>]*src=["'])[^"']*["']/i,
246
+ `$1${src}"`
247
+ );
248
+ return true;
249
+ }
250
+ // Store as a generic image src attribute
251
+ component.attributes = component.attributes || {};
252
+ component.attributes.src = src;
253
+ return true;
254
+ }
255
+ return false;
256
+ }
257
+
166
258
  /**
167
259
  * Get all components
168
260
  */
@@ -190,11 +282,499 @@ export class ComponentManager {
190
282
  return count;
191
283
  }
192
284
 
285
+ /**
286
+ * Convert the current component tree to HTML.
287
+ */
288
+ toHTML(): string {
289
+ return this.componentsToHTML(this.parsedComponents);
290
+ }
291
+
292
+ /**
293
+ * Convert the current component tree to JSX/TSX source.
294
+ *
295
+ * Notes:
296
+ * - This is a best-effort export for round-tripping.
297
+ * - Attributes are emitted as JSX props; `class` becomes `className`.
298
+ * - Inline style strings are converted to an object expression.
299
+ */
300
+ toJSX(options?: { pretty?: boolean; indent?: string }): string {
301
+ const pretty = options?.pretty ?? true;
302
+ const indent = options?.indent ?? ' ';
303
+
304
+ const newline = pretty ? '\n' : '';
305
+
306
+ const toComponentName = (id: string): string => {
307
+ const cleaned = id
308
+ .replace(/[^a-zA-Z0-9_\-]/g, ' ')
309
+ .trim()
310
+ .split(/\s+|\-/g)
311
+ .filter(Boolean)
312
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
313
+ .join('');
314
+
315
+ return cleaned.match(/^[A-Z]/) ? cleaned : `C${cleaned}`;
316
+ };
317
+
318
+ const definitions: string[] = [];
319
+
320
+ const exportComponent = (component: Component) => {
321
+ const id = typeof component.attributes?.id === 'string' ? component.attributes.id : null;
322
+ if (!id) return;
323
+
324
+ const name = toComponentName(id);
325
+ const body = this.componentToJSX(component, 2, { pretty, indent, newline });
326
+
327
+ definitions.push(
328
+ `export function ${name}() {${newline}` +
329
+ `${indent}return (${newline}` +
330
+ `${body}${newline}` +
331
+ `${indent});${newline}` +
332
+ `}${newline}`
333
+ );
334
+ };
335
+
336
+ this.parsedComponents.forEach(exportComponent);
337
+
338
+ if (definitions.length === 0) {
339
+ // No ids to create named components; fall back to inline JSX.
340
+ const inline = this.parsedComponents
341
+ .map((component) => this.componentToJSX(component, 0, { pretty, indent, newline }))
342
+ .join(newline);
343
+
344
+ return `export function Template() {${newline}` +
345
+ `${indent}return (${newline}` +
346
+ `${pretty ? inline.split('\n').map((l) => (l ? indent + l : l)).join('\n') : inline}${newline}` +
347
+ `${indent});${newline}` +
348
+ `}`;
349
+ }
350
+
351
+ return definitions.join(newline);
352
+ }
353
+
354
+ /**
355
+ * Replace current components by parsing the provided HTML.
356
+ */
357
+ setFromHTML(html: string): void {
358
+ if (!this.dom) {
359
+ console.warn('EditorTs: ComponentManager.setFromHTML() requires DOM; provide a dom adapter when running server-side.');
360
+ return;
361
+ }
362
+
363
+ this.parsedComponents = this.htmlToComponents(html);
364
+ this.sync();
365
+ }
366
+
367
+ /**
368
+ * Replace current components by parsing the provided JSX/TSX.
369
+ *
370
+ * This is intended for server/build-time usage. It uses `typescript` (peer dep)
371
+ * and does not require DOM.
372
+ */
373
+ async setFromJSX(source: string): Promise<void> {
374
+ const components = await this.jsxToComponents(source);
375
+ if (components.length === 0) return;
376
+
377
+ this.parsedComponents = components;
378
+ this.sync();
379
+ }
380
+
381
+ /**
382
+ * Sync HTML from components back to body.html.
383
+ */
384
+ syncHtmlFromComponents(): void {
385
+ this.body.html = `<body>${this.toHTML()}</body>`;
386
+ }
387
+
193
388
  /**
194
389
  * Sync changes back to page body
195
390
  */
196
391
  sync(): void {
197
392
  this.body.components = JSON.stringify(this.parsedComponents);
393
+ if (this.parsedComponents.length > 0) {
394
+ this.syncHtmlFromComponents();
395
+ }
396
+ }
397
+
398
+ private componentsToHTML(components: Component[]): string {
399
+ return components.map((component) => this.componentToHTML(component)).join('');
400
+ }
401
+
402
+ private componentToHTML(component: Component): string {
403
+ const tagName = component.tagName ?? 'div';
404
+ const attributes = this.attributesToString(component.attributes);
405
+ const style = typeof component.style === 'string' && component.style.trim() !== '' ? ` style="${sanitizeHTML(component.style)}"` : '';
406
+
407
+ const contentText = typeof component.content === 'string' ? sanitizeHTML(component.content) : '';
408
+ const childrenHtml = component.components ? this.componentsToHTML(component.components) : '';
409
+
410
+ const isVoid = component.void === true || ComponentManager.voidTags.has(tagName.toLowerCase());
411
+
412
+ if (isVoid) {
413
+ return `<${tagName}${attributes}${style} />`;
414
+ }
415
+
416
+ return `<${tagName}${attributes}${style}>${contentText}${childrenHtml}</${tagName}>`;
417
+ }
418
+
419
+ private attributesToString(attributes: Component['attributes']): string {
420
+ if (!attributes) return '';
421
+
422
+ const parts: string[] = [];
423
+
424
+ Object.entries(attributes).forEach(([key, value]) => {
425
+ if (value === undefined || value === null) return;
426
+ if (key === 'style') return;
427
+
428
+ if (typeof value === 'boolean') {
429
+ if (value) parts.push(`${key}`);
430
+ return;
431
+ }
432
+
433
+ const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
434
+ parts.push(`${key}="${sanitizeHTML(stringValue)}"`);
435
+ });
436
+
437
+ return parts.length > 0 ? ` ${parts.join(' ')}` : '';
438
+ }
439
+
440
+ private componentToJSX(
441
+ component: Component,
442
+ depth: number,
443
+ options: { pretty: boolean; indent: string; newline: string }
444
+ ): string {
445
+ const tagName = component.tagName ?? 'div';
446
+
447
+ const { pretty, indent, newline } = options;
448
+ const leading = pretty ? indent.repeat(depth) : '';
449
+
450
+ const isVoid = component.void === true || ComponentManager.voidTags.has(tagName.toLowerCase());
451
+
452
+ const props = this.attributesToJSXProps(component);
453
+ const open = `<${tagName}${props}>`;
454
+
455
+ if (isVoid) {
456
+ return `${leading}<${tagName}${props} />`;
457
+ }
458
+
459
+ const children: string[] = [];
460
+
461
+ if (typeof component.content === 'string' && component.content.trim() !== '') {
462
+ children.push(this.escapeJsxText(component.content));
463
+ }
464
+
465
+ if (component.components && component.components.length > 0) {
466
+ const rendered = component.components.map((c) => this.componentToJSX(c, depth + 1, options));
467
+ children.push(rendered.join(newline));
468
+ }
469
+
470
+ if (children.length === 0) {
471
+ return `${leading}${open}</${tagName}>`;
472
+ }
473
+
474
+ if (!pretty) {
475
+ return `${leading}${open}${children.join('')}</${tagName}>`;
476
+ }
477
+
478
+ const inner = children
479
+ .map((child) => {
480
+ // If the child already has indentation (nested JSX), keep it as-is.
481
+ if (child.startsWith(indent.repeat(depth + 1))) return child;
482
+ return `${indent.repeat(depth + 1)}${child}`;
483
+ })
484
+ .join(newline);
485
+
486
+ return `${leading}${open}${newline}${inner}${newline}${leading}</${tagName}>`;
487
+ }
488
+
489
+ private escapeJsxText(text: string): string {
490
+ // Escape braces so the output remains valid JSX text.
491
+ return text.replace(/\{/g, '&#123;').replace(/\}/g, '&#125;');
492
+ }
493
+
494
+ private cssKeyToJsx(key: string): string {
495
+ return key.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
496
+ }
497
+
498
+ private toJsxStyleObject(styleText: string): Record<string, string> {
499
+ const raw = cssStringToObject(styleText);
500
+ const out: Record<string, string> = {};
501
+
502
+ Object.entries(raw).forEach(([key, value]) => {
503
+ out[this.cssKeyToJsx(key)] = value;
504
+ });
505
+
506
+ return out;
507
+ }
508
+
509
+ private attributesToJSXProps(component: Component): string {
510
+ const attributes = component.attributes;
511
+ const props: string[] = [];
512
+
513
+ if (attributes) {
514
+ Object.entries(attributes).forEach(([rawKey, value]) => {
515
+ if (value === undefined || value === null) return;
516
+
517
+ const key = rawKey === 'class' ? 'className' : rawKey;
518
+
519
+ if (typeof value === 'string') {
520
+ props.push(`${key}=${JSON.stringify(value)}`);
521
+ return;
522
+ }
523
+
524
+ if (typeof value === 'number' || typeof value === 'boolean') {
525
+ props.push(`${key}={${String(value)}}`);
526
+ return;
527
+ }
528
+
529
+ // Fallback: JSON
530
+ props.push(`${key}={${JSON.stringify(value)}}`);
531
+ });
532
+ }
533
+
534
+ if (typeof component.style === 'string' && component.style.trim() !== '') {
535
+ const styleObj = this.toJsxStyleObject(component.style);
536
+ props.push(`style={${JSON.stringify(styleObj)}}`);
537
+ }
538
+
539
+ return props.length > 0 ? ` ${props.join(' ')}` : '';
540
+ }
541
+
542
+ private async jsxToComponents(source: string): Promise<Component[]> {
543
+ const ts = await this.loadTypeScript();
544
+ if (!ts) return [];
545
+
546
+ const file = ts.createSourceFile('editorts.tsx', source, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX);
547
+
548
+ const roots: Component[] = [];
549
+
550
+ const visit = (node: import('typescript').Node) => {
551
+ if (ts.isJsxElement(node)) {
552
+ const component = this.jsxElementToComponent(ts, node);
553
+ if (component) roots.push(component);
554
+ return;
555
+ }
556
+
557
+ if (ts.isJsxSelfClosingElement(node)) {
558
+ const component = this.jsxSelfClosingElementToComponent(ts, node);
559
+ if (component) roots.push(component);
560
+ return;
561
+ }
562
+
563
+ ts.forEachChild(node, visit);
564
+ };
565
+
566
+ ts.forEachChild(file, visit);
567
+
568
+ return roots;
569
+ }
570
+
571
+ private async loadTypeScript(): Promise<typeof import('typescript') | null> {
572
+ try {
573
+ return await import('typescript');
574
+ } catch (err: unknown) {
575
+ const message = err instanceof Error ? err.message : String(err);
576
+ console.warn('EditorTs: setFromJSX() requires optional peer dependency typescript:', message);
577
+ return null;
578
+ }
579
+ }
580
+
581
+ private jsxElementToComponent(
582
+ ts: typeof import('typescript'),
583
+ node: import('typescript').JsxElement
584
+ ): Component | null {
585
+ const opening = node.openingElement;
586
+
587
+ const tagName = this.jsxTagName(ts, opening.tagName);
588
+ if (!tagName) return null;
589
+
590
+ const component: Component = {
591
+ type: tagName,
592
+ tagName,
593
+ attributes: this.jsxAttributesToRecord(ts, opening.attributes),
594
+ };
595
+
596
+ const children = this.jsxChildrenToComponents(ts, node.children);
597
+ if (children.length > 0) {
598
+ component.components = children;
599
+ }
600
+
601
+ // Prefer textContent only when there are no nested JSX elements.
602
+ const textContent = node.children
603
+ .filter((c) => ts.isJsxText(c))
604
+ .map((c) => c.getText())
605
+ .join('')
606
+ .trim();
607
+
608
+ if (children.length === 0 && textContent !== '') {
609
+ component.content = textContent;
610
+ }
611
+
612
+ return component;
613
+ }
614
+
615
+ private jsxSelfClosingElementToComponent(
616
+ ts: typeof import('typescript'),
617
+ node: import('typescript').JsxSelfClosingElement
618
+ ): Component | null {
619
+ const tagName = this.jsxTagName(ts, node.tagName);
620
+ if (!tagName) return null;
621
+
622
+ const component: Component = {
623
+ type: tagName,
624
+ tagName,
625
+ attributes: this.jsxAttributesToRecord(ts, node.attributes),
626
+ void: true,
627
+ };
628
+
629
+ return component;
630
+ }
631
+
632
+ private jsxChildrenToComponents(
633
+ ts: typeof import('typescript'),
634
+ children: readonly import('typescript').JsxChild[]
635
+ ): Component[] {
636
+ const out: Component[] = [];
637
+
638
+ children.forEach((child) => {
639
+ if (ts.isJsxElement(child)) {
640
+ const next = this.jsxElementToComponent(ts, child);
641
+ if (next) out.push(next);
642
+ } else if (ts.isJsxSelfClosingElement(child)) {
643
+ const next = this.jsxSelfClosingElementToComponent(ts, child);
644
+ if (next) out.push(next);
645
+ }
646
+ });
647
+
648
+ return out;
649
+ }
650
+
651
+ private jsxTagName(
652
+ ts: typeof import('typescript'),
653
+ tagName: import('typescript').JsxTagNameExpression
654
+ ): string | null {
655
+ if (ts.isIdentifier(tagName)) {
656
+ // Only allow intrinsic tags here.
657
+ return tagName.text;
658
+ }
659
+
660
+ if (ts.isPropertyAccessExpression(tagName)) {
661
+ return tagName.getText();
662
+ }
663
+
664
+ return tagName.getText();
665
+ }
666
+
667
+ private jsxAttributesToRecord(
668
+ ts: typeof import('typescript'),
669
+ attrs: import('typescript').JsxAttributes
670
+ ): Component['attributes'] {
671
+ const out: Component['attributes'] = {};
672
+
673
+ attrs.properties.forEach((prop) => {
674
+ if (ts.isJsxAttribute(prop)) {
675
+ const key = ts.isIdentifier(prop.name) ? prop.name.text : prop.name.getText();
676
+
677
+ if (!prop.initializer) {
678
+ out[key] = true;
679
+ return;
680
+ }
681
+
682
+ if (ts.isStringLiteral(prop.initializer)) {
683
+ out[key] = prop.initializer.text;
684
+ return;
685
+ }
686
+
687
+ if (ts.isJsxExpression(prop.initializer)) {
688
+ const expr = prop.initializer.expression;
689
+ if (!expr) return;
690
+
691
+ if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) {
692
+ out[key] = expr.text;
693
+ return;
694
+ }
695
+
696
+ if (ts.isNumericLiteral(expr)) {
697
+ out[key] = Number(expr.text);
698
+ return;
699
+ }
700
+
701
+ if (expr.kind === ts.SyntaxKind.TrueKeyword) {
702
+ out[key] = true;
703
+ return;
704
+ }
705
+
706
+ if (expr.kind === ts.SyntaxKind.FalseKeyword) {
707
+ out[key] = false;
708
+ return;
709
+ }
710
+
711
+ // For now, fall back to source string.
712
+ out[key] = expr.getText();
713
+ return;
714
+ }
715
+ }
716
+
717
+ if (ts.isJsxSpreadAttribute(prop)) {
718
+ // Spread props are not representable in JSON; ignore for now.
719
+ return;
720
+ }
721
+ });
722
+
723
+ return out;
724
+ }
725
+
726
+ private htmlToComponents(html: string): Component[] {
727
+ // Strip outer <body> wrapper if present.
728
+ const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
729
+ const bodyHtml = bodyMatch ? bodyMatch[1]! : html;
730
+
731
+ if (!this.dom) {
732
+ console.warn('EditorTs: ComponentManager.htmlToComponents() requires DOM; provide a dom adapter when running server-side.');
733
+ return [];
734
+ }
735
+
736
+ const template = this.dom.createTemplate();
737
+ template.innerHTML = bodyHtml;
738
+
739
+ const elements = Array.from(template.content.children) as HTMLElement[];
740
+ return elements.map((el) => this.elementToComponent(el));
741
+ }
742
+
743
+ private elementToComponent(el: HTMLElement): Component {
744
+ const tagName = el.tagName.toLowerCase();
745
+
746
+ const attributes: Component['attributes'] = {};
747
+ Array.from(el.attributes).forEach((attr) => {
748
+ if (attributes) {
749
+ attributes[attr.name] = attr.value;
750
+ }
751
+ });
752
+
753
+ const childElements = Array.from(el.children) as HTMLElement[];
754
+
755
+ const component: Component = {
756
+ type: tagName,
757
+ tagName,
758
+ attributes,
759
+ };
760
+
761
+ // Only treat as text content when there are no nested elements.
762
+ if (childElements.length === 0) {
763
+ const text = el.textContent ?? '';
764
+ if (text.trim() !== '') {
765
+ component.content = text;
766
+ }
767
+ }
768
+
769
+ if (childElements.length > 0) {
770
+ component.components = childElements.map((child) => this.elementToComponent(child));
771
+ }
772
+
773
+ if (ComponentManager.voidTags.has(tagName)) {
774
+ component.void = true;
775
+ }
776
+
777
+ return component;
198
778
  }
199
779
 
200
780
  /**
@@ -203,4 +783,115 @@ export class ComponentManager {
203
783
  replaceAll(components: Component[]): void {
204
784
  this.parsedComponents = components;
205
785
  }
786
+
787
+ /**
788
+ * Move a component to a new position
789
+ * @param componentId - The ID of the component to move
790
+ * @param newParentId - The ID of the new parent (null for root level)
791
+ * @param newIndex - The index position within the new parent
792
+ */
793
+ moveComponent(componentId: string, newParentId: string | null, newIndex: number): boolean {
794
+ // Find and remove the component from its current location
795
+ const component = this.findById(componentId);
796
+ if (!component) return false;
797
+
798
+ // Remove from current location
799
+ if (!this.removeComponent(componentId)) return false;
800
+
801
+ // Add to new location
802
+ if (newParentId === null) {
803
+ // Move to root level
804
+ const insertIndex = Math.min(newIndex, this.parsedComponents.length);
805
+ this.parsedComponents.splice(insertIndex, 0, component);
806
+ } else {
807
+ // Move to a parent component
808
+ const parent = this.findById(newParentId);
809
+ if (!parent) {
810
+ // Restore component to root if parent not found
811
+ this.parsedComponents.push(component);
812
+ return false;
813
+ }
814
+
815
+ if (!parent.components) {
816
+ parent.components = [];
817
+ }
818
+
819
+ const insertIndex = Math.min(newIndex, parent.components.length);
820
+ parent.components.splice(insertIndex, 0, component);
821
+ }
822
+
823
+ return true;
824
+ }
825
+
826
+ /**
827
+ * Reorder a component within its current parent
828
+ * @param componentId - The ID of the component to reorder
829
+ * @param newIndex - The new index position
830
+ */
831
+ reorderComponent(componentId: string, newIndex: number): boolean {
832
+ // Find the parent that contains this component
833
+ const result = this.findParentAndIndex(componentId);
834
+ if (!result) return false;
835
+
836
+ const { parent, index } = result;
837
+ const components = parent ? parent.components! : this.parsedComponents;
838
+
839
+ // Remove from current position
840
+ const [component] = components.splice(index, 1);
841
+
842
+ // Insert at new position (adjust for removal)
843
+ const adjustedIndex = newIndex > index ? newIndex - 1 : newIndex;
844
+ const insertIndex = Math.min(Math.max(0, adjustedIndex), components.length);
845
+ components.splice(insertIndex, 0, component!);
846
+
847
+ return true;
848
+ }
849
+
850
+ /**
851
+ * Get the parent component ID and index for a component.
852
+ * Returns { parentId: null } when the component is at the root.
853
+ */
854
+ getParentAndIndex(componentId: string): { parentId: string | null; index: number } | null {
855
+ const result = this.findParentAndIndex(componentId);
856
+ if (!result) return null;
857
+
858
+ return {
859
+ parentId: result.parent?.attributes?.id ?? null,
860
+ index: result.index,
861
+ };
862
+ }
863
+
864
+ /**
865
+ * Find the parent component and index of a component
866
+ */
867
+ private findParentAndIndex(componentId: string): { parent: Component | null; index: number } | null {
868
+ // Check root level
869
+ for (let i = 0; i < this.parsedComponents.length; i++) {
870
+ if (this.parsedComponents[i]?.attributes?.id === componentId) {
871
+ return { parent: null, index: i };
872
+ }
873
+ }
874
+
875
+ // Search recursively
876
+ return this.findParentAndIndexInTree(this.parsedComponents, componentId);
877
+ }
878
+
879
+ /**
880
+ * Recursively search for parent and index
881
+ */
882
+ private findParentAndIndexInTree(components: Component[], componentId: string): { parent: Component | null; index: number } | null {
883
+ for (const component of components) {
884
+ if (component.components) {
885
+ for (let i = 0; i < component.components.length; i++) {
886
+ if (component.components[i]?.attributes?.id === componentId) {
887
+ return { parent: component, index: i };
888
+ }
889
+ }
890
+ // Search deeper
891
+ const result = this.findParentAndIndexInTree(component.components, componentId);
892
+ if (result) return result;
893
+ }
894
+ }
895
+ return null;
896
+ }
206
897
  }