@nuasite/cms 0.12.4 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/editor.js CHANGED
@@ -373,7 +373,7 @@ function Eg(t, e) {
373
373
  function Mg(t, e) {
374
374
  return typeof e == "function" ? e(t) : e;
375
375
  }
376
- const ok = "0.12.4", sk = ok, it = {
376
+ const ok = "0.13.0", sk = ok, it = {
377
377
  /** Highlight overlay for hovered elements */
378
378
  HIGHLIGHT: 2147483644,
379
379
  /** Hover outline for elements/components */
package/package.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "directory": "packages/astro-cms"
15
15
  },
16
16
  "license": "Apache-2.0",
17
- "version": "0.12.4",
17
+ "version": "0.13.0",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -3,7 +3,7 @@ import fs from 'node:fs/promises'
3
3
  import type { IncomingMessage, ServerResponse } from 'node:http'
4
4
  import path from 'node:path'
5
5
  import { getProjectRoot } from './config'
6
- import { detectArrayPattern, extractArrayElementProps, handleAddArrayItem, handleRemoveArrayItem } from './handlers/array-ops'
6
+ import { buildMapPattern, detectArrayPattern, extractArrayElementProps, handleAddArrayItem, handleRemoveArrayItem, parseInlineArrayName } from './handlers/array-ops'
7
7
  import {
8
8
  extractPropsFromSource,
9
9
  findComponentInvocationLine,
@@ -507,6 +507,10 @@ async function processHtmlForDev(
507
507
  }
508
508
 
509
509
  for (const comp of Object.values(result.components)) {
510
+ // Skip inline array components — they have no <Tag> in source;
511
+ // their props are resolved in the array-group pass below
512
+ if (comp.componentName.startsWith('__array:')) continue
513
+
510
514
  let found = false
511
515
 
512
516
  // Try invocationSourcePath first (may point to a layout, not the page)
@@ -547,7 +551,7 @@ async function processHtmlForDev(
547
551
  }
548
552
 
549
553
  for (const group of componentGroups.values()) {
550
- if (group.length <= 1) continue
554
+ if (group.length < 1) continue
551
555
  // Only process groups where at least one component has empty props (spread case)
552
556
  if (!group.some(c => Object.keys(c.props).length === 0)) continue
553
557
 
@@ -556,11 +560,33 @@ async function processHtmlForDev(
556
560
  const lines = await readLines(path.resolve(projectRoot, filePath))
557
561
  if (!lines) continue
558
562
 
559
- // Find the invocation line (occurrence 0, since .map() has a single <Component> tag)
560
- const invLine = findComponentInvocationLine(lines, firstComp.componentName, 0)
561
- if (invLine < 0) continue
562
-
563
- const pattern = detectArrayPattern(lines, invLine)
563
+ // For inline array components (__array:varName or __array:varName#N), find the .map() line
564
+ // directly instead of searching for a component tag that won't exist
565
+ let pattern: ReturnType<typeof detectArrayPattern>
566
+ const parsed = parseInlineArrayName(firstComp.componentName)
567
+ if (parsed) {
568
+ const { arrayVarName, mapOccurrence } = parsed
569
+ const fmEndCheck = findFrontmatterEnd(lines)
570
+ const mapRegex = new RegExp(buildMapPattern(arrayVarName))
571
+ let mapLine = -1
572
+ let seen = 0
573
+ for (let i = fmEndCheck; i < lines.length; i++) {
574
+ if (mapRegex.test(lines[i]!)) {
575
+ if (seen === mapOccurrence) {
576
+ mapLine = i
577
+ break
578
+ }
579
+ seen++
580
+ }
581
+ }
582
+ if (mapLine < 0) continue
583
+ pattern = { arrayVarName, mapLineIndex: mapLine }
584
+ } else {
585
+ // Find the invocation line (occurrence 0, since .map() has a single <Component> tag)
586
+ const invLine = findComponentInvocationLine(lines, firstComp.componentName, 0)
587
+ if (invLine < 0) continue
588
+ pattern = detectArrayPattern(lines, invLine)
589
+ }
564
590
  if (!pattern) continue
565
591
 
566
592
  const fmEnd = findFrontmatterEnd(lines)
@@ -3,7 +3,7 @@ import fs from 'node:fs/promises'
3
3
  import { getProjectRoot } from '../config'
4
4
  import type { ManifestWriter } from '../manifest-writer'
5
5
  import type { CmsManifest, ComponentInstance } from '../types'
6
- import { acquireFileLock, normalizePagePath, resolveAndValidatePath } from '../utils'
6
+ import { acquireFileLock, escapeRegex, normalizePagePath, resolveAndValidatePath } from '../utils'
7
7
  import {
8
8
  findComponentInvocationFile,
9
9
  findComponentInvocationLine,
@@ -13,6 +13,40 @@ import {
13
13
  normalizeFilePath,
14
14
  } from './component-ops'
15
15
 
16
+ /**
17
+ * Parse an inline array component name like `__array:varName` or `__array:varName#1`.
18
+ * Returns the variable name and the .map() occurrence index (0-based).
19
+ */
20
+ export function parseInlineArrayName(componentName: string): { arrayVarName: string; mapOccurrence: number } | null {
21
+ if (!componentName.startsWith('__array:')) return null
22
+ const rest = componentName.slice('__array:'.length)
23
+ const hashIndex = rest.indexOf('#')
24
+ if (hashIndex < 0) return { arrayVarName: rest, mapOccurrence: 0 }
25
+ const occurrence = Number(rest.slice(hashIndex + 1))
26
+ if (Number.isNaN(occurrence)) return null
27
+ return {
28
+ arrayVarName: rest.slice(0, hashIndex),
29
+ mapOccurrence: occurrence,
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Build a regex pattern string that matches `.map(` calls on variables/properties.
35
+ *
36
+ * Supports simple variables (`{items.map(`) and dotted property paths
37
+ * (`{data.items.map(`, `{Astro.props.items.map(`).
38
+ * Also handles optional chaining (`{items?.map(`).
39
+ *
40
+ * If `varName` is omitted, uses a capture group to match any variable name
41
+ * (captures only the last segment before `.map`).
42
+ */
43
+ export function buildMapPattern(varName?: string): string {
44
+ // Allow optional leading dotted path segments (e.g. `Astro.props.`)
45
+ const prefix = '(?:[\\w]+\\.)*'
46
+ const name = varName ? escapeRegex(varName) : '(\\w+)'
47
+ return `\\{${prefix}${name}\\??\\.map\\s*\\(`
48
+ }
49
+
16
50
  export interface AddArrayItemRequest {
17
51
  referenceComponentId: string
18
52
  position: 'before' | 'after'
@@ -55,8 +89,7 @@ export function detectArrayPattern(
55
89
  const searchStart = Math.max(0, invocationLineIndex - 5)
56
90
  for (let i = invocationLineIndex; i >= searchStart; i--) {
57
91
  const line = lines[i]!
58
- // Match patterns like: {varName.map( or varName.map(
59
- const match = line.match(/\{?\s*(\w+)\.map\s*\(/)
92
+ const match = line.match(new RegExp(buildMapPattern()))
60
93
  if (match) {
61
94
  return { arrayVarName: match[1]!, mapLineIndex: i }
62
95
  }
@@ -131,7 +164,7 @@ function extractElementBounds(
131
164
  ): ArrayElementBounds[] {
132
165
  const bounds: ArrayElementBounds[] = []
133
166
  for (const el of elements) {
134
- if (el?.loc) {
167
+ if (el?.loc && el.type !== 'SpreadElement') {
135
168
  bounds.push({
136
169
  // Babel loc is 1-indexed; convert to 0-indexed file lines
137
170
  startLine: el.loc.start.line - 1 + frontmatterStartLine,
@@ -254,6 +287,66 @@ async function resolveArrayContext(
254
287
  ) {
255
288
  const projectRoot = getProjectRoot()
256
289
 
290
+ // Inline array components (__array:varName or __array:varName#N) — find .map() line directly
291
+ const parsed = parseInlineArrayName(component.componentName)
292
+ if (parsed) {
293
+ const { arrayVarName, mapOccurrence } = parsed
294
+ const filePath = normalizeFilePath(component.invocationSourcePath ?? component.sourcePath)
295
+ const fullPath = resolveAndValidatePath(filePath)
296
+ const content = await fs.readFile(fullPath, 'utf-8')
297
+ const lines = content.split('\n')
298
+
299
+ // Find the Nth .map() line by searching the template section
300
+ const fmEnd = findFrontmatterEnd(lines)
301
+ if (fmEnd === 0) return null
302
+
303
+ let mapLineIndex = -1
304
+ let occurrencesSeen = 0
305
+ const mapPattern = new RegExp(buildMapPattern(arrayVarName))
306
+ for (let i = fmEnd; i < lines.length; i++) {
307
+ if (mapPattern.test(lines[i]!)) {
308
+ if (occurrencesSeen === mapOccurrence) {
309
+ mapLineIndex = i
310
+ break
311
+ }
312
+ occurrencesSeen++
313
+ }
314
+ }
315
+ if (mapLineIndex < 0) return null
316
+
317
+ const frontmatterStartLine = 1
318
+ const frontmatterContent = lines.slice(1, fmEnd - 1).join('\n')
319
+
320
+ const elementBounds = findArrayDeclaration(
321
+ frontmatterContent,
322
+ frontmatterStartLine,
323
+ arrayVarName,
324
+ )
325
+ if (!elementBounds || elementBounds.length === 0) return null
326
+
327
+ // Get array index from same-source components (sorted by invocationIndex for reliable ordering)
328
+ const sameSourceComponents = Object.values(manifest.components)
329
+ .filter(c =>
330
+ c.componentName === component.componentName
331
+ && c.invocationSourcePath === component.invocationSourcePath
332
+ )
333
+ .sort((a, b) => (a.invocationIndex ?? 0) - (b.invocationIndex ?? 0))
334
+ const arrayIndex = sameSourceComponents.findIndex(c => c.id === component.id)
335
+ if (arrayIndex < 0 || arrayIndex >= elementBounds.length) return null
336
+
337
+ return {
338
+ filePath,
339
+ fullPath,
340
+ lines,
341
+ content,
342
+ elementBounds,
343
+ arrayIndex,
344
+ frontmatterContent,
345
+ frontmatterStartLine,
346
+ arrayVarName,
347
+ }
348
+ }
349
+
257
350
  const invocation = await findComponentInvocationFile(
258
351
  projectRoot,
259
352
  pageUrl,
@@ -308,11 +401,13 @@ async function resolveArrayContext(
308
401
  // which maps directly to the Nth array element.
309
402
  const occurrenceIndex = getComponentOccurrenceIndex(manifest, component)
310
403
  // Count only components with the same name AND same invocationSourcePath to get array index
404
+ // Sort by invocationIndex for reliable ordering (don't rely on Object.values insertion order)
311
405
  const sameSourceComponents = Object.values(manifest.components)
312
406
  .filter(c =>
313
407
  c.componentName === component.componentName
314
408
  && c.invocationSourcePath === component.invocationSourcePath
315
409
  )
410
+ .sort((a, b) => (a.invocationIndex ?? 0) - (b.invocationIndex ?? 0))
316
411
  const arrayIndex = sameSourceComponents.findIndex(c => c.id === component.id)
317
412
 
318
413
  if (arrayIndex < 0 || arrayIndex >= elementBounds.length) {
@@ -329,7 +424,6 @@ async function resolveArrayContext(
329
424
  frontmatterContent,
330
425
  frontmatterStartLine,
331
426
  arrayVarName: pattern.arrayVarName,
332
- occurrenceIndex,
333
427
  }
334
428
  }
335
429
 
@@ -366,15 +460,25 @@ export async function handleRemoveArrayItem(
366
460
  return { success: false, error: 'Could not detect array pattern for this component' }
367
461
  }
368
462
 
369
- const { fullPath, lines, elementBounds, arrayIndex } = ctx
463
+ const { fullPath, arrayIndex } = ctx
370
464
 
371
465
  const release = await acquireFileLock(fullPath)
372
466
  try {
373
- // Re-read the file to avoid stale data
467
+ // Re-read the file and recompute bounds to avoid stale data
374
468
  const freshContent = await fs.readFile(fullPath, 'utf-8')
375
469
  const freshLines = freshContent.split('\n')
376
470
 
377
- const bounds = elementBounds[arrayIndex]!
471
+ const freshFmEnd = findFrontmatterEnd(freshLines)
472
+ if (freshFmEnd === 0) {
473
+ return { success: false, error: 'Could not find frontmatter in source file' }
474
+ }
475
+ const freshFmContent = freshLines.slice(1, freshFmEnd - 1).join('\n')
476
+ const freshBounds = findArrayDeclaration(freshFmContent, 1, ctx.arrayVarName)
477
+ if (!freshBounds || arrayIndex >= freshBounds.length) {
478
+ return { success: false, error: 'Array declaration not found or index out of bounds after re-read' }
479
+ }
480
+
481
+ const bounds = freshBounds[arrayIndex]!
378
482
  const removeStart = bounds.startLine
379
483
  let removeEnd = bounds.endLine
380
484
 
@@ -387,8 +491,8 @@ export async function handleRemoveArrayItem(
387
491
  // now becomes the last element (remove its trailing comma)
388
492
  }
389
493
 
390
- // Check line after removeEnd for a comma-only or blank line
391
- if (removeEnd + 1 < freshLines.length && freshLines[removeEnd + 1]!.trim() === '') {
494
+ // Check line after removeEnd for a blank separator line (only between elements, not at start)
495
+ if (removeStart > 0 && removeEnd + 1 < freshLines.length && freshLines[removeEnd + 1]!.trim() === '') {
392
496
  removeEnd++
393
497
  }
394
498
 
@@ -454,14 +558,25 @@ export async function handleAddArrayItem(
454
558
  return { success: false, error: 'Could not detect array pattern for this component' }
455
559
  }
456
560
 
457
- const { fullPath, elementBounds, arrayIndex } = ctx
561
+ const { fullPath, arrayIndex } = ctx
458
562
 
459
563
  const release = await acquireFileLock(fullPath)
460
564
  try {
565
+ // Re-read the file and recompute bounds to avoid stale data
461
566
  const freshContent = await fs.readFile(fullPath, 'utf-8')
462
567
  const freshLines = freshContent.split('\n')
463
568
 
464
- const refBounds = elementBounds[arrayIndex]!
569
+ const freshFmEnd = findFrontmatterEnd(freshLines)
570
+ if (freshFmEnd === 0) {
571
+ return { success: false, error: 'Could not find frontmatter in source file' }
572
+ }
573
+ const freshFmContent = freshLines.slice(1, freshFmEnd - 1).join('\n')
574
+ const freshBounds = findArrayDeclaration(freshFmContent, 1, ctx.arrayVarName)
575
+ if (!freshBounds || arrayIndex >= freshBounds.length) {
576
+ return { success: false, error: 'Array declaration not found or index out of bounds after re-read' }
577
+ }
578
+
579
+ const refBounds = freshBounds[arrayIndex]!
465
580
 
466
581
  // Generate JS object literal from props
467
582
  const newElement = generateObjectLiteral(props)
@@ -482,7 +597,7 @@ export async function handleAddArrayItem(
482
597
  if (position === 'before') {
483
598
  // Insert before the reference element
484
599
  const insertLine = refBounds.startLine
485
- freshLines.splice(insertLine, 0, indentedLines + ',')
600
+ freshLines.splice(insertLine, 0, ...(indentedLines + ',').split('\n'))
486
601
  } else {
487
602
  // Insert after the reference element
488
603
  const insertLine = refBounds.endLine + 1
@@ -491,17 +606,19 @@ export async function handleAddArrayItem(
491
606
  if (!refEndLine.trimEnd().endsWith(',')) {
492
607
  freshLines[refBounds.endLine] = refEndLine.replace(/(\s*)$/, ',$1')
493
608
  }
494
- freshLines.splice(insertLine, 0, indentedLines + ',')
609
+ freshLines.splice(insertLine, 0, ...(indentedLines + ',').split('\n'))
495
610
  }
496
611
 
497
612
  // Clean up trailing comma before closing bracket
498
- // Find the closing ] and remove comma from the last element
499
- for (let i = freshLines.length - 1; i >= 0; i--) {
613
+ // Search forward from the last element to find this array's closing ]
614
+ // The insertion always happens within the array, so the ] shifts by addedLineCount
615
+ const lastEl = freshBounds[freshBounds.length - 1]!
616
+ const addedLineCount = indentedLines.split('\n').length
617
+ const closingSearchStart = lastEl.endLine + addedLineCount + 1
618
+ for (let i = closingSearchStart; i < closingSearchStart + 5 && i < freshLines.length; i++) {
500
619
  if (freshLines[i]!.trim().startsWith(']')) {
501
620
  const prev = freshLines[i - 1]
502
621
  if (prev?.trimEnd().endsWith(',')) {
503
- // Check if this is the array we're editing by scanning backwards
504
- // to find the array variable
505
622
  freshLines[i - 1] = prev.replace(/,(\s*)$/, '$1')
506
623
  }
507
624
  break
@@ -528,7 +645,7 @@ export async function handleAddArrayItem(
528
645
  * Generate a JavaScript object literal string from props.
529
646
  * Example: { name: 'Components', slug: 'components' }
530
647
  */
531
- function generateObjectLiteral(props: Record<string, unknown>): string {
648
+ export function generateObjectLiteral(props: Record<string, unknown>): string {
532
649
  const entries = Object.entries(props)
533
650
  if (entries.length === 0) return '{}'
534
651
 
@@ -545,9 +662,10 @@ function generateObjectLiteral(props: Record<string, unknown>): string {
545
662
  }
546
663
 
547
664
  function formatValue(value: unknown): string {
548
- if (typeof value === 'string') return `'${value.replace(/'/g, "\\'")}'`
665
+ if (typeof value === 'string') return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
549
666
  if (typeof value === 'number' || typeof value === 'boolean') return String(value)
550
- if (value === null || value === undefined) return 'undefined'
667
+ if (value === null) return 'null'
668
+ if (value === undefined) return 'undefined'
551
669
  if (Array.isArray(value)) return `[${value.map(formatValue).join(', ')}]`
552
670
  if (typeof value === 'object') return generateObjectLiteral(value as Record<string, unknown>)
553
671
  return String(value)
@@ -1,6 +1,6 @@
1
+ import { NodeType, parse as parseHtml } from 'node-html-parser'
1
2
  import fs from 'node:fs/promises'
2
3
  import path from 'node:path'
3
- import { NodeType, parse as parseHtml } from 'node-html-parser'
4
4
  import { getProjectRoot } from '../config'
5
5
  import type { AttributeChangePayload, ChangePayload, SaveBatchRequest } from '../editor/types'
6
6
  import type { ManifestWriter } from '../manifest-writer'
@@ -339,6 +339,66 @@ export async function processHtml(
339
339
  })
340
340
  }
341
341
 
342
+ // Inline array detection pass: detect elements with data-cms-array-source
343
+ // (injected by vite-plugin-array-transform) and create virtual ComponentInstance entries
344
+ if (markComponents) {
345
+ root.querySelectorAll('[data-cms-array-source]').forEach((node) => {
346
+ const arrayVarName = node.getAttribute('data-cms-array-source')
347
+ if (!arrayVarName) return
348
+
349
+ // Walk ancestors to find invocationSourcePath and source line
350
+ let invocationSourcePath: string | undefined
351
+ let sourceLine = 0
352
+ let ancestor = node.parentNode as HTMLNode | null
353
+ while (ancestor) {
354
+ const ancestorSource = ancestor.getAttribute?.('data-astro-source-file')
355
+ if (ancestorSource) {
356
+ invocationSourcePath = ancestorSource
357
+ // Try to get source line from ancestor
358
+ const locAttr = ancestor.getAttribute?.('data-astro-source-loc')
359
+ || ancestor.getAttribute?.('data-astro-source-line')
360
+ if (locAttr) {
361
+ sourceLine = parseInt(locAttr.split(':')[0] ?? '0', 10)
362
+ }
363
+ break
364
+ }
365
+ ancestor = ancestor.parentNode as HTMLNode | null
366
+ }
367
+
368
+ const componentName = `__array:${arrayVarName}`
369
+
370
+ // Track invocation index using existing componentCountPerParent map
371
+ let invocationIndex: number | undefined
372
+ if (invocationSourcePath) {
373
+ if (!componentCountPerParent.has(invocationSourcePath)) {
374
+ componentCountPerParent.set(invocationSourcePath, new Map())
375
+ }
376
+ const counters = componentCountPerParent.get(invocationSourcePath)!
377
+ const current = counters.get(componentName) ?? 0
378
+ counters.set(componentName, current + 1)
379
+ invocationIndex = current
380
+ }
381
+
382
+ const id = getNextId()
383
+ node.setAttribute('data-cms-component-id', id)
384
+
385
+ components[id] = {
386
+ id,
387
+ componentName,
388
+ file: fileId,
389
+ sourcePath: invocationSourcePath ?? '',
390
+ sourceLine,
391
+ props: {},
392
+ invocationSourcePath,
393
+ invocationIndex,
394
+ isInlineArray: true,
395
+ }
396
+
397
+ // Remove the marker attribute from output HTML
398
+ node.removeAttribute('data-cms-array-source')
399
+ })
400
+ }
401
+
342
402
  // Second pass: mark span elements with text-only styling classes as styled spans
343
403
  // This allows the CMS editor to recognize pre-existing styled text
344
404
  if (markStyledSpans) {
package/src/types.ts CHANGED
@@ -195,6 +195,8 @@ export interface ComponentInstance {
195
195
  invocationSourcePath?: string
196
196
  /** 0-based index among same-name component invocations in the parent file */
197
197
  invocationIndex?: number
198
+ /** Whether this component represents an inline HTML element inside a .map() array */
199
+ isInlineArray?: boolean
198
200
  }
199
201
 
200
202
  /** Represents a content collection entry (markdown file) */
@@ -0,0 +1,119 @@
1
+ import type { Plugin } from 'vite'
2
+ import { buildMapPattern } from './handlers/array-ops'
3
+ import { findFrontmatterEnd } from './handlers/component-ops'
4
+
5
+ /**
6
+ * Vite transform plugin that injects `data-cms-array-source` markers on root HTML
7
+ * elements inside `.map()` callbacks in Astro template sections.
8
+ *
9
+ * This enables the CMS to detect inline array-rendered HTML elements (not just
10
+ * named Astro components) and provide add/remove array item operations.
11
+ *
12
+ * Only targets lowercase tags (inline HTML). Uppercase tags (components) are
13
+ * already supported via `data-astro-source-file` tracking.
14
+ */
15
+ export function createArrayTransformPlugin(): Plugin {
16
+ return {
17
+ name: 'cms-array-transform',
18
+ enforce: 'pre',
19
+ transform(code, id) {
20
+ if (!id.endsWith('.astro')) return null
21
+
22
+ // Find template section (after closing ---)
23
+ const templateStart = findTemplateStart(code)
24
+ if (templateStart < 0) return null
25
+
26
+ const template = code.slice(templateStart)
27
+ const transformed = injectArraySourceMarkers(template)
28
+ if (transformed === template) return null
29
+
30
+ return {
31
+ code: code.slice(0, templateStart) + transformed,
32
+ map: null,
33
+ }
34
+ },
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Find the start of the template section in an Astro file.
40
+ * The template starts after the closing `---` of the frontmatter block.
41
+ */
42
+ export function findTemplateStart(code: string): number {
43
+ const lines = code.split('\n')
44
+ const fmEndLine = findFrontmatterEnd(lines)
45
+ if (fmEndLine === 0) return 0 // No frontmatter, whole file is template
46
+
47
+ // Convert line index back to character offset
48
+ let offset = 0
49
+ for (let i = 0; i < fmEndLine; i++) {
50
+ offset += lines[i]!.length + 1 // +1 for the newline
51
+ }
52
+ return offset
53
+ }
54
+
55
+ /**
56
+ * Scan the template section for `.map(` patterns that render inline HTML elements,
57
+ * and inject `data-cms-array-source="varName"` on the root element.
58
+ */
59
+ export function injectArraySourceMarkers(template: string): string {
60
+ // Match patterns like: {varName.map((item) => ( or {varName.map(item =>
61
+ // We process each match individually
62
+ const mapPattern = new RegExp(buildMapPattern(), 'g')
63
+ let result = template
64
+ let offset = 0
65
+ // Track how many .map() calls we've seen per variable name
66
+ const varMapCounts = new Map<string, number>()
67
+
68
+ for (const match of template.matchAll(mapPattern)) {
69
+ const arrayVarName = match[1]!
70
+ const matchEnd = match.index! + match[0].length
71
+
72
+ // Scan forward from the match to find the arrow `=>` and then the first `<tag`
73
+ const afterMatch = template.slice(matchEnd)
74
+ const arrowIndex = afterMatch.indexOf('=>')
75
+ if (arrowIndex < 0) continue
76
+
77
+ const afterArrow = afterMatch.slice(arrowIndex + 2)
78
+ // Find the first opening tag: `<tagName` where tagName starts with a letter.
79
+ // Supports multiple patterns:
80
+ // => <tag (direct return)
81
+ // => (<tag (parenthesized)
82
+ // => { return <tag (block body)
83
+ // => { return (<tag (block body, parenthesized)
84
+ // => expr && <tag (logical AND conditional)
85
+ // => cond ? <tag (ternary conditional)
86
+ const tagMatch = afterArrow.match(/^[\s(]*<([a-zA-Z][\w.-]*)/)
87
+ ?? afterArrow.match(/^[\s]*\{[\s]*return[\s(]*<([a-zA-Z][\w.-]*)/)
88
+ ?? afterArrow.match(/^[\s(]*[^<]*?&&\s*[\s(]*<([a-zA-Z][\w.-]*)/)
89
+ ?? afterArrow.match(/^[\s(]*[^<]*?\?\s*[\s(]*<([a-zA-Z][\w.-]*)/)
90
+ if (!tagMatch) continue
91
+
92
+ const tagName = tagMatch[1]!
93
+ // Skip uppercase tags (Astro components) — they are already supported
94
+ if (tagName[0] === tagName[0]!.toUpperCase() && tagName[0] !== tagName[0]!.toLowerCase()) continue
95
+
96
+ // Find the exact position of this `<tagName` in the original template
97
+ const tagStartInAfterArrow = afterArrow.indexOf(tagMatch[0])
98
+ const absoluteTagPos = matchEnd + arrowIndex + 2 + tagStartInAfterArrow
99
+ // Position right after `<tagName`
100
+ const insertPos = absoluteTagPos + tagMatch[0].length
101
+
102
+ // Check if the attribute is already injected (search to closing >)
103
+ const closingBracket = template.indexOf('>', insertPos)
104
+ const searchEnd = closingBracket >= 0 ? closingBracket : template.length
105
+ const alreadyHasAttr = template.slice(insertPos, searchEnd).includes('data-cms-array-source')
106
+ if (alreadyHasAttr) continue
107
+
108
+ // Track occurrence index per variable name (for multiple .map() of same array)
109
+ const mapIndex = varMapCounts.get(arrayVarName) ?? 0
110
+ varMapCounts.set(arrayVarName, mapIndex + 1)
111
+ const attrValue = mapIndex === 0 ? arrayVarName : `${arrayVarName}#${mapIndex}`
112
+
113
+ const injection = ` data-cms-array-source="${attrValue}"`
114
+ result = result.slice(0, insertPos + offset) + injection + result.slice(insertPos + offset)
115
+ offset += injection.length
116
+ }
117
+
118
+ return result
119
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Plugin } from 'vite'
2
2
  import type { ManifestWriter } from './manifest-writer'
3
3
  import type { CmsMarkerOptions, ComponentDefinition } from './types'
4
+ import { createArrayTransformPlugin } from './vite-plugin-array-transform'
4
5
 
5
6
  export interface VitePluginContext {
6
7
  manifestWriter: ManifestWriter
@@ -38,5 +39,5 @@ export function createVitePlugin(context: VitePluginContext): Plugin[] {
38
39
  // HTML processing is done in build-processor.ts after pages are generated.
39
40
  // Source location attributes are provided natively by Astro's compiler
40
41
  // (data-astro-source-file, data-astro-source-loc) in dev mode.
41
- return [virtualManifestPlugin]
42
+ return [virtualManifestPlugin, createArrayTransformPlugin()]
42
43
  }