@nuasite/cms 0.12.4 → 0.13.1

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.
@@ -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,8 @@
1
1
  import type { Plugin } from 'vite'
2
+ import { expectedDeletions } from './dev-middleware'
2
3
  import type { ManifestWriter } from './manifest-writer'
3
4
  import type { CmsMarkerOptions, ComponentDefinition } from './types'
5
+ import { createArrayTransformPlugin } from './vite-plugin-array-transform'
4
6
 
5
7
  export interface VitePluginContext {
6
8
  manifestWriter: ManifestWriter
@@ -11,7 +13,7 @@ export interface VitePluginContext {
11
13
  }
12
14
 
13
15
  export function createVitePlugin(context: VitePluginContext): Plugin[] {
14
- const { manifestWriter, componentDefinitions } = context
16
+ const { manifestWriter, componentDefinitions, command } = context
15
17
 
16
18
  const virtualManifestPlugin: Plugin = {
17
19
  name: 'cms-marker-virtual-manifest',
@@ -33,10 +35,33 @@ export function createVitePlugin(context: VitePluginContext): Plugin[] {
33
35
  },
34
36
  }
35
37
 
38
+ // Intercept Vite's file watcher to suppress full page reloads when the CMS
39
+ // deletes a content collection entry. Without this, Vite/Astro detects the
40
+ // unlink and forces a reload, undoing the optimistic UI update.
41
+ const watcherPlugin: Plugin = {
42
+ name: 'cms-suppress-delete-reload',
43
+ configureServer(server) {
44
+ if (command !== 'dev') return
45
+
46
+ // Monkey-patch the watcher to intercept unlink events before Vite/Astro
47
+ // processes them. We use prependListener so our handler runs first.
48
+ const watcher = server.watcher
49
+ const origEmit = watcher.emit.bind(watcher)
50
+ watcher.emit = function (event: string, filePath: string, ...args: any[]) {
51
+ if ((event === 'unlink' || event === 'unlinkDir') && expectedDeletions.has(filePath)) {
52
+ expectedDeletions.delete(filePath)
53
+ // Swallow the event — don't let Vite/Astro see it
54
+ return true
55
+ }
56
+ return origEmit(event, filePath, ...args)
57
+ } as typeof watcher.emit
58
+ },
59
+ }
60
+
36
61
  // Note: We cannot use transformIndexHtml for static Astro builds because
37
62
  // Astro generates HTML files directly without going through Vite's HTML pipeline.
38
63
  // HTML processing is done in build-processor.ts after pages are generated.
39
64
  // Source location attributes are provided natively by Astro's compiler
40
65
  // (data-astro-source-file, data-astro-source-loc) in dev mode.
41
- return [virtualManifestPlugin]
66
+ return [virtualManifestPlugin, watcherPlugin, createArrayTransformPlugin()]
42
67
  }