@nuasite/cms 0.5.1 → 0.7.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/README.md +11 -11
- package/dist/editor.js +7497 -7188
- package/package.json +1 -1
- package/src/build-processor.ts +44 -1
- package/src/dev-middleware.ts +47 -2
- package/src/editor/components/collections-browser.tsx +10 -6
- package/src/editor/components/fields.tsx +7 -7
- package/src/editor/components/frontmatter-fields.tsx +163 -2
- package/src/editor/components/outline.tsx +165 -6
- package/src/editor/components/seo-editor.tsx +2 -1
- package/src/editor/components/text-style-toolbar.tsx +1 -1
- package/src/editor/components/toast/toast.tsx +19 -2
- package/src/editor/editor.ts +24 -25
- package/src/editor/index.tsx +100 -5
- package/src/editor/types.ts +4 -2
- package/src/handlers/array-ops.ts +102 -0
- package/src/handlers/source-writer.ts +6 -2
- package/src/html-processor.ts +8 -4
- package/src/index.ts +1 -1
- package/src/source-finder/cross-file-tracker.ts +42 -0
- package/src/source-finder/element-finder.ts +18 -4
- package/src/source-finder/snippet-utils.ts +3 -0
- package/src/source-finder/types.ts +3 -0
- package/src/source-finder/variable-extraction.ts +2 -2
- package/src/tailwind-colors.ts +33 -0
- package/src/types.ts +3 -0
|
@@ -175,7 +175,8 @@ export function SeoEditor() {
|
|
|
175
175
|
})
|
|
176
176
|
|
|
177
177
|
if (result.errors && result.errors.length > 0) {
|
|
178
|
-
|
|
178
|
+
const details = result.errors.map(e => e.error).join('; ')
|
|
179
|
+
showToast(`SEO save failed: ${details}`, 'error')
|
|
179
180
|
} else {
|
|
180
181
|
showToast(`Saved ${result.updated} SEO change(s) successfully!`, 'success')
|
|
181
182
|
clearPendingSeoChanges()
|
|
@@ -196,7 +196,7 @@ export function TextStyleToolbar({ visible, rect, element, onStyleChange }: Text
|
|
|
196
196
|
return (
|
|
197
197
|
<div
|
|
198
198
|
data-cms-ui
|
|
199
|
-
onMouseDown={(e) => e.stopPropagation()}
|
|
199
|
+
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation() }}
|
|
200
200
|
onClick={(e) => e.stopPropagation()}
|
|
201
201
|
style={{
|
|
202
202
|
position: 'fixed',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useState } from 'preact/hooks'
|
|
1
|
+
import { useCallback, useEffect, useState } from 'preact/hooks'
|
|
2
2
|
import { TIMING } from '../../constants'
|
|
3
3
|
import type { ToastMessage } from './types'
|
|
4
4
|
|
|
@@ -8,8 +8,16 @@ interface ToastProps extends ToastMessage {
|
|
|
8
8
|
|
|
9
9
|
export const Toast = ({ id, message, type, onRemove }: ToastProps) => {
|
|
10
10
|
const [isVisible, setIsVisible] = useState(true)
|
|
11
|
+
const persistent = type === 'error'
|
|
12
|
+
|
|
13
|
+
const dismiss = useCallback(() => {
|
|
14
|
+
setIsVisible(false)
|
|
15
|
+
setTimeout(() => onRemove(id), TIMING.TOAST_FADE_DURATION_MS)
|
|
16
|
+
}, [id, onRemove])
|
|
11
17
|
|
|
12
18
|
useEffect(() => {
|
|
19
|
+
if (persistent) return
|
|
20
|
+
|
|
13
21
|
const hideTimer = setTimeout(() => {
|
|
14
22
|
setIsVisible(false)
|
|
15
23
|
}, TIMING.TOAST_VISIBLE_DURATION_MS)
|
|
@@ -22,7 +30,7 @@ export const Toast = ({ id, message, type, onRemove }: ToastProps) => {
|
|
|
22
30
|
clearTimeout(hideTimer)
|
|
23
31
|
clearTimeout(removeTimer)
|
|
24
32
|
}
|
|
25
|
-
}, [id, onRemove])
|
|
33
|
+
}, [id, onRemove, persistent])
|
|
26
34
|
|
|
27
35
|
const typeClasses = {
|
|
28
36
|
error: 'bg-cms-dark border-l-4 border-l-cms-error text-white',
|
|
@@ -44,6 +52,15 @@ export const Toast = ({ id, message, type, onRemove }: ToastProps) => {
|
|
|
44
52
|
{type === 'error' && <span class="text-cms-error text-lg">✕</span>}
|
|
45
53
|
{type === 'info' && <span class="w-2.5 h-2.5 rounded-full bg-white/50 shrink-0" />}
|
|
46
54
|
{message}
|
|
55
|
+
{persistent && (
|
|
56
|
+
<button
|
|
57
|
+
onClick={dismiss}
|
|
58
|
+
class="ml-1 text-white/60 hover:text-white transition-colors text-lg leading-none cursor-pointer"
|
|
59
|
+
aria-label="Dismiss"
|
|
60
|
+
>
|
|
61
|
+
✕
|
|
62
|
+
</button>
|
|
63
|
+
)}
|
|
47
64
|
</div>
|
|
48
65
|
)
|
|
49
66
|
}
|
package/src/editor/editor.ts
CHANGED
|
@@ -154,27 +154,13 @@ export async function startEditMode(
|
|
|
154
154
|
makeElementEditable(el)
|
|
155
155
|
|
|
156
156
|
// Suppress browser native contentEditable undo/redo (we handle it ourselves)
|
|
157
|
-
// Also
|
|
158
|
-
// default behavior which creates <div> elements with characters
|
|
157
|
+
// Also prevent Enter/Shift+Enter from inserting line breaks
|
|
159
158
|
el.addEventListener('beforeinput', (e) => {
|
|
160
159
|
if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') {
|
|
161
160
|
e.preventDefault()
|
|
162
161
|
}
|
|
163
|
-
if (e.inputType === 'insertParagraph') {
|
|
162
|
+
if (e.inputType === 'insertParagraph' || e.inputType === 'insertLineBreak') {
|
|
164
163
|
e.preventDefault()
|
|
165
|
-
const selection = window.getSelection()
|
|
166
|
-
if (selection && selection.rangeCount > 0) {
|
|
167
|
-
const range = selection.getRangeAt(0)
|
|
168
|
-
range.deleteContents()
|
|
169
|
-
const br = document.createElement('br')
|
|
170
|
-
range.insertNode(br)
|
|
171
|
-
range.setStartAfter(br)
|
|
172
|
-
range.collapse(true)
|
|
173
|
-
selection.removeAllRanges()
|
|
174
|
-
selection.addRange(range)
|
|
175
|
-
// Trigger input event for change tracking
|
|
176
|
-
el.dispatchEvent(new Event('input', { bubbles: true }))
|
|
177
|
-
}
|
|
178
164
|
}
|
|
179
165
|
})
|
|
180
166
|
|
|
@@ -633,14 +619,14 @@ export async function saveAllChanges(
|
|
|
633
619
|
// For each color type that changed, add a separate change entry
|
|
634
620
|
const { originalClasses, newClasses } = change
|
|
635
621
|
const entry = manifest.entries[cmsId]
|
|
636
|
-
const
|
|
622
|
+
const classTypes = ['bg', 'text', 'border', 'hoverBg', 'hoverText', 'fontWeight', 'fontStyle', 'textDecoration', 'fontSize'] as const
|
|
637
623
|
|
|
638
624
|
// Find the best source info from any color type that has it
|
|
639
625
|
// (all color types share the same class attribute on the same element)
|
|
640
626
|
let sharedSourcePath: string | undefined
|
|
641
627
|
let sharedSourceLine: number | undefined
|
|
642
628
|
let sharedSourceSnippet: string | undefined
|
|
643
|
-
for (const ct of
|
|
629
|
+
for (const ct of classTypes) {
|
|
644
630
|
const orig = originalClasses[ct]
|
|
645
631
|
const curr = newClasses[ct]
|
|
646
632
|
const sp = curr?.sourcePath ?? orig?.sourcePath
|
|
@@ -653,10 +639,10 @@ export async function saveAllChanges(
|
|
|
653
639
|
}
|
|
654
640
|
}
|
|
655
641
|
|
|
656
|
-
for (const
|
|
657
|
-
const origAttr = originalClasses[
|
|
658
|
-
const newAttr = newClasses[
|
|
659
|
-
if (newAttr
|
|
642
|
+
for (const classType of classTypes) {
|
|
643
|
+
const origAttr = originalClasses[classType]
|
|
644
|
+
const newAttr = newClasses[classType]
|
|
645
|
+
if (newAttr && newAttr.value !== (origAttr?.value ?? '')) {
|
|
660
646
|
const bestSourcePath = newAttr.sourcePath ?? origAttr?.sourcePath ?? sharedSourcePath
|
|
661
647
|
const bestSourceLine = newAttr.sourceLine ?? origAttr?.sourceLine ?? sharedSourceLine
|
|
662
648
|
const bestSourceSnippet = newAttr.sourceSnippet ?? origAttr?.sourceSnippet ?? sharedSourceSnippet
|
|
@@ -670,7 +656,7 @@ export async function saveAllChanges(
|
|
|
670
656
|
colorChange: {
|
|
671
657
|
oldClass: origAttr?.value || '',
|
|
672
658
|
newClass: newAttr.value,
|
|
673
|
-
type:
|
|
659
|
+
type: classType,
|
|
674
660
|
sourcePath: bestSourcePath,
|
|
675
661
|
sourceLine: bestSourceLine,
|
|
676
662
|
sourceSnippet: bestSourceSnippet,
|
|
@@ -1047,7 +1033,7 @@ function setupColorTracking(
|
|
|
1047
1033
|
export function handleColorChange(
|
|
1048
1034
|
config: CmsConfig,
|
|
1049
1035
|
cmsId: string,
|
|
1050
|
-
colorType:
|
|
1036
|
+
colorType: string,
|
|
1051
1037
|
oldClass: string,
|
|
1052
1038
|
newClass: string,
|
|
1053
1039
|
onStateChange?: () => void,
|
|
@@ -1140,11 +1126,24 @@ export interface DeploymentPollingOptions {
|
|
|
1140
1126
|
* Start polling for deployment status after a save operation.
|
|
1141
1127
|
* Polls the API every 3 seconds until deployment completes or fails.
|
|
1142
1128
|
* Waits for deployment to appear for up to 30 seconds before giving up.
|
|
1129
|
+
* Skips polling entirely when deployment is not available (e.g. local dev).
|
|
1143
1130
|
*/
|
|
1144
|
-
export function startDeploymentPolling(config: CmsConfig, options?: DeploymentPollingOptions): void {
|
|
1131
|
+
export async function startDeploymentPolling(config: CmsConfig, options?: DeploymentPollingOptions): Promise<void> {
|
|
1145
1132
|
// Clear any existing timers
|
|
1146
1133
|
stopDeploymentPolling()
|
|
1147
1134
|
|
|
1135
|
+
// Do a preflight check to see if deployment is available
|
|
1136
|
+
try {
|
|
1137
|
+
const preflight = await getDeploymentStatus(config.apiBase)
|
|
1138
|
+
if (preflight.deploymentEnabled === false) {
|
|
1139
|
+
// Deployment not available (e.g. local dev) — skip polling entirely
|
|
1140
|
+
return
|
|
1141
|
+
}
|
|
1142
|
+
} catch {
|
|
1143
|
+
// If we can't even reach the endpoint, skip polling
|
|
1144
|
+
return
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1148
1147
|
// Reset wait attempts counter and store the timestamp when we started
|
|
1149
1148
|
deploymentWaitAttempts = 0
|
|
1150
1149
|
deploymentStartTimestamp = new Date().toISOString()
|
package/src/editor/index.tsx
CHANGED
|
@@ -55,6 +55,26 @@ import { hasPendingEntryNavigation, loadEditingState, loadSettingsFromStorage, s
|
|
|
55
55
|
import CMS_STYLES from './styles.css?inline'
|
|
56
56
|
import { generateCSSVariables, resolveTheme } from './themes'
|
|
57
57
|
|
|
58
|
+
/** Inline CSS values for Tailwind text style classes (for preview before save) */
|
|
59
|
+
const TEXT_STYLE_INLINE_CSS: Record<string, Record<string, string>> = {
|
|
60
|
+
'font-normal': { fontWeight: '400' },
|
|
61
|
+
'font-medium': { fontWeight: '500' },
|
|
62
|
+
'font-semibold': { fontWeight: '600' },
|
|
63
|
+
'font-bold': { fontWeight: '700' },
|
|
64
|
+
'italic': { fontStyle: 'italic' },
|
|
65
|
+
'not-italic': { fontStyle: 'normal' },
|
|
66
|
+
'underline': { textDecoration: 'underline' },
|
|
67
|
+
'line-through': { textDecoration: 'line-through' },
|
|
68
|
+
'no-underline': { textDecoration: 'none' },
|
|
69
|
+
'text-xs': { fontSize: '0.75rem', lineHeight: '1rem' },
|
|
70
|
+
'text-sm': { fontSize: '0.875rem', lineHeight: '1.25rem' },
|
|
71
|
+
'text-base': { fontSize: '1rem', lineHeight: '1.5rem' },
|
|
72
|
+
'text-lg': { fontSize: '1.125rem', lineHeight: '1.75rem' },
|
|
73
|
+
'text-xl': { fontSize: '1.25rem', lineHeight: '1.75rem' },
|
|
74
|
+
'text-2xl': { fontSize: '1.5rem', lineHeight: '2rem' },
|
|
75
|
+
'text-3xl': { fontSize: '1.875rem', lineHeight: '2.25rem' },
|
|
76
|
+
}
|
|
77
|
+
|
|
58
78
|
const CmsUI = () => {
|
|
59
79
|
const config = signals.config.value
|
|
60
80
|
const outlineState = useElementDetection()
|
|
@@ -142,10 +162,12 @@ const CmsUI = () => {
|
|
|
142
162
|
if (result.success) {
|
|
143
163
|
signals.showToast(`Saved ${result.updated} change(s) successfully!`, 'success')
|
|
144
164
|
} else if (result.errors) {
|
|
145
|
-
|
|
165
|
+
const details = result.errors.map(e => e.error).join('; ')
|
|
166
|
+
signals.showToast(`Save failed: ${details}`, 'error')
|
|
146
167
|
}
|
|
147
168
|
} catch (err) {
|
|
148
|
-
|
|
169
|
+
const message = err instanceof Error ? err.message : 'Unknown error'
|
|
170
|
+
signals.showToast(`Save failed: ${message}`, 'error')
|
|
149
171
|
}
|
|
150
172
|
}, [config, updateUI])
|
|
151
173
|
|
|
@@ -216,6 +238,65 @@ const CmsUI = () => {
|
|
|
216
238
|
signals.openAttributeEditor(cmsId, rect)
|
|
217
239
|
}, [])
|
|
218
240
|
|
|
241
|
+
// Handle text style change from outline (element-level styling via class toggle)
|
|
242
|
+
const handleOutlineTextStyleChange = useCallback((cmsId: string, styleType: string, oldClass: string, newClass: string) => {
|
|
243
|
+
let change = signals.pendingColorChanges.value.get(cmsId)
|
|
244
|
+
|
|
245
|
+
// Create pending color change entry if it doesn't exist yet
|
|
246
|
+
// (elements with allowStyling=false may lack colorClasses but still need text style tracking)
|
|
247
|
+
if (!change) {
|
|
248
|
+
const el = document.querySelector(`[data-cms-id="${cmsId}"]`) as HTMLElement
|
|
249
|
+
if (!el) return
|
|
250
|
+
|
|
251
|
+
const entry = signals.manifest.value.entries[cmsId]
|
|
252
|
+
const originalClasses: Record<string, import('../types').Attribute> = {}
|
|
253
|
+
const newClasses: Record<string, import('../types').Attribute> = {}
|
|
254
|
+
if (entry?.colorClasses) {
|
|
255
|
+
for (const [key, attr] of Object.entries(entry.colorClasses)) {
|
|
256
|
+
originalClasses[key] = { ...attr }
|
|
257
|
+
newClasses[key] = { ...attr }
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
signals.setPendingColorChange(cmsId, {
|
|
262
|
+
element: el,
|
|
263
|
+
cmsId,
|
|
264
|
+
originalClasses,
|
|
265
|
+
newClasses,
|
|
266
|
+
isDirty: false,
|
|
267
|
+
})
|
|
268
|
+
change = signals.pendingColorChanges.value.get(cmsId)!
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Apply the class change on the DOM element
|
|
272
|
+
const el = change.element
|
|
273
|
+
const previousClassName = el.className
|
|
274
|
+
const previousStyleCssText = el.style.cssText
|
|
275
|
+
|
|
276
|
+
if (oldClass) {
|
|
277
|
+
el.classList.remove(oldClass)
|
|
278
|
+
const oldCss = TEXT_STYLE_INLINE_CSS[oldClass]
|
|
279
|
+
if (oldCss) {
|
|
280
|
+
for (const prop of Object.keys(oldCss)) {
|
|
281
|
+
;(el.style as any)[prop] = ''
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
el.classList.add(newClass)
|
|
286
|
+
|
|
287
|
+
// Apply inline styles for immediate visual preview
|
|
288
|
+
// (Tailwind classes not present in source won't be in the compiled CSS)
|
|
289
|
+
const newCss = TEXT_STYLE_INLINE_CSS[newClass]
|
|
290
|
+
if (newCss) {
|
|
291
|
+
for (const [prop, value] of Object.entries(newCss)) {
|
|
292
|
+
;(el.style as any)[prop] = value
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Delegate to handleColorChange (same class-replacement mechanism)
|
|
297
|
+
handleColorChange(config, cmsId, styleType, oldClass, newClass, updateUI, previousClassName, previousStyleCssText)
|
|
298
|
+
}, [config, updateUI])
|
|
299
|
+
|
|
219
300
|
// Handle attribute editor close
|
|
220
301
|
const handleAttributeEditorClose = useCallback(() => {
|
|
221
302
|
signals.closeAttributeEditor()
|
|
@@ -233,12 +314,24 @@ const CmsUI = () => {
|
|
|
233
314
|
const showEditableHighlights = signals.showEditableHighlights.value
|
|
234
315
|
const hasSeoData = !!(manifest as any).seo
|
|
235
316
|
|
|
317
|
+
// Check if selected text element allows inline styling
|
|
318
|
+
const selectedElementCmsId = textSelectionState.element?.getAttribute('data-cms-id')
|
|
319
|
+
const selectedEntry = selectedElementCmsId ? manifest.entries[selectedElementCmsId] : undefined
|
|
320
|
+
const isTextStylingAllowed = selectedEntry?.allowStyling !== false
|
|
321
|
+
|
|
236
322
|
// Get color toolbar data
|
|
323
|
+
const pendingColorChanges = signals.pendingColorChanges.value
|
|
237
324
|
const colorEditorElement = colorEditorState.targetElementId
|
|
238
|
-
?
|
|
325
|
+
? pendingColorChanges.get(colorEditorState.targetElementId)?.element ?? null
|
|
239
326
|
: null
|
|
240
327
|
const colorEditorCurrentClasses = colorEditorState.targetElementId
|
|
241
|
-
?
|
|
328
|
+
? pendingColorChanges.get(colorEditorState.targetElementId)?.newClasses
|
|
329
|
+
: undefined
|
|
330
|
+
|
|
331
|
+
// Get current text style classes for the outlined element (reactive - triggers re-render on change)
|
|
332
|
+
const outlineCmsId = outlineState.cmsId
|
|
333
|
+
const outlineTextStyleClasses = outlineCmsId
|
|
334
|
+
? pendingColorChanges.get(outlineCmsId)?.newClasses
|
|
242
335
|
: undefined
|
|
243
336
|
|
|
244
337
|
return (
|
|
@@ -256,8 +349,10 @@ const CmsUI = () => {
|
|
|
256
349
|
tagName={outlineState.tagName}
|
|
257
350
|
element={outlineState.element}
|
|
258
351
|
cmsId={outlineState.cmsId}
|
|
352
|
+
textStyleClasses={outlineTextStyleClasses}
|
|
259
353
|
onColorClick={handleOutlineColorClick}
|
|
260
354
|
onAttributeClick={handleOutlineAttributeClick}
|
|
355
|
+
onTextStyleChange={handleOutlineTextStyleChange}
|
|
261
356
|
/>
|
|
262
357
|
</ErrorBoundary>
|
|
263
358
|
|
|
@@ -307,7 +402,7 @@ const CmsUI = () => {
|
|
|
307
402
|
|
|
308
403
|
<ErrorBoundary componentName="Text Style Toolbar">
|
|
309
404
|
<TextStyleToolbar
|
|
310
|
-
visible={textSelectionState.hasSelection && isEditing && !isAIProcessing}
|
|
405
|
+
visible={textSelectionState.hasSelection && isEditing && !isAIProcessing && isTextStylingAllowed}
|
|
311
406
|
rect={textSelectionState.rect}
|
|
312
407
|
element={textSelectionState.element}
|
|
313
408
|
onStyleChange={updateUI}
|
package/src/editor/types.ts
CHANGED
|
@@ -138,8 +138,8 @@ export interface ColorChangePayload {
|
|
|
138
138
|
oldClass: string
|
|
139
139
|
/** The new color class (e.g., 'bg-red-500') */
|
|
140
140
|
newClass: string
|
|
141
|
-
/** Type of color change
|
|
142
|
-
type: 'bg' | 'text' | 'border' | 'hoverBg' | 'hoverText'
|
|
141
|
+
/** Type of color/style change */
|
|
142
|
+
type: 'bg' | 'text' | 'border' | 'hoverBg' | 'hoverText' | 'fontWeight' | 'fontStyle' | 'textDecoration' | 'fontSize'
|
|
143
143
|
/** Path to the source file where the color class is defined */
|
|
144
144
|
sourcePath?: string
|
|
145
145
|
/** Line number where the color class is defined */
|
|
@@ -418,6 +418,8 @@ export interface DeploymentStatusResponse {
|
|
|
418
418
|
publishedUrl: string
|
|
419
419
|
} | null
|
|
420
420
|
pendingCount: number
|
|
421
|
+
/** When false, deployment is not available (e.g. local dev) and polling should be skipped */
|
|
422
|
+
deploymentEnabled?: boolean
|
|
421
423
|
}
|
|
422
424
|
|
|
423
425
|
export interface DeploymentState {
|
|
@@ -142,6 +142,108 @@ function extractElementBounds(
|
|
|
142
142
|
return bounds
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Extract property values from a specific array element in the frontmatter.
|
|
147
|
+
*
|
|
148
|
+
* Parses the frontmatter code with Babel, finds the array variable declaration,
|
|
149
|
+
* and returns the property values from the element at the given index.
|
|
150
|
+
* Used to resolve spread props for array-rendered components (e.g. `{...item}`).
|
|
151
|
+
*/
|
|
152
|
+
export function extractArrayElementProps(
|
|
153
|
+
frontmatterContent: string,
|
|
154
|
+
arrayVarName: string,
|
|
155
|
+
elementIndex: number,
|
|
156
|
+
): Record<string, any> | null {
|
|
157
|
+
let ast: ReturnType<typeof parseBabel>
|
|
158
|
+
try {
|
|
159
|
+
ast = parseBabel(frontmatterContent, {
|
|
160
|
+
sourceType: 'module',
|
|
161
|
+
plugins: ['typescript'],
|
|
162
|
+
errorRecovery: true,
|
|
163
|
+
})
|
|
164
|
+
} catch {
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
for (const node of ast.program.body) {
|
|
169
|
+
const arrayExpr = findArrayExpression(node, arrayVarName)
|
|
170
|
+
if (arrayExpr && elementIndex < arrayExpr.elements.length) {
|
|
171
|
+
const element = arrayExpr.elements[elementIndex]
|
|
172
|
+
if (element?.type === 'ObjectExpression') {
|
|
173
|
+
return extractObjectValues(element, frontmatterContent)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function findArrayExpression(node: any, varName: string): any | null {
|
|
182
|
+
if (node.type === 'VariableDeclaration') {
|
|
183
|
+
for (const decl of node.declarations) {
|
|
184
|
+
if (
|
|
185
|
+
decl.id.type === 'Identifier'
|
|
186
|
+
&& decl.id.name === varName
|
|
187
|
+
&& decl.init?.type === 'ArrayExpression'
|
|
188
|
+
) {
|
|
189
|
+
return decl.init
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (node.type === 'ExportNamedDeclaration' && node.declaration?.type === 'VariableDeclaration') {
|
|
194
|
+
for (const decl of node.declaration.declarations) {
|
|
195
|
+
if (
|
|
196
|
+
decl.id.type === 'Identifier'
|
|
197
|
+
&& decl.id.name === varName
|
|
198
|
+
&& decl.init?.type === 'ArrayExpression'
|
|
199
|
+
) {
|
|
200
|
+
return decl.init
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return null
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function extractObjectValues(node: any, source: string): Record<string, any> {
|
|
208
|
+
const props: Record<string, any> = {}
|
|
209
|
+
for (const prop of node.properties) {
|
|
210
|
+
if (prop.type !== 'ObjectProperty') continue
|
|
211
|
+
const key = prop.key.type === 'Identifier' ? prop.key.name : prop.key.value
|
|
212
|
+
if (!key) continue
|
|
213
|
+
props[key] = extractAstValue(prop.value, source)
|
|
214
|
+
}
|
|
215
|
+
return props
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function extractAstValue(node: any, source: string): any {
|
|
219
|
+
switch (node.type) {
|
|
220
|
+
case 'StringLiteral':
|
|
221
|
+
return node.value
|
|
222
|
+
case 'NumericLiteral':
|
|
223
|
+
return node.value
|
|
224
|
+
case 'BooleanLiteral':
|
|
225
|
+
return node.value
|
|
226
|
+
case 'NullLiteral':
|
|
227
|
+
return null
|
|
228
|
+
case 'TemplateLiteral':
|
|
229
|
+
if (node.expressions.length === 0 && node.quasis.length === 1) {
|
|
230
|
+
return node.quasis[0].value.cooked
|
|
231
|
+
}
|
|
232
|
+
return source.slice(node.start, node.end)
|
|
233
|
+
case 'ArrayExpression':
|
|
234
|
+
return node.elements.map((el: any) => el ? extractAstValue(el, source) : null)
|
|
235
|
+
case 'ObjectExpression':
|
|
236
|
+
return extractObjectValues(node, source)
|
|
237
|
+
case 'UnaryExpression':
|
|
238
|
+
if (node.operator === '-' && node.argument.type === 'NumericLiteral') {
|
|
239
|
+
return -node.argument.value
|
|
240
|
+
}
|
|
241
|
+
return source.slice(node.start, node.end)
|
|
242
|
+
default:
|
|
243
|
+
return source.slice(node.start, node.end)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
145
247
|
/**
|
|
146
248
|
* Resolve the file, lines, invocation index, and array info for a component.
|
|
147
249
|
*/
|
|
@@ -8,7 +8,7 @@ import { acquireFileLock, escapeReplacement, normalizePagePath, resolveAndValida
|
|
|
8
8
|
export interface ColorChangePayload {
|
|
9
9
|
oldClass: string
|
|
10
10
|
newClass: string
|
|
11
|
-
type: 'bg' | 'text' | 'border' | 'hoverBg' | 'hoverText'
|
|
11
|
+
type: 'bg' | 'text' | 'border' | 'hoverBg' | 'hoverText' | 'fontWeight' | 'fontStyle' | 'textDecoration' | 'fontSize'
|
|
12
12
|
sourcePath?: string
|
|
13
13
|
sourceLine?: number
|
|
14
14
|
sourceSnippet?: string
|
|
@@ -360,7 +360,11 @@ function replaceClassInAttribute(
|
|
|
360
360
|
const idx = classes.indexOf(oldClass)
|
|
361
361
|
if (idx === -1) return null
|
|
362
362
|
|
|
363
|
-
|
|
363
|
+
if (newClass) {
|
|
364
|
+
classes[idx] = newClass
|
|
365
|
+
} else {
|
|
366
|
+
classes.splice(idx, 1)
|
|
367
|
+
}
|
|
364
368
|
return line.replace(classAttrPattern, `${prefix}${quote}${classes.join(' ')}${quote}`)
|
|
365
369
|
}
|
|
366
370
|
|
package/src/html-processor.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type HTMLElement as ParsedHTMLElement, parse } from 'node-html-parser'
|
|
2
2
|
import { processSeoFromHtml } from './seo-processor'
|
|
3
3
|
import { enhanceManifestWithSourceSnippets } from './source-finder'
|
|
4
|
-
import { extractColorClasses } from './tailwind-colors'
|
|
4
|
+
import { extractColorClasses, extractTextStyleClasses } from './tailwind-colors'
|
|
5
5
|
import type { Attribute, ComponentInstance, ImageMetadata, ManifestEntry, PageSeoData, SeoOptions } from './types'
|
|
6
6
|
import { generateStableId } from './utils'
|
|
7
7
|
|
|
@@ -830,9 +830,13 @@ export async function processHtml(
|
|
|
830
830
|
// Generate stable ID based on content and context
|
|
831
831
|
const stableId = generateStableId(tag, entryText, entrySourcePath)
|
|
832
832
|
|
|
833
|
-
// Extract color classes for buttons and other elements
|
|
833
|
+
// Extract color classes and text style classes for buttons and other elements
|
|
834
834
|
const classAttr = node.getAttribute('class')
|
|
835
835
|
const colorClasses = extractColorClasses(classAttr)
|
|
836
|
+
const textStyleClasses = extractTextStyleClasses(classAttr)
|
|
837
|
+
const allTrackedClasses = colorClasses || textStyleClasses
|
|
838
|
+
? { ...colorClasses, ...textStyleClasses }
|
|
839
|
+
: undefined
|
|
836
840
|
|
|
837
841
|
// Extract all relevant attributes for git diff tracking
|
|
838
842
|
const attributes = extractAllAttributes(node)
|
|
@@ -856,8 +860,8 @@ export async function processHtml(
|
|
|
856
860
|
stableId,
|
|
857
861
|
// Image metadata for image entries
|
|
858
862
|
imageMetadata: imageInfo?.metadata,
|
|
859
|
-
// Color classes for buttons/styled elements
|
|
860
|
-
colorClasses,
|
|
863
|
+
// Color and text style classes for buttons/styled elements
|
|
864
|
+
colorClasses: allTrackedClasses,
|
|
861
865
|
// All attributes with resolved values (isStatic will be updated later from source)
|
|
862
866
|
attributes,
|
|
863
867
|
}
|
package/src/index.ts
CHANGED
|
@@ -93,7 +93,7 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
return {
|
|
96
|
-
name: '@nuasite/
|
|
96
|
+
name: '@nuasite/cms',
|
|
97
97
|
hooks: {
|
|
98
98
|
'astro:config:setup': async ({ updateConfig, command, injectScript, logger }) => {
|
|
99
99
|
// --- CMS Marker setup ---
|
|
@@ -158,6 +158,28 @@ async function searchDirForExpressionProp(
|
|
|
158
158
|
// we look for cardProps.title in the definitions
|
|
159
159
|
const spreadPropPath = `${spreadVarName}.${propName}`
|
|
160
160
|
|
|
161
|
+
// When spread is inside a .map() call, search for array element definitions
|
|
162
|
+
// e.g., packages.map(pkg => <Card {...pkg} />) -> look for packages[N].propName
|
|
163
|
+
if (spreadMatch.mapSourceArray) {
|
|
164
|
+
const mapSourceArray = spreadMatch.mapSourceArray
|
|
165
|
+
for (const def of cached.variableDefinitions) {
|
|
166
|
+
if (
|
|
167
|
+
def.name === propName
|
|
168
|
+
&& def.parentName?.startsWith(mapSourceArray + '[')
|
|
169
|
+
&& normalizeText(def.value) === normalizedSearch
|
|
170
|
+
) {
|
|
171
|
+
return {
|
|
172
|
+
file: path.relative(getProjectRoot(), fullPath),
|
|
173
|
+
line: def.line,
|
|
174
|
+
snippet: cached.lines[def.line - 1] || '',
|
|
175
|
+
type: 'variable',
|
|
176
|
+
variableName: buildDefinitionPath(def),
|
|
177
|
+
definitionLine: def.line,
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
161
183
|
for (const def of cached.variableDefinitions) {
|
|
162
184
|
const defPath = buildDefinitionPath(def)
|
|
163
185
|
if (defPath === spreadPropPath) {
|
|
@@ -671,6 +693,26 @@ async function searchDirForAttributeProp(
|
|
|
671
693
|
// Try spread prop usage
|
|
672
694
|
const spreadMatch = findSpreadProp(cached.ast, componentName)
|
|
673
695
|
if (spreadMatch) {
|
|
696
|
+
// When spread is inside a .map() call, search for array element definitions
|
|
697
|
+
if (spreadMatch.mapSourceArray) {
|
|
698
|
+
const mapSourceArray = spreadMatch.mapSourceArray
|
|
699
|
+
for (const def of cached.variableDefinitions) {
|
|
700
|
+
if (
|
|
701
|
+
def.name === propName
|
|
702
|
+
&& def.parentName?.startsWith(mapSourceArray + '[')
|
|
703
|
+
) {
|
|
704
|
+
return {
|
|
705
|
+
file: path.relative(getProjectRoot(), fullPath),
|
|
706
|
+
line: def.line,
|
|
707
|
+
snippet: cached.lines[def.line - 1] || '',
|
|
708
|
+
type: 'variable',
|
|
709
|
+
variableName: buildDefinitionPath(def),
|
|
710
|
+
definitionLine: def.line,
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
674
716
|
const spreadPropPath = `${spreadMatch.spreadVarName}.${propName}`
|
|
675
717
|
for (const def of cached.variableDefinitions) {
|
|
676
718
|
const defPath = buildDefinitionPath(def)
|
|
@@ -353,7 +353,7 @@ export function findSpreadProp(
|
|
|
353
353
|
ast: AstroNode,
|
|
354
354
|
componentName: string,
|
|
355
355
|
): SpreadPropMatch | null {
|
|
356
|
-
function visit(node: AstroNode): SpreadPropMatch | null {
|
|
356
|
+
function visit(node: AstroNode, parentExpression: AstroNode | null): SpreadPropMatch | null {
|
|
357
357
|
// Check component nodes matching the name
|
|
358
358
|
if (node.type === 'component') {
|
|
359
359
|
const compNode = node as ComponentNode
|
|
@@ -362,20 +362,34 @@ export function findSpreadProp(
|
|
|
362
362
|
// Check for spread attributes: {...cardProps}
|
|
363
363
|
// In Astro AST: type='attribute', kind='spread', name=variable name
|
|
364
364
|
if (attr.type === 'attribute' && attr.kind === 'spread' && attr.name) {
|
|
365
|
-
|
|
365
|
+
const match: SpreadPropMatch = {
|
|
366
366
|
componentName,
|
|
367
367
|
spreadVarName: attr.name,
|
|
368
368
|
line: attr.position?.start.line ?? compNode.position?.start.line ?? 0,
|
|
369
369
|
}
|
|
370
|
+
|
|
371
|
+
// Check if this spread is inside a .map() call by examining parent expression
|
|
372
|
+
if (parentExpression) {
|
|
373
|
+
const exprText = getTextContent(parentExpression)
|
|
374
|
+
const mapMatch = exprText.match(/(\w+(?:\.\w+)*)\.map\s*\(\s*\(?(\w+)\)?\s*=>/)
|
|
375
|
+
if (mapMatch && mapMatch[2] === attr.name) {
|
|
376
|
+
match.mapSourceArray = mapMatch[1]
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return match
|
|
370
381
|
}
|
|
371
382
|
}
|
|
372
383
|
}
|
|
373
384
|
}
|
|
374
385
|
|
|
386
|
+
// Track the nearest ancestor expression node
|
|
387
|
+
const nextParentExpression = node.type === 'expression' ? node : parentExpression
|
|
388
|
+
|
|
375
389
|
// Recursively visit children
|
|
376
390
|
if ('children' in node && Array.isArray(node.children)) {
|
|
377
391
|
for (const child of node.children) {
|
|
378
|
-
const result = visit(child)
|
|
392
|
+
const result = visit(child, nextParentExpression)
|
|
379
393
|
if (result) return result
|
|
380
394
|
}
|
|
381
395
|
}
|
|
@@ -383,5 +397,5 @@ export function findSpreadProp(
|
|
|
383
397
|
return null
|
|
384
398
|
}
|
|
385
399
|
|
|
386
|
-
return visit(ast)
|
|
400
|
+
return visit(ast, null)
|
|
387
401
|
}
|
|
@@ -618,6 +618,7 @@ export async function enhanceManifestWithSourceSnippets(
|
|
|
618
618
|
sourceLine: matchingDef.line,
|
|
619
619
|
sourceSnippet: defSnippet,
|
|
620
620
|
variableName: buildDefinitionPath(matchingDef),
|
|
621
|
+
allowStyling: false,
|
|
621
622
|
attributes,
|
|
622
623
|
colorClasses,
|
|
623
624
|
sourceHash,
|
|
@@ -666,6 +667,7 @@ export async function enhanceManifestWithSourceSnippets(
|
|
|
666
667
|
sourceLine: result.line,
|
|
667
668
|
sourceSnippet: propSnippet,
|
|
668
669
|
variableName: result.variableName,
|
|
670
|
+
allowStyling: false,
|
|
669
671
|
attributes,
|
|
670
672
|
colorClasses,
|
|
671
673
|
sourceHash: propSourceHash,
|
|
@@ -691,6 +693,7 @@ export async function enhanceManifestWithSourceSnippets(
|
|
|
691
693
|
sourceLine: result.line,
|
|
692
694
|
sourceSnippet: parentSnippet,
|
|
693
695
|
variableName: result.variableName,
|
|
696
|
+
allowStyling: false,
|
|
694
697
|
attributes,
|
|
695
698
|
colorClasses,
|
|
696
699
|
sourceHash: propSourceHash,
|
|
@@ -184,6 +184,9 @@ export interface SpreadPropMatch {
|
|
|
184
184
|
/** The variable name being spread (e.g., 'cardProps' from {...cardProps}) */
|
|
185
185
|
spreadVarName: string
|
|
186
186
|
line: number
|
|
187
|
+
/** Source array name when spread is inside a .map() call
|
|
188
|
+
* e.g., 'packages' from packages.map((pkg) => <Card {...pkg} />) */
|
|
189
|
+
mapSourceArray?: string
|
|
187
190
|
}
|
|
188
191
|
|
|
189
192
|
export interface ImageMatch {
|