@nuasite/cms 0.29.0 → 0.31.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 (42) hide show
  1. package/dist/editor.js +11397 -11351
  2. package/package.json +1 -1
  3. package/src/collection-scanner.ts +87 -25
  4. package/src/editor/components/attribute-editor.tsx +2 -10
  5. package/src/editor/components/bg-image-overlay.tsx +2 -10
  6. package/src/editor/components/collections-browser.tsx +5 -13
  7. package/src/editor/components/color-toolbar.tsx +2 -9
  8. package/src/editor/components/confirm-dialog.tsx +4 -12
  9. package/src/editor/components/create-page-modal.tsx +1 -9
  10. package/src/editor/components/fields.tsx +134 -116
  11. package/src/editor/components/image-overlay.tsx +3 -14
  12. package/src/editor/components/link-edit-popover.tsx +3 -6
  13. package/src/editor/components/markdown-editor-overlay.tsx +31 -37
  14. package/src/editor/components/markdown-inline-editor.tsx +2 -1
  15. package/src/editor/components/mdx-component-picker.tsx +3 -6
  16. package/src/editor/components/media-library.tsx +15 -37
  17. package/src/editor/components/modal-shell.tsx +34 -5
  18. package/src/editor/components/plain-text-chip-utils.ts +14 -0
  19. package/src/editor/components/plain-text-chip.tsx +61 -0
  20. package/src/editor/components/prop-editor.tsx +67 -68
  21. package/src/editor/components/reference-picker.tsx +6 -24
  22. package/src/editor/components/seo-editor.tsx +4 -10
  23. package/src/editor/components/spinner.tsx +17 -0
  24. package/src/editor/components/text-style-toolbar.tsx +2 -15
  25. package/src/editor/components/toolbar.tsx +2 -1
  26. package/src/editor/constants.ts +33 -0
  27. package/src/editor/dom.ts +37 -0
  28. package/src/editor/editor.ts +90 -5
  29. package/src/editor/hooks/index.ts +4 -0
  30. package/src/editor/hooks/useClickOutsideEscape.ts +43 -0
  31. package/src/editor/hooks/useSearchFilter.ts +21 -0
  32. package/src/editor/index.tsx +9 -0
  33. package/src/handlers/source-writer.ts +75 -21
  34. package/src/html-processor.ts +75 -94
  35. package/src/index.ts +5 -0
  36. package/src/rehype-cms-marker.ts +15 -0
  37. package/src/source-finder/ast-extractors.ts +37 -0
  38. package/src/source-finder/cache.ts +23 -0
  39. package/src/source-finder/search-index.ts +304 -13
  40. package/src/source-finder/snippet-utils.ts +179 -2
  41. package/src/source-finder/types.ts +3 -0
  42. package/src/source-finder/variable-extraction.ts +8 -1
@@ -6,6 +6,7 @@ import {
6
6
  enableAllInteractiveElements,
7
7
  findInnermostCmsElement,
8
8
  getAllCmsElements,
9
+ getCaretRangeFromPoint,
9
10
  getChildCmsElements,
10
11
  getEditableHtmlFromElement,
11
12
  getEditableTextFromElement,
@@ -71,6 +72,50 @@ const INLINE_STYLE_ELEMENTS = [
71
72
  'q',
72
73
  ]
73
74
 
75
+ // Collapse burst spam (repeated shortcut / paste) into a single toast; long enough to
76
+ // merge a burst, short enough that the next deliberate action still explains itself.
77
+ const FORMATTING_BLOCKED_TOAST_COOLDOWN_MS = 3000
78
+ let lastFormattingBlockedToastAt = 0
79
+
80
+ // Signals listener cleanup on stopEditMode. Aborting removes every listener
81
+ // attached with { signal } in the current edit session in one shot.
82
+ let editModeAbortController: AbortController | null = null
83
+
84
+ function notifyFormattingBlocked(): void {
85
+ const now = Date.now()
86
+ if (now - lastFormattingBlockedToastAt < FORMATTING_BLOCKED_TOAST_COOLDOWN_MS) {
87
+ return
88
+ }
89
+ lastFormattingBlockedToastAt = now
90
+ signals.showToast("Formatting isn't available — this text is used as a plain value", 'info')
91
+ }
92
+
93
+ // Uses the Selection/Range API rather than the deprecated document.execCommand('insertText').
94
+ export function insertPlainTextAtRange(range: Range, text: string): boolean {
95
+ if (!text) return false
96
+ range.deleteContents()
97
+ const textNode = document.createTextNode(text)
98
+ range.insertNode(textNode)
99
+ range.setStartAfter(textNode)
100
+ range.setEndAfter(textNode)
101
+ const selection = window.getSelection()
102
+ if (selection) {
103
+ selection.removeAllRanges()
104
+ selection.addRange(range)
105
+ }
106
+ return true
107
+ }
108
+
109
+ function applyPlainTextInsert(el: HTMLElement, text: string, html: string, range: Range | null): void {
110
+ const inserted = range && text ? insertPlainTextAtRange(range, text) : false
111
+ // Dispatch even when only HTML was stripped (no plain text to insert) so downstream
112
+ // state resynchronizes with the intercepted event.
113
+ if (inserted || html) {
114
+ el.dispatchEvent(new Event('input', { bubbles: true }))
115
+ }
116
+ if (html) notifyFormattingBlocked()
117
+ }
118
+
74
119
  /**
75
120
  * Check if an element contains styled/formatted content (inline text styling).
76
121
  * This includes:
@@ -102,6 +147,10 @@ export async function startEditMode(
102
147
  initHighlightSystem()
103
148
  onStateChange?.()
104
149
 
150
+ editModeAbortController?.abort()
151
+ editModeAbortController = new AbortController()
152
+ const editModeSignal = editModeAbortController.signal
153
+
105
154
  try {
106
155
  const manifest = await fetchManifest()
107
156
  signals.setManifest(manifest)
@@ -177,8 +226,8 @@ export async function startEditMode(
177
226
 
178
227
  makeElementEditable(el)
179
228
 
180
- // Suppress browser native contentEditable undo/redo (we handle it ourselves)
181
- // Also prevent Enter/Shift+Enter from inserting line breaks
229
+ const stylingAllowed = manifestEntry?.allowStyling !== false
230
+
182
231
  el.addEventListener('beforeinput', (e) => {
183
232
  if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') {
184
233
  e.preventDefault()
@@ -186,7 +235,39 @@ export async function startEditMode(
186
235
  if (e.inputType === 'insertParagraph' || e.inputType === 'insertLineBreak') {
187
236
  e.preventDefault()
188
237
  }
189
- })
238
+ if (!stylingAllowed && e.inputType?.startsWith('format')) {
239
+ e.preventDefault()
240
+ notifyFormattingBlocked()
241
+ }
242
+ }, { signal: editModeSignal })
243
+
244
+ if (!stylingAllowed) {
245
+ el.addEventListener('paste', (e) => {
246
+ const clipboard = (e as ClipboardEvent).clipboardData
247
+ if (!clipboard) return
248
+ const html = clipboard.getData('text/html')
249
+ const text = clipboard.getData('text/plain')
250
+ e.preventDefault()
251
+ const selection = window.getSelection()
252
+ const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null
253
+ applyPlainTextInsert(el, text, html, range)
254
+ }, { signal: editModeSignal })
255
+
256
+ el.addEventListener('drop', (e) => {
257
+ const transfer = (e as DragEvent).dataTransfer
258
+ if (!transfer) return
259
+ const html = transfer.getData('text/html')
260
+ const text = transfer.getData('text/plain')
261
+ if (!text && !html) return
262
+ e.preventDefault()
263
+ let range = getCaretRangeFromPoint((e as DragEvent).clientX, (e as DragEvent).clientY)
264
+ if (!range) {
265
+ const selection = window.getSelection()
266
+ range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null
267
+ }
268
+ applyPlainTextInsert(el, text, html, range)
269
+ }, { signal: editModeSignal })
270
+ }
190
271
 
191
272
  // Setup color tracking for elements with colorClasses in manifest
192
273
  setupColorTracking(config, el, cmsId, savedColorEdits[cmsId])
@@ -338,6 +419,8 @@ export function stopEditMode(onStateChange?: () => void): void {
338
419
  if (!signals.isSelectMode.value) {
339
420
  enableAllInteractiveElements()
340
421
  }
422
+ editModeAbortController?.abort()
423
+ editModeAbortController = null
341
424
  cleanupHighlightSystem()
342
425
  onStateChange?.()
343
426
 
@@ -639,8 +722,10 @@ export async function saveAllChanges(
639
722
  payload.childCmsIds = change.childCmsElements.map(c => c.id)
640
723
  }
641
724
 
642
- // Include HTML content when there are styled spans
643
- if (change.hasStyledContent) {
725
+ // Include HTML content when there are styled spans — but never for entries
726
+ // that disallow styling (string props, collection fields), since inline HTML
727
+ // would be written back into a string attribute and break the source.
728
+ if (change.hasStyledContent && entry?.allowStyling !== false) {
644
729
  payload.hasStyledContent = true
645
730
  payload.htmlValue = getEditableHtmlFromElement(change.element)
646
731
  }
@@ -17,3 +17,7 @@ export type { ImageHoverState } from './useImageHoverDetection'
17
17
 
18
18
  export { useBgImageHoverDetection } from './useBgImageHoverDetection'
19
19
  export type { BgImageHoverState } from './useBgImageHoverDetection'
20
+
21
+ export { useClickOutsideEscape } from './useClickOutsideEscape'
22
+
23
+ export { useSearchFilter } from './useSearchFilter'
@@ -0,0 +1,43 @@
1
+ import { useEffect, useRef } from 'preact/hooks'
2
+
3
+ /**
4
+ * Dismiss handler for floating UI: closes on outside mousedown or Escape key.
5
+ * Pass all refs that should be considered "inside" (e.g. panel + trigger).
6
+ *
7
+ * Uses composedPath() so it works correctly inside Shadow DOM, and registers
8
+ * in the capture phase so stopPropagation() in bubble-phase handlers
9
+ * (e.g. modal overlays) doesn't block detection.
10
+ */
11
+ export function useClickOutsideEscape(
12
+ refs: ReadonlyArray<{ readonly current: HTMLElement | null }>,
13
+ isOpen: boolean,
14
+ onClose: () => void,
15
+ ): void {
16
+ // Store refs and onClose in a ref so the effect never needs to re-register
17
+ // when the caller creates a new array (e.g. from spreading exemptRefs).
18
+ // The actual .current values of each ref are read at event time, not capture time.
19
+ const stableRefs = useRef(refs)
20
+ stableRefs.current = refs
21
+ const stableOnClose = useRef(onClose)
22
+ stableOnClose.current = onClose
23
+
24
+ useEffect(() => {
25
+ if (!isOpen) return
26
+ const onMouseDown = (e: MouseEvent) => {
27
+ const path = e.composedPath()
28
+ for (const ref of stableRefs.current) {
29
+ if (ref.current && path.includes(ref.current)) return
30
+ }
31
+ stableOnClose.current()
32
+ }
33
+ const onKeyDown = (e: KeyboardEvent) => {
34
+ if (e.key === 'Escape') stableOnClose.current()
35
+ }
36
+ document.addEventListener('mousedown', onMouseDown, true)
37
+ document.addEventListener('keydown', onKeyDown)
38
+ return () => {
39
+ document.removeEventListener('mousedown', onMouseDown, true)
40
+ document.removeEventListener('keydown', onKeyDown)
41
+ }
42
+ }, [isOpen])
43
+ }
@@ -0,0 +1,21 @@
1
+ import { useMemo, useRef } from 'preact/hooks'
2
+
3
+ /**
4
+ * Filter a list of items by a search query.
5
+ * The `getSearchableText` callback returns the text to match against (e.g. `o => \`${o.label} ${o.value}\``).
6
+ * Uses a ref internally so callers don't need to memoize the callback.
7
+ */
8
+ export function useSearchFilter<T>(
9
+ items: T[],
10
+ query: string,
11
+ getSearchableText: (item: T) => string,
12
+ ): T[] {
13
+ const fnRef = useRef(getSearchableText)
14
+ fnRef.current = getSearchableText
15
+
16
+ return useMemo(() => {
17
+ if (!query) return items
18
+ const q = query.toLowerCase()
19
+ return items.filter(item => fnRef.current(item).toLowerCase().includes(q))
20
+ }, [items, query])
21
+ }
@@ -16,6 +16,7 @@ import { ImageOverlay } from './components/image-overlay'
16
16
  import { MarkdownEditorOverlay } from './components/markdown-editor-overlay'
17
17
  import { MediaLibrary } from './components/media-library'
18
18
  import { Outline } from './components/outline'
19
+ import { PlainTextChip } from './components/plain-text-chip'
19
20
  import { RedirectCountdown } from './components/redirect-countdown'
20
21
  import { RedirectsManager } from './components/redirects-manager'
21
22
  import { ReferencePicker } from './components/reference-picker'
@@ -562,6 +563,14 @@ const CmsUI = () => {
562
563
  />
563
564
  </ErrorBoundary>
564
565
 
566
+ <ErrorBoundary componentName="Plain Text Chip">
567
+ <PlainTextChip
568
+ visible={textSelectionState.hasSelection && isEditing && !isTextStylingAllowed}
569
+ rect={textSelectionState.rect}
570
+ entry={selectedEntry}
571
+ />
572
+ </ErrorBoundary>
573
+
565
574
  <ErrorBoundary componentName="Color Toolbar">
566
575
  <ColorToolbar
567
576
  visible={colorEditorState.isOpen && isEditing}
@@ -7,7 +7,7 @@ import type { AttributeChangePayload, ChangePayload, SaveBatchRequest } from '..
7
7
  import type { ManifestWriter } from '../manifest-writer'
8
8
  import { extractAstroImageOriginalUrl } from '../source-finder/snippet-utils'
9
9
  import type { CmsManifest, ManifestEntry } from '../types'
10
- import { acquireFileLock, escapeReplacement, normalizePagePath, resolveAndValidatePath } from '../utils'
10
+ import { acquireFileLock, escapeRegex, escapeReplacement, normalizePagePath, resolveAndValidatePath } from '../utils'
11
11
 
12
12
  export interface SaveBatchResponse {
13
13
  updated: number
@@ -216,8 +216,8 @@ export function applyImageChange(
216
216
  let replacedIndex = -1
217
217
  for (const srcToFind of srcCandidates) {
218
218
  // Use non-global patterns to replace only the first occurrence
219
- const srcPatternDouble = new RegExp(`src="${escapeRegExp(srcToFind)}"`)
220
- const srcPatternSingle = new RegExp(`src='${escapeRegExp(srcToFind)}'`)
219
+ const srcPatternDouble = new RegExp(`src="${escapeRegex(srcToFind)}"`)
220
+ const srcPatternSingle = new RegExp(`src='${escapeRegex(srcToFind)}'`)
221
221
 
222
222
  const escapedNewSrc = escapeReplacement(newSrc)
223
223
  const doubleMatch = newContent.match(srcPatternDouble)
@@ -487,7 +487,39 @@ function appendClassToAttribute(
487
487
  return { success: false, error: 'No class attribute found in source file' }
488
488
  }
489
489
 
490
- function applyAttributeChanges(
490
+ /**
491
+ * Locate `sourceSnippet` in `content` and replace the first quoted occurrence
492
+ * of `oldValue` inside that snippet with `newValue`, then splice back. Returns
493
+ * the updated file content, or undefined if the snippet isn't in the file or
494
+ * contains no quoted match.
495
+ *
496
+ * Used as the save-path for attribute values backed by a JS literal (variable
497
+ * definition, conditional branch) where there's no `attrName=` prefix on the
498
+ * source line. Scoping the match to the recorded snippet prevents accidental
499
+ * hits elsewhere in the file.
500
+ */
501
+ const QUOTED_LITERAL_DELIMITERS = [`'`, `"`, '`'] as const
502
+
503
+ function replaceLiteralInSnippet(
504
+ content: string,
505
+ snippet: string,
506
+ oldValue: string,
507
+ newValue: string,
508
+ ): string | undefined {
509
+ if (!content.includes(snippet)) return undefined
510
+
511
+ const safeNewValue = escapeReplacement(newValue)
512
+ const escapedOld = escapeRegex(oldValue)
513
+ for (const quote of QUOTED_LITERAL_DELIMITERS) {
514
+ const pattern = new RegExp(`${quote}(${escapedOld})${quote}`)
515
+ if (!pattern.test(snippet)) continue
516
+ const updated = snippet.replace(pattern, `${quote}${safeNewValue}${quote}`)
517
+ if (updated !== snippet) return content.replace(snippet, updated)
518
+ }
519
+ return undefined
520
+ }
521
+
522
+ export function applyAttributeChanges(
491
523
  content: string,
492
524
  change: ChangePayload,
493
525
  ): {
@@ -517,10 +549,10 @@ function applyAttributeChanges(
517
549
  if (lineIndex >= 0 && lineIndex < lines.length) {
518
550
  const line = lines[lineIndex]!
519
551
  const doubleQuotePattern = new RegExp(
520
- `(${escapeRegExp(attributeName)}\\s*=\\s*)"(${escapeRegExp(attrOldValue)})"`,
552
+ `(${escapeRegex(attributeName)}\\s*=\\s*)"(${escapeRegex(attrOldValue)})"`,
521
553
  )
522
554
  const singleQuotePattern = new RegExp(
523
- `(${escapeRegExp(attributeName)}\\s*=\\s*)'(${escapeRegExp(attrOldValue)})'`,
555
+ `(${escapeRegex(attributeName)}\\s*=\\s*)'(${escapeRegex(attrOldValue)})'`,
524
556
  )
525
557
 
526
558
  const safeNewValue = escapeReplacement(attrNewValue)
@@ -533,10 +565,33 @@ function applyAttributeChanges(
533
565
  newContent = lines.join('\n')
534
566
  attrApplied++
535
567
  } else {
536
- failedChanges.push({
537
- cmsId: change.cmsId,
538
- error: `Attribute '${attributeName}="${attrOldValue}"' not found on line ${targetLine}`,
539
- })
568
+ // JS-backed value (variable def or conditional branch) — no
569
+ // `attrName=` on the line, so scope the replacement to the
570
+ // recorded snippet.
571
+ const snippet = attrChange.sourceSnippet
572
+ if (!snippet) {
573
+ failedChanges.push({
574
+ cmsId: change.cmsId,
575
+ error: `Attribute '${attributeName}="${attrOldValue}"' not found on line ${targetLine}`,
576
+ })
577
+ } else {
578
+ const snippetResult = replaceLiteralInSnippet(
579
+ newContent,
580
+ snippet,
581
+ attrOldValue,
582
+ attrNewValue,
583
+ )
584
+ if (snippetResult) {
585
+ newContent = snippetResult
586
+ attrApplied++
587
+ } else {
588
+ failedChanges.push({
589
+ cmsId: change.cmsId,
590
+ error: `Attribute '${attributeName}="${attrOldValue}"' not found on line ${targetLine} `
591
+ + `and source snippet did not yield a quoted literal match`,
592
+ })
593
+ }
594
+ }
540
595
  }
541
596
  } else {
542
597
  failedChanges.push({
@@ -547,10 +602,10 @@ function applyAttributeChanges(
547
602
  } else {
548
603
  // Fallback: replace first occurrence in the whole file
549
604
  const doubleQuotePattern = new RegExp(
550
- `(${escapeRegExp(attributeName)}\\s*=\\s*)"(${escapeRegExp(attrOldValue)})"`,
605
+ `(${escapeRegex(attributeName)}\\s*=\\s*)"(${escapeRegex(attrOldValue)})"`,
551
606
  )
552
607
  const singleQuotePattern = new RegExp(
553
- `(${escapeRegExp(attributeName)}\\s*=\\s*)'(${escapeRegExp(attrOldValue)})'`,
608
+ `(${escapeRegex(attributeName)}\\s*=\\s*)'(${escapeRegex(attrOldValue)})'`,
554
609
  )
555
610
 
556
611
  const safeNewValue = escapeReplacement(attrNewValue)
@@ -590,7 +645,10 @@ export function applyTextChange(
590
645
  return { success: false, error: 'Source snippet not found in file' }
591
646
  }
592
647
 
593
- const newText = htmlValue ?? newValue
648
+ // Never write HTML back into entries that don't allow styling — these are string props,
649
+ // collection fields, etc. where inline HTML would produce invalid source code.
650
+ const stylingAllowed = manifest.entries[change.cmsId]?.allowStyling !== false
651
+ const newText = stylingAllowed ? (htmlValue ?? newValue) : newValue
594
652
 
595
653
  // When originalValue contains CMS placeholders (child elements like {{cms:cms-5}}),
596
654
  // replace only the text segments between placeholders directly in the sourceSnippet.
@@ -724,10 +782,10 @@ function findTextInSnippet(snippet: string, decodedText: string): string | null
724
782
  ["'", '&apos;'],
725
783
  ]
726
784
 
727
- let pattern = escapeRegExp(decodedText)
785
+ let pattern = escapeRegex(decodedText)
728
786
  for (const [char, entity] of entityMap) {
729
- const escapedChar = escapeRegExp(char)
730
- const escapedEntity = escapeRegExp(entity)
787
+ const escapedChar = escapeRegex(char)
788
+ const escapedEntity = escapeRegex(entity)
731
789
  pattern = pattern.replace(new RegExp(escapedChar, 'g'), `(?:${escapedChar}|${escapedEntity})`)
732
790
  }
733
791
 
@@ -736,7 +794,7 @@ function findTextInSnippet(snippet: string, decodedText: string): string | null
736
794
  if (match) return match[0]
737
795
 
738
796
  // Try matching with <br> tags stripped from snippet
739
- const chars = [...decodedText].map((ch) => escapeRegExp(ch))
797
+ const chars = [...decodedText].map((ch) => escapeRegex(ch))
740
798
  const brAwarePattern = chars.join('(?:<br\\b[^>]*\\/?>)*')
741
799
  const brRegex = new RegExp(brAwarePattern)
742
800
  const brMatch = snippet.match(brRegex)
@@ -1008,7 +1066,3 @@ function tryBrNormalizedChange(
1008
1066
 
1009
1067
  return result !== sourceSnippet ? result : null
1010
1068
  }
1011
-
1012
- function escapeRegExp(string: string): string {
1013
- return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
1014
- }
@@ -17,6 +17,35 @@ import { generateStableId } from './utils'
17
17
  /** Type for parsed HTML element nodes from node-html-parser */
18
18
  type HTMLNode = ParsedHTMLElement
19
19
 
20
+ /** Check whether any ancestor of `node` (inclusive) has `data-astro-source-file`. */
21
+ function hasAncestorSourceFile(node: HTMLNode): boolean {
22
+ let current: HTMLNode | null = node
23
+ while (current) {
24
+ if (current.getAttribute?.('data-astro-source-file')) return true
25
+ current = current.parentNode as HTMLNode | null
26
+ }
27
+ return false
28
+ }
29
+
30
+ /** Walk ancestors of `node` (inclusive) to find the nearest source file and line. */
31
+ function findAncestorSourceLocation(node: HTMLNode): { sourceFile?: string; sourceLine?: number } {
32
+ let current: HTMLNode | null = node
33
+ while (current) {
34
+ const file = current.getAttribute?.('data-astro-source-file')
35
+ if (file) {
36
+ const line = current.getAttribute?.('data-astro-source-loc') || current.getAttribute?.('data-astro-source-line')
37
+ let sourceLine: number | undefined
38
+ if (line) {
39
+ const parsed = parseInt(line.split(':')[0] ?? '1', 10)
40
+ if (!Number.isNaN(parsed)) sourceLine = parsed
41
+ }
42
+ return { sourceFile: file, sourceLine }
43
+ }
44
+ current = current.parentNode as HTMLNode | null
45
+ }
46
+ return {}
47
+ }
48
+
20
49
  /**
21
50
  * Inline text styling elements that should NOT be marked with CMS IDs.
22
51
  * These elements are text formatting and should be part of their parent's content.
@@ -434,52 +463,61 @@ export async function processHtml(
434
463
  // This needs to run BEFORE image marking so we can skip images inside markdown
435
464
  let markdownWrapperNode: HTMLNode | null = null
436
465
 
437
- // Two strategies:
438
- // 1. Dev mode: look for elements with data-astro-source-file containing children without it
439
- // 2. Build mode: find element whose first child content matches the start of markdown body
466
+ // Three strategies in priority order:
467
+ // 0. Rehype marker: the rehype-cms-marker plugin marks the first rendered element
468
+ // with data-cms-markdown-content its parent is the wrapper
469
+ // 1. Dev mode heuristic: elements with data-astro-source-file whose children lack it
470
+ // 2. Build mode: find element whose content matches the markdown body text
440
471
  if (collectionInfo) {
441
472
  const allElements = root.querySelectorAll('*')
442
473
  let foundWrapper = false
443
474
 
475
+ // Strategy 0: Rehype marker — most reliable
476
+ const markerEl = root.querySelector('[data-cms-markdown-content]')
477
+ if (markerEl) {
478
+ markerEl.removeAttribute('data-cms-markdown-content')
479
+ const parent = markerEl.parentNode as HTMLNode | null
480
+ if (parent && parent.tagName) {
481
+ const id = getNextId()
482
+ parent.setAttribute(attributeName, id)
483
+ parent.setAttribute('data-cms-markdown', 'true')
484
+ collectionWrapperId = id
485
+ markdownWrapperNode = parent
486
+ foundWrapper = true
487
+ }
488
+ }
489
+
444
490
  // Strategy 1: Dev mode - look for source file attributes
445
- const SKIP_WRAPPER_TAGS = new Set(['html', 'head', 'body', 'script', 'style', 'meta', 'link'])
446
- for (const node of allElements) {
447
- const tag = node.tagName?.toLowerCase?.() ?? ''
448
- if (SKIP_WRAPPER_TAGS.has(tag)) continue
449
- const sourceFile = node.getAttribute('data-astro-source-file')
450
- if (!sourceFile) continue
451
-
452
- // Check if this element has any direct child elements without source file attribute
453
- // These would be markdown-rendered elements
454
- const childElements = node.childNodes.filter(
455
- (child): child is HTMLNode => child.nodeType === 1 && 'tagName' in child,
456
- )
457
- const hasMarkdownChildren = childElements.some(
458
- (child) => !child.getAttribute?.('data-astro-source-file'),
459
- )
460
-
461
- if (hasMarkdownChildren) {
462
- // Check if any ancestor already has been marked as a collection wrapper
463
- // We want the innermost wrapper
464
- let parent = node.parentNode as HTMLNode | null
465
- let hasAncestorWrapper = false
466
- while (parent) {
467
- if (parent.getAttribute?.(attributeName)?.startsWith('cms-collection-')) {
468
- hasAncestorWrapper = true
469
- break
491
+ if (!foundWrapper) {
492
+ const SKIP_WRAPPER_TAGS = new Set(['html', 'head', 'body', 'script', 'style', 'meta', 'link'])
493
+ for (const node of allElements) {
494
+ const tag = node.tagName?.toLowerCase?.() ?? ''
495
+ if (SKIP_WRAPPER_TAGS.has(tag)) continue
496
+ const sourceFile = node.getAttribute('data-astro-source-file')
497
+ if (!sourceFile) continue
498
+
499
+ // Check if this element has any direct child elements without source file attribute
500
+ // These would be markdown-rendered elements
501
+ const childElements = node.childNodes.filter(
502
+ (child): child is HTMLNode => child.nodeType === 1 && 'tagName' in child,
503
+ )
504
+ const hasMarkdownChildren = childElements.some(
505
+ (child) => !child.getAttribute?.('data-astro-source-file'),
506
+ )
507
+
508
+ if (hasMarkdownChildren) {
509
+ // Remove data-cms-markdown from previous (shallower) wrapper
510
+ // we want only the deepest wrapper to have it
511
+ if (markdownWrapperNode) {
512
+ markdownWrapperNode.removeAttribute('data-cms-markdown')
470
513
  }
471
- parent = parent.parentNode as HTMLNode | null
472
- }
473
514
 
474
- if (!hasAncestorWrapper) {
475
- // Mark this as the collection wrapper using the standard attribute
476
515
  const id = getNextId()
477
516
  node.setAttribute(attributeName, id)
478
517
  node.setAttribute('data-cms-markdown', 'true')
479
518
  collectionWrapperId = id
480
519
  markdownWrapperNode = node
481
520
  foundWrapper = true
482
- // Don't break - we want the deepest wrapper, so we'll overwrite
483
521
  }
484
522
  }
485
523
  }
@@ -636,42 +674,13 @@ export async function processHtml(
636
674
 
637
675
  // When skipMarkdownContent is true (collection pages), only mark images
638
676
  // that have source file attributes (from Astro templates, not markdown)
639
- if (skipMarkdownContent) {
640
- // Check if the image or any ancestor has source file attribute
641
- let hasSourceAttr = false
642
- let current: HTMLNode | null = node
643
- while (current) {
644
- if (current.getAttribute?.('data-astro-source-file')) {
645
- hasSourceAttr = true
646
- break
647
- }
648
- current = current.parentNode as HTMLNode | null
649
- }
650
- if (!hasSourceAttr) return
651
- }
677
+ if (skipMarkdownContent && !hasAncestorSourceFile(node)) return
652
678
 
653
679
  const id = getNextId()
654
680
  node.setAttribute(attributeName, id)
655
681
  node.setAttribute('data-cms-img', 'true')
656
682
 
657
- // Try to get source location from the image itself or ancestors
658
- let sourceFile: string | undefined
659
- let sourceLine: number | undefined
660
- let current: HTMLNode | null = node
661
- while (current && !sourceFile) {
662
- const file = current.getAttribute?.('data-astro-source-file')
663
- const line = current.getAttribute?.('data-astro-source-loc') || current.getAttribute?.('data-astro-source-line')
664
- if (file) {
665
- sourceFile = file
666
- if (line) {
667
- const lineNum = parseInt(line.split(':')[0] ?? '1', 10)
668
- if (!Number.isNaN(lineNum)) {
669
- sourceLine = lineNum
670
- }
671
- }
672
- }
673
- current = current.parentNode as HTMLNode | null
674
- }
683
+ const { sourceFile, sourceLine } = findAncestorSourceLocation(node)
675
684
 
676
685
  // Build image metadata
677
686
  const metadata: ImageMetadata = {
@@ -708,41 +717,13 @@ export async function processHtml(
708
717
  if (!bgMeta) return
709
718
 
710
719
  // When skipMarkdownContent is true, only mark elements with source file attributes
711
- if (skipMarkdownContent) {
712
- let hasSourceAttr = false
713
- let current: HTMLNode | null = node
714
- while (current) {
715
- if (current.getAttribute?.('data-astro-source-file')) {
716
- hasSourceAttr = true
717
- break
718
- }
719
- current = current.parentNode as HTMLNode | null
720
- }
721
- if (!hasSourceAttr) return
722
- }
720
+ if (skipMarkdownContent && !hasAncestorSourceFile(node)) return
723
721
 
724
722
  const id = getNextId()
725
723
  node.setAttribute(attributeName, id)
726
724
  node.setAttribute('data-cms-bg-img', 'true')
727
725
 
728
- // Try to get source location from the element itself or ancestors
729
- let sourceFile: string | undefined
730
- let sourceLine: number | undefined
731
- let current: HTMLNode | null = node
732
- while (current && !sourceFile) {
733
- const file = current.getAttribute?.('data-astro-source-file')
734
- const line = current.getAttribute?.('data-astro-source-loc') || current.getAttribute?.('data-astro-source-line')
735
- if (file) {
736
- sourceFile = file
737
- if (line) {
738
- const lineNum = parseInt(line.split(':')[0] ?? '1', 10)
739
- if (!Number.isNaN(lineNum)) {
740
- sourceLine = lineNum
741
- }
742
- }
743
- }
744
- current = current.parentNode as HTMLNode | null
745
- }
726
+ const { sourceFile, sourceLine } = findAncestorSourceLocation(node)
746
727
 
747
728
  bgImageEntries.set(id, {
748
729
  metadata: bgMeta,
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ import { getErrorCollector, resetErrorCollector } from './error-collector'
13
13
  import { ManifestWriter } from './manifest-writer'
14
14
  import { createLocalStorageAdapter } from './media/local'
15
15
  import type { MediaStorageAdapter } from './media/types'
16
+ import { rehypeCmsMarker } from './rehype-cms-marker'
16
17
  import type { CmsFeatures, CmsMarkerOptions, ComponentDefinition } from './types'
17
18
  import { createVitePlugin } from './vite-plugin'
18
19
 
@@ -283,6 +284,9 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
283
284
  const needsAliases = !src && !hasPrebuiltBundle
284
285
 
285
286
  updateConfig({
287
+ markdown: {
288
+ rehypePlugins: [rehypeCmsMarker],
289
+ },
286
290
  vite: {
287
291
  plugins: vitePlugins,
288
292
  resolve: needsAliases
@@ -367,6 +371,7 @@ export type { Color, Date, DateTime, Email, Image, Reference, Textarea, Time, Ur
367
371
 
368
372
  export { scanCollections } from './collection-scanner'
369
373
  export { getProjectRoot, resetProjectRoot, setProjectRoot } from './config'
374
+ export { rehypeCmsMarker } from './rehype-cms-marker'
370
375
  export type { CollectionInfo, MarkdownContent, SourceLocation, VariableReference } from './source-finder'
371
376
  export { findCollectionSource, parseMarkdownContent } from './source-finder'
372
377
  export type {
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Rehype plugin that marks the first element of rendered markdown/MDX content
3
+ * with `data-cms-markdown-content`. The HTML processor uses this marker to
4
+ * reliably identify the wrapper element (the marker's parent) instead of
5
+ * relying on heuristics.
6
+ */
7
+ export function rehypeCmsMarker() {
8
+ return (tree: any) => {
9
+ const firstElement = tree.children?.find((n: any) => n.type === 'element')
10
+ if (firstElement) {
11
+ firstElement.properties ??= {}
12
+ firstElement.properties['dataCmsMarkdownContent'] = ''
13
+ }
14
+ }
15
+ }