@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.
- package/dist/editor.js +11397 -11351
- package/package.json +1 -1
- package/src/collection-scanner.ts +87 -25
- package/src/editor/components/attribute-editor.tsx +2 -10
- package/src/editor/components/bg-image-overlay.tsx +2 -10
- package/src/editor/components/collections-browser.tsx +5 -13
- package/src/editor/components/color-toolbar.tsx +2 -9
- package/src/editor/components/confirm-dialog.tsx +4 -12
- package/src/editor/components/create-page-modal.tsx +1 -9
- package/src/editor/components/fields.tsx +134 -116
- package/src/editor/components/image-overlay.tsx +3 -14
- package/src/editor/components/link-edit-popover.tsx +3 -6
- package/src/editor/components/markdown-editor-overlay.tsx +31 -37
- package/src/editor/components/markdown-inline-editor.tsx +2 -1
- package/src/editor/components/mdx-component-picker.tsx +3 -6
- package/src/editor/components/media-library.tsx +15 -37
- package/src/editor/components/modal-shell.tsx +34 -5
- package/src/editor/components/plain-text-chip-utils.ts +14 -0
- package/src/editor/components/plain-text-chip.tsx +61 -0
- package/src/editor/components/prop-editor.tsx +67 -68
- package/src/editor/components/reference-picker.tsx +6 -24
- package/src/editor/components/seo-editor.tsx +4 -10
- package/src/editor/components/spinner.tsx +17 -0
- package/src/editor/components/text-style-toolbar.tsx +2 -15
- package/src/editor/components/toolbar.tsx +2 -1
- package/src/editor/constants.ts +33 -0
- package/src/editor/dom.ts +37 -0
- package/src/editor/editor.ts +90 -5
- package/src/editor/hooks/index.ts +4 -0
- package/src/editor/hooks/useClickOutsideEscape.ts +43 -0
- package/src/editor/hooks/useSearchFilter.ts +21 -0
- package/src/editor/index.tsx +9 -0
- package/src/handlers/source-writer.ts +75 -21
- package/src/html-processor.ts +75 -94
- package/src/index.ts +5 -0
- package/src/rehype-cms-marker.ts +15 -0
- package/src/source-finder/ast-extractors.ts +37 -0
- package/src/source-finder/cache.ts +23 -0
- package/src/source-finder/search-index.ts +304 -13
- package/src/source-finder/snippet-utils.ts +179 -2
- package/src/source-finder/types.ts +3 -0
- package/src/source-finder/variable-extraction.ts +8 -1
package/src/editor/editor.ts
CHANGED
|
@@ -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
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/editor/index.tsx
CHANGED
|
@@ -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="${
|
|
220
|
-
const srcPatternSingle = new RegExp(`src='${
|
|
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
|
-
|
|
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
|
-
`(${
|
|
552
|
+
`(${escapeRegex(attributeName)}\\s*=\\s*)"(${escapeRegex(attrOldValue)})"`,
|
|
521
553
|
)
|
|
522
554
|
const singleQuotePattern = new RegExp(
|
|
523
|
-
`(${
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
`(${
|
|
605
|
+
`(${escapeRegex(attributeName)}\\s*=\\s*)"(${escapeRegex(attrOldValue)})"`,
|
|
551
606
|
)
|
|
552
607
|
const singleQuotePattern = new RegExp(
|
|
553
|
-
`(${
|
|
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
|
-
|
|
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
|
["'", '''],
|
|
725
783
|
]
|
|
726
784
|
|
|
727
|
-
let pattern =
|
|
785
|
+
let pattern = escapeRegex(decodedText)
|
|
728
786
|
for (const [char, entity] of entityMap) {
|
|
729
|
-
const escapedChar =
|
|
730
|
-
const escapedEntity =
|
|
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) =>
|
|
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
|
-
}
|
package/src/html-processor.ts
CHANGED
|
@@ -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
|
-
//
|
|
438
|
-
//
|
|
439
|
-
//
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|