@nuasite/cms 0.6.0 → 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/package.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "directory": "packages/astro-cms"
15
15
  },
16
16
  "license": "Apache-2.0",
17
- "version": "0.6.0",
17
+ "version": "0.7.0",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -403,6 +403,9 @@ async function processFile(
403
403
  entry.variableName = mdSource.variableName
404
404
  entry.collectionName = mdSource.collectionName
405
405
  entry.collectionSlug = mdSource.collectionSlug
406
+ if (mdSource.variableName) {
407
+ entry.allowStyling = false
408
+ }
406
409
  return
407
410
  }
408
411
  }
@@ -414,6 +417,9 @@ async function processFile(
414
417
  entry.sourceLine = sourceLocation.line
415
418
  entry.sourceSnippet = sourceLocation.snippet
416
419
  entry.variableName = sourceLocation.variableName
420
+ if (sourceLocation.variableName) {
421
+ entry.allowStyling = false
422
+ }
417
423
 
418
424
  // Update attribute and colorClasses source information if we have an opening tag
419
425
  if (sourceLocation.openingTagSnippet) {
@@ -3,6 +3,7 @@ import { getColorPreview, parseColorClass } from '../color-utils'
3
3
  import { Z_INDEX } from '../constants'
4
4
  import { isPageDark } from '../dom'
5
5
  import * as signals from '../signals'
6
+ import type { Attribute } from '../../types'
6
7
 
7
8
  export interface OutlineProps {
8
9
  visible: boolean
@@ -14,10 +15,14 @@ export interface OutlineProps {
14
15
  element?: HTMLElement | null
15
16
  /** CMS ID of the hovered element */
16
17
  cmsId?: string | null
18
+ /** Current text style classes from pending changes (reactive) */
19
+ textStyleClasses?: Record<string, Attribute>
17
20
  /** Callback when a color swatch is clicked */
18
21
  onColorClick?: (cmsId: string, rect: DOMRect) => void
19
22
  /** Callback when an attribute indicator is clicked */
20
23
  onAttributeClick?: (cmsId: string, rect: DOMRect) => void
24
+ /** Callback when a text style toggle is clicked */
25
+ onTextStyleChange?: (cmsId: string, styleType: string, oldClass: string, newClass: string) => void
21
26
  }
22
27
 
23
28
  // Minimum space needed to show label outside the element
@@ -30,7 +35,7 @@ const STICKY_PADDING = 8
30
35
  * Uses a custom element with Shadow DOM to avoid style conflicts.
31
36
  */
32
37
  export function Outline(
33
- { visible, rect, isComponent = false, componentName, tagName, element, cmsId, onColorClick, onAttributeClick }: OutlineProps,
38
+ { visible, rect, isComponent = false, componentName, tagName, element, cmsId, textStyleClasses, onColorClick, onAttributeClick, onTextStyleChange }: OutlineProps,
34
39
  ) {
35
40
  const containerRef = useRef<HTMLDivElement>(null)
36
41
  const shadowRootRef = useRef<ShadowRoot | null>(null)
@@ -182,6 +187,60 @@ export function Outline(
182
187
  .attr-button:hover svg {
183
188
  color: #DFFF40;
184
189
  }
190
+
191
+ .text-style-btn {
192
+ width: 28px;
193
+ height: 28px;
194
+ display: flex;
195
+ align-items: center;
196
+ justify-content: center;
197
+ background: transparent;
198
+ border: 1px solid transparent;
199
+ border-radius: 6px;
200
+ cursor: pointer;
201
+ transition: all 150ms ease;
202
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
203
+ font-size: 13px;
204
+ color: rgba(255,255,255,0.7);
205
+ padding: 0;
206
+ line-height: 1;
207
+ }
208
+
209
+ .text-style-btn:hover {
210
+ background: rgba(255,255,255,0.1);
211
+ color: #DFFF40;
212
+ }
213
+
214
+ .text-style-btn.active {
215
+ background: rgba(223, 255, 64, 0.15);
216
+ border-color: rgba(223, 255, 64, 0.4);
217
+ color: #DFFF40;
218
+ }
219
+
220
+ .text-size-select {
221
+ height: 28px;
222
+ background: transparent;
223
+ border: 1px solid rgba(255,255,255,0.15);
224
+ border-radius: 6px;
225
+ color: rgba(255,255,255,0.7);
226
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
227
+ font-size: 11px;
228
+ padding: 0 4px;
229
+ cursor: pointer;
230
+ transition: all 150ms ease;
231
+ -webkit-appearance: none;
232
+ appearance: none;
233
+ }
234
+
235
+ .text-size-select:hover {
236
+ border-color: rgba(223, 255, 64, 0.4);
237
+ color: #DFFF40;
238
+ }
239
+
240
+ .text-size-select:focus {
241
+ outline: none;
242
+ border-color: #DFFF40;
243
+ }
185
244
  `
186
245
 
187
246
  overlayRef.current = document.createElement('div')
@@ -305,15 +364,15 @@ export function Outline(
305
364
 
306
365
  // Check for color swatches and attribute button
307
366
  const manifest = signals.manifest.value
308
- const pendingColorChange = cmsId ? signals.pendingColorChanges.value.get(cmsId) : null
309
367
  const entry = cmsId ? manifest.entries[cmsId] : null
310
- const colorClasses = pendingColorChange?.newClasses ?? entry?.colorClasses
368
+ const colorClasses = textStyleClasses ?? entry?.colorClasses
311
369
 
312
370
  const hasColorSwatches = colorClasses && (colorClasses.bg?.value || colorClasses.text?.value) && onColorClick
313
371
  const hasEditableAttributes = entry?.attributes && Object.keys(entry.attributes).length > 0
372
+ const needsElementLevelStyling = entry?.allowStyling === false && onTextStyleChange
314
373
 
315
- // Show unified toolbar if there are swatches or attribute button
316
- if ((hasColorSwatches || hasEditableAttributes) && (onColorClick || onAttributeClick)) {
374
+ // Show unified toolbar if there are swatches, attribute button, or element-level text styling
375
+ if ((hasColorSwatches || hasEditableAttributes || needsElementLevelStyling) && (onColorClick || onAttributeClick || onTextStyleChange)) {
317
376
  toolbarRef.current.className = 'element-toolbar'
318
377
  toolbarRef.current.innerHTML = ''
319
378
 
@@ -390,11 +449,111 @@ export function Outline(
390
449
  }
391
450
  toolbarRef.current.appendChild(attrButton)
392
451
  }
452
+
453
+ // Add text style buttons for elements where inline styling is unavailable
454
+ if (needsElementLevelStyling && cmsId) {
455
+ // Add divider if there are other toolbar items before
456
+ if (hasColorSwatches || hasEditableAttributes) {
457
+ const divider = document.createElement('div')
458
+ divider.className = 'toolbar-divider'
459
+ toolbarRef.current.appendChild(divider)
460
+ }
461
+
462
+ const currentClasses = textStyleClasses ?? colorClasses ?? {}
463
+
464
+ // Bold toggle
465
+ const boldBtn = document.createElement('button')
466
+ const isBold = currentClasses.fontWeight?.value === 'font-bold'
467
+ boldBtn.className = `text-style-btn${isBold ? ' active' : ''}`
468
+ boldBtn.innerHTML = '<strong>B</strong>'
469
+ boldBtn.title = isBold ? 'Remove bold' : 'Bold'
470
+ boldBtn.onclick = (e) => {
471
+ e.stopPropagation()
472
+ const oldClass = currentClasses.fontWeight?.value || ''
473
+ const newClass = isBold ? 'font-normal' : 'font-bold'
474
+ onTextStyleChange!(cmsId!, 'fontWeight', oldClass, newClass)
475
+ }
476
+ toolbarRef.current.appendChild(boldBtn)
477
+
478
+ // Italic toggle
479
+ const italicBtn = document.createElement('button')
480
+ const isItalic = currentClasses.fontStyle?.value === 'italic'
481
+ italicBtn.className = `text-style-btn${isItalic ? ' active' : ''}`
482
+ italicBtn.innerHTML = '<em>I</em>'
483
+ italicBtn.title = isItalic ? 'Remove italic' : 'Italic'
484
+ italicBtn.onclick = (e) => {
485
+ e.stopPropagation()
486
+ const oldClass = currentClasses.fontStyle?.value || ''
487
+ const newClass = isItalic ? 'not-italic' : 'italic'
488
+ onTextStyleChange!(cmsId!, 'fontStyle', oldClass, newClass)
489
+ }
490
+ toolbarRef.current.appendChild(italicBtn)
491
+
492
+ // Underline toggle
493
+ const underlineBtn = document.createElement('button')
494
+ const isUnderline = currentClasses.textDecoration?.value === 'underline'
495
+ underlineBtn.className = `text-style-btn${isUnderline ? ' active' : ''}`
496
+ underlineBtn.innerHTML = '<span style="text-decoration:underline">U</span>'
497
+ underlineBtn.title = isUnderline ? 'Remove underline' : 'Underline'
498
+ underlineBtn.onclick = (e) => {
499
+ e.stopPropagation()
500
+ const oldClass = currentClasses.textDecoration?.value || ''
501
+ const newClass = isUnderline ? 'no-underline' : 'underline'
502
+ onTextStyleChange!(cmsId!, 'textDecoration', oldClass, newClass)
503
+ }
504
+ toolbarRef.current.appendChild(underlineBtn)
505
+
506
+ // Strikethrough toggle
507
+ const strikeBtn = document.createElement('button')
508
+ const isStrike = currentClasses.textDecoration?.value === 'line-through'
509
+ strikeBtn.className = `text-style-btn${isStrike ? ' active' : ''}`
510
+ strikeBtn.innerHTML = '<span style="text-decoration:line-through">S</span>'
511
+ strikeBtn.title = isStrike ? 'Remove strikethrough' : 'Strikethrough'
512
+ strikeBtn.onclick = (e) => {
513
+ e.stopPropagation()
514
+ const oldClass = currentClasses.textDecoration?.value || ''
515
+ const newClass = isStrike ? 'no-underline' : 'line-through'
516
+ onTextStyleChange!(cmsId!, 'textDecoration', oldClass, newClass)
517
+ }
518
+ toolbarRef.current.appendChild(strikeBtn)
519
+
520
+ // Font size dropdown
521
+ const sizeSelect = document.createElement('select')
522
+ sizeSelect.className = 'text-size-select'
523
+ sizeSelect.title = 'Font size'
524
+ const sizeOptions = [
525
+ { value: '', label: 'Size' },
526
+ { value: 'text-xs', label: 'XS' },
527
+ { value: 'text-sm', label: 'SM' },
528
+ { value: 'text-base', label: 'Base' },
529
+ { value: 'text-lg', label: 'LG' },
530
+ { value: 'text-xl', label: 'XL' },
531
+ { value: 'text-2xl', label: '2XL' },
532
+ { value: 'text-3xl', label: '3XL' },
533
+ ]
534
+ const currentSize = currentClasses.fontSize?.value || ''
535
+ for (const opt of sizeOptions) {
536
+ const option = document.createElement('option')
537
+ option.value = opt.value
538
+ option.textContent = opt.label
539
+ if (opt.value === currentSize) option.selected = true
540
+ sizeSelect.appendChild(option)
541
+ }
542
+ sizeSelect.onchange = (e) => {
543
+ e.stopPropagation()
544
+ const newClass = (e.target as HTMLSelectElement).value
545
+ if (newClass) {
546
+ const oldClass = currentClasses.fontSize?.value || ''
547
+ onTextStyleChange!(cmsId!, 'fontSize', oldClass, newClass)
548
+ }
549
+ }
550
+ toolbarRef.current.appendChild(sizeSelect)
551
+ }
393
552
  } else {
394
553
  toolbarRef.current.className = 'element-toolbar hidden'
395
554
  }
396
555
  }
397
- }, [visible, rect, isComponent, componentName, tagName, cmsId, onColorClick, onAttributeClick])
556
+ }, [visible, rect, isComponent, componentName, tagName, cmsId, textStyleClasses, onColorClick, onAttributeClick, onTextStyleChange])
398
557
 
399
558
  return (
400
559
  <div
@@ -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',
@@ -619,14 +619,14 @@ export async function saveAllChanges(
619
619
  // For each color type that changed, add a separate change entry
620
620
  const { originalClasses, newClasses } = change
621
621
  const entry = manifest.entries[cmsId]
622
- const colorTypes = ['bg', 'text', 'border', 'hoverBg', 'hoverText'] as const
622
+ const classTypes = ['bg', 'text', 'border', 'hoverBg', 'hoverText', 'fontWeight', 'fontStyle', 'textDecoration', 'fontSize'] as const
623
623
 
624
624
  // Find the best source info from any color type that has it
625
625
  // (all color types share the same class attribute on the same element)
626
626
  let sharedSourcePath: string | undefined
627
627
  let sharedSourceLine: number | undefined
628
628
  let sharedSourceSnippet: string | undefined
629
- for (const ct of colorTypes) {
629
+ for (const ct of classTypes) {
630
630
  const orig = originalClasses[ct]
631
631
  const curr = newClasses[ct]
632
632
  const sp = curr?.sourcePath ?? orig?.sourcePath
@@ -639,10 +639,10 @@ export async function saveAllChanges(
639
639
  }
640
640
  }
641
641
 
642
- for (const colorType of colorTypes) {
643
- const origAttr = originalClasses[colorType]
644
- const newAttr = newClasses[colorType]
645
- if (newAttr?.value && newAttr.value !== origAttr?.value) {
642
+ for (const classType of classTypes) {
643
+ const origAttr = originalClasses[classType]
644
+ const newAttr = newClasses[classType]
645
+ if (newAttr && newAttr.value !== (origAttr?.value ?? '')) {
646
646
  const bestSourcePath = newAttr.sourcePath ?? origAttr?.sourcePath ?? sharedSourcePath
647
647
  const bestSourceLine = newAttr.sourceLine ?? origAttr?.sourceLine ?? sharedSourceLine
648
648
  const bestSourceSnippet = newAttr.sourceSnippet ?? origAttr?.sourceSnippet ?? sharedSourceSnippet
@@ -656,7 +656,7 @@ export async function saveAllChanges(
656
656
  colorChange: {
657
657
  oldClass: origAttr?.value || '',
658
658
  newClass: newAttr.value,
659
- type: colorType,
659
+ type: classType,
660
660
  sourcePath: bestSourcePath,
661
661
  sourceLine: bestSourceLine,
662
662
  sourceSnippet: bestSourceSnippet,
@@ -1033,7 +1033,7 @@ function setupColorTracking(
1033
1033
  export function handleColorChange(
1034
1034
  config: CmsConfig,
1035
1035
  cmsId: string,
1036
- colorType: 'bg' | 'text' | 'border' | 'hoverBg' | 'hoverText',
1036
+ colorType: string,
1037
1037
  oldClass: string,
1038
1038
  newClass: string,
1039
1039
  onStateChange?: () => void,
@@ -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()
@@ -218,6 +238,65 @@ const CmsUI = () => {
218
238
  signals.openAttributeEditor(cmsId, rect)
219
239
  }, [])
220
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
+
221
300
  // Handle attribute editor close
222
301
  const handleAttributeEditorClose = useCallback(() => {
223
302
  signals.closeAttributeEditor()
@@ -235,12 +314,24 @@ const CmsUI = () => {
235
314
  const showEditableHighlights = signals.showEditableHighlights.value
236
315
  const hasSeoData = !!(manifest as any).seo
237
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
+
238
322
  // Get color toolbar data
323
+ const pendingColorChanges = signals.pendingColorChanges.value
239
324
  const colorEditorElement = colorEditorState.targetElementId
240
- ? signals.pendingColorChanges.value.get(colorEditorState.targetElementId)?.element ?? null
325
+ ? pendingColorChanges.get(colorEditorState.targetElementId)?.element ?? null
241
326
  : null
242
327
  const colorEditorCurrentClasses = colorEditorState.targetElementId
243
- ? signals.pendingColorChanges.value.get(colorEditorState.targetElementId)?.newClasses
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
244
335
  : undefined
245
336
 
246
337
  return (
@@ -258,8 +349,10 @@ const CmsUI = () => {
258
349
  tagName={outlineState.tagName}
259
350
  element={outlineState.element}
260
351
  cmsId={outlineState.cmsId}
352
+ textStyleClasses={outlineTextStyleClasses}
261
353
  onColorClick={handleOutlineColorClick}
262
354
  onAttributeClick={handleOutlineAttributeClick}
355
+ onTextStyleChange={handleOutlineTextStyleChange}
263
356
  />
264
357
  </ErrorBoundary>
265
358
 
@@ -309,7 +402,7 @@ const CmsUI = () => {
309
402
 
310
403
  <ErrorBoundary componentName="Text Style Toolbar">
311
404
  <TextStyleToolbar
312
- visible={textSelectionState.hasSelection && isEditing && !isAIProcessing}
405
+ visible={textSelectionState.hasSelection && isEditing && !isAIProcessing && isTextStylingAllowed}
313
406
  rect={textSelectionState.rect}
314
407
  element={textSelectionState.element}
315
408
  onStyleChange={updateUI}
@@ -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: 'bg' | 'text' | 'border' | 'hoverBg' | 'hoverText' */
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 */
@@ -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
- classes[idx] = newClass
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
 
@@ -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
  }
@@ -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,
@@ -776,6 +776,39 @@ export function extractColorClasses(classAttr: string | null | undefined): Recor
776
776
  return Object.keys(result).length > 0 ? result : undefined
777
777
  }
778
778
 
779
+ /**
780
+ * Regex patterns for matching text style classes on elements.
781
+ */
782
+ const TEXT_STYLE_CLASS_PATTERNS: Record<string, RegExp> = {
783
+ fontWeight: /^font-(thin|extralight|light|normal|medium|semibold|bold|extrabold|black)$/,
784
+ fontStyle: /^(italic|not-italic)$/,
785
+ textDecoration: /^(underline|overline|line-through|no-underline)$/,
786
+ fontSize: /^text-(xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl)$/,
787
+ }
788
+
789
+ /**
790
+ * Extract text style classes from an element's class attribute.
791
+ * Returns a Record<string, Attribute> with keys: fontWeight, fontStyle, textDecoration, fontSize.
792
+ * Pattern: same as extractColorClasses — scan classes, match patterns, return first match per category.
793
+ */
794
+ export function extractTextStyleClasses(classAttr: string | null | undefined): Record<string, Attribute> | undefined {
795
+ if (!classAttr) return undefined
796
+
797
+ const classes = classAttr.split(/\s+/).filter(Boolean)
798
+ const result: Record<string, Attribute> = {}
799
+
800
+ for (const cls of classes) {
801
+ for (const [key, pattern] of Object.entries(TEXT_STYLE_CLASS_PATTERNS)) {
802
+ if (pattern.test(cls) && !(key in result)) {
803
+ result[key] = { value: cls }
804
+ break
805
+ }
806
+ }
807
+ }
808
+
809
+ return Object.keys(result).length > 0 ? result : undefined
810
+ }
811
+
779
812
  /**
780
813
  * Check if a class is a color class (including gradient colors).
781
814
  */
package/src/types.ts CHANGED
@@ -161,6 +161,9 @@ export interface ManifestEntry {
161
161
  colorClasses?: Record<string, Attribute>
162
162
  /** All HTML attributes with source information */
163
163
  attributes?: Record<string, Attribute>
164
+ /** Whether inline text styling (bold, italic, etc.) can be applied.
165
+ * False when text comes from a string variable/prop that cannot contain HTML markup. */
166
+ allowStyling?: boolean
164
167
  }
165
168
 
166
169
  export interface ComponentInstance {