@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 +1 -1
- package/package.json +1 -1
- package/src/dev-middleware.ts +33 -7
- package/src/handlers/array-ops.ts +139 -21
- package/src/handlers/source-writer.ts +1 -1
- package/src/html-processor.ts +60 -0
- package/src/types.ts +2 -0
- package/src/vite-plugin-array-transform.ts +119 -0
- package/src/vite-plugin.ts +2 -1
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.
|
|
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
package/src/dev-middleware.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
//
|
|
499
|
-
|
|
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
|
|
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'
|
package/src/html-processor.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/vite-plugin.ts
CHANGED
|
@@ -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
|
}
|