@nuasite/cms 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.5.0",
17
+ "version": "0.5.1",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
package/src/editor/dom.ts CHANGED
@@ -161,16 +161,19 @@ export function isStyledSpan(element: HTMLElement): boolean {
161
161
  }
162
162
 
163
163
  /**
164
- * Helper function to recursively extract plain text from child nodes,
165
- * replacing CMS elements with their placeholders.
166
- * Note: This returns plain text only - for styled content, use innerHTML directly.
164
+ * Block-level elements that browsers may create inside contentEditable on Enter.
165
+ * These are treated as line breaks when extracting text.
167
166
  */
167
+ const BLOCK_ELEMENTS = new Set(['div', 'p', 'section', 'article', 'header', 'footer', 'blockquote'])
168
+
168
169
  function extractTextFromChildNodes(parentNode: HTMLElement): string {
169
170
  let text = ''
170
171
 
171
172
  parentNode.childNodes.forEach(node => {
172
173
  if (node.nodeType === Node.TEXT_NODE) {
173
- text += node.nodeValue || ''
174
+ // Normalize non-breaking spaces (\u00a0) that browsers insert in
175
+ // contentEditable to regular spaces
176
+ text += (node.nodeValue || '').replace(/\u00a0/g, ' ')
174
177
  } else if (node.nodeType === Node.ELEMENT_NODE) {
175
178
  const element = node as HTMLElement
176
179
  const tagName = element.tagName.toLowerCase()
@@ -186,9 +189,17 @@ function extractTextFromChildNodes(parentNode: HTMLElement): string {
186
189
  if (directCmsId) {
187
190
  // Element has CMS ID - replace with placeholder
188
191
  text += `{{cms:${directCmsId}}}`
192
+ } else if (BLOCK_ELEMENTS.has(tagName)) {
193
+ // Block-level elements created by browser on Enter should be
194
+ // treated as line breaks, not collapsed into the text
195
+ const blockText = extractTextFromChildNodes(element)
196
+ if (blockText) {
197
+ // Only add <br> separator if there's already text before this block
198
+ text += (text ? '<br>' : '') + blockText
199
+ }
189
200
  } else {
190
201
  // For all other elements (including styled spans), just get their text content
191
- text += element.textContent || ''
202
+ text += (element.textContent || '').replace(/\u00a0/g, ' ')
192
203
  }
193
204
  }
194
205
  })
@@ -154,10 +154,28 @@ export async function startEditMode(
154
154
  makeElementEditable(el)
155
155
 
156
156
  // Suppress browser native contentEditable undo/redo (we handle it ourselves)
157
+ // Also convert Enter (insertParagraph) to <br> instead of the browser's
158
+ // default behavior which creates <div> elements with &nbsp; characters
157
159
  el.addEventListener('beforeinput', (e) => {
158
160
  if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') {
159
161
  e.preventDefault()
160
162
  }
163
+ if (e.inputType === 'insertParagraph') {
164
+ 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
+ }
161
179
  })
162
180
 
163
181
  // Setup color tracking for elements with colorClasses in manifest
@@ -6,7 +6,7 @@ import type { Attribute, ManifestEntry } from '../types'
6
6
  import { escapeRegex, generateSourceHash } from '../utils'
7
7
  import { buildDefinitionPath } from './ast-extractors'
8
8
  import { getCachedParsedFile } from './ast-parser'
9
- import { findAttributeSourceLocation } from './cross-file-tracker'
9
+ import { findAttributeSourceLocation, searchForExpressionProp, searchForPropInParents } from './cross-file-tracker'
10
10
  import { findImageElementNearLine, findImageSourceLocation } from './image-finder'
11
11
 
12
12
  // ============================================================================
@@ -640,6 +640,67 @@ export async function enhanceManifestWithSourceSnippets(
640
640
  sourceHash,
641
641
  }] as const
642
642
  }
643
+
644
+ // Cross-file search for prop-driven dynamic text
645
+ // When text comes from a prop (e.g., {title} where title = Astro.props.title),
646
+ // trace it to where the prop value is actually defined in a parent component
647
+ if (cached) {
648
+ // Extract expression variables from the snippet to find props
649
+ const exprPattern = /\{(\w+(?:\.\w+|\[\d+\])*)\}/g
650
+ let exprMatch: RegExpExecArray | null
651
+ while ((exprMatch = exprPattern.exec(sourceSnippet)) !== null) {
652
+ const exprPath = exprMatch[1]!
653
+ const baseVar = exprPath.match(/^(\w+)/)?.[1]
654
+ if (baseVar && cached.propAliases.has(baseVar)) {
655
+ const propName = cached.propAliases.get(baseVar)!
656
+ const componentFileName = path.basename(filePath)
657
+ const result = await searchForExpressionProp(
658
+ componentFileName, propName, exprPath, entry.text!,
659
+ )
660
+ if (result) {
661
+ const propSnippet = result.snippet ?? trimmedText
662
+ const propSourceHash = generateSourceHash(propSnippet)
663
+ return [id, {
664
+ ...entry,
665
+ sourcePath: result.file,
666
+ sourceLine: result.line,
667
+ sourceSnippet: propSnippet,
668
+ variableName: result.variableName,
669
+ attributes,
670
+ colorClasses,
671
+ sourceHash: propSourceHash,
672
+ }] as const
673
+ }
674
+ }
675
+ }
676
+
677
+ // Search for quoted prop values in parent components
678
+ // (handles <Component title="literal text" />)
679
+ const srcDir = path.join(getProjectRoot(), 'src')
680
+ for (const searchDir of ['pages', 'components', 'layouts']) {
681
+ try {
682
+ const result = await searchForPropInParents(
683
+ path.join(srcDir, searchDir), trimmedText,
684
+ )
685
+ if (result) {
686
+ const parentSnippet = result.snippet ?? trimmedText
687
+ const propSourceHash = generateSourceHash(parentSnippet)
688
+ return [id, {
689
+ ...entry,
690
+ sourcePath: result.file,
691
+ sourceLine: result.line,
692
+ sourceSnippet: parentSnippet,
693
+ variableName: result.variableName,
694
+ attributes,
695
+ colorClasses,
696
+ sourceHash: propSourceHash,
697
+ }] as const
698
+ }
699
+ } catch {
700
+ // Directory doesn't exist
701
+ }
702
+ }
703
+ }
643
704
  }
644
705
 
645
706
  // Original static content path