@nuasite/cms 0.37.0 → 0.38.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.
@@ -22,6 +22,7 @@ import {
22
22
  findInTextIndex,
23
23
  findTemplateElementUsingStringLiteral,
24
24
  findTranslationByKeyAndText,
25
+ findVariableHitInFile,
25
26
  initializeSearchIndex,
26
27
  isTranslationFilePath,
27
28
  } from './search-index'
@@ -110,20 +111,19 @@ export function findTextDefinitionLine(
110
111
  * this function searches backwards to find the opening tag first.
111
112
  */
112
113
  export function extractCompleteTagSnippet(lines: string[], startLine: number, tag: string): string {
113
- // Pattern to match opening tag - either followed by whitespace/>, or at end of line (multi-line tag)
114
114
  const escapedTag = escapeRegex(tag)
115
+ // Opening tag — followed by whitespace/`>`, or at end of line (multi-line tag).
115
116
  const openTagPattern = new RegExp(`<${escapedTag}(?:[\\s>]|$)`, 'gi')
117
+ const selfClosingPattern = new RegExp(`<${escapedTag}[^>]*/>`, 'gi')
118
+ const closeTagPattern = new RegExp(`</${escapedTag}>`, 'gi')
116
119
 
117
- // Check if the start line contains the opening tag
118
120
  let actualStartLine = startLine
119
121
  const startLineContent = lines[startLine] || ''
120
122
  if (!openTagPattern.test(startLineContent)) {
121
- // Search backwards to find the opening tag
123
+ // Search backwards for the opening tag.
122
124
  for (let i = startLine - 1; i >= Math.max(0, startLine - 20); i--) {
123
125
  const line = lines[i]
124
126
  if (!line) continue
125
-
126
- // Reset regex lastIndex for fresh test
127
127
  openTagPattern.lastIndex = 0
128
128
  if (openTagPattern.test(line)) {
129
129
  actualStartLine = i
@@ -136,32 +136,27 @@ export function extractCompleteTagSnippet(lines: string[], startLine: number, ta
136
136
  let depth = 0
137
137
  let foundClosing = false
138
138
 
139
- // Start from the opening tag line
140
139
  for (let i = actualStartLine; i < Math.min(actualStartLine + 30, lines.length); i++) {
141
- const line = lines[i]
142
-
143
- if (!line) {
144
- continue
145
- }
140
+ const line = lines[i] ?? ''
146
141
 
142
+ // Preserve blank lines verbatim — the snippet must match the file byte-for-byte
143
+ // so the writer's `content.includes(sourceSnippet)` check passes. Blank lines
144
+ // are common between frontmatter and the template body.
147
145
  snippetLines.push(line)
146
+ if (!line) continue
148
147
 
149
- // Count opening and closing tags
150
- // Opening tag can be followed by whitespace, >, or end of line (multi-line tag)
151
- const openTags = (line.match(new RegExp(`<${escapedTag}(?:[\\s>]|$)`, 'gi')) || []).length
152
- const selfClosing = (line.match(new RegExp(`<${escapedTag}[^>]*/>`, 'gi')) || []).length
153
- const closeTags = (line.match(new RegExp(`</${escapedTag}>`, 'gi')) || []).length
148
+ const openTags = countMatches(line, openTagPattern)
149
+ const selfClosing = countMatches(line, selfClosingPattern)
150
+ const closeTags = countMatches(line, closeTagPattern)
154
151
 
155
152
  depth += openTags - selfClosing - closeTags
156
153
 
157
- // If we found a self-closing tag or closed all tags, we're done
158
154
  if (selfClosing > 0 || (depth <= 0 && (closeTags > 0 || openTags > 0))) {
159
155
  foundClosing = true
160
156
  break
161
157
  }
162
158
  }
163
159
 
164
- // If we didn't find closing tag, just return the first line
165
160
  if (!foundClosing && snippetLines.length > 1) {
166
161
  return snippetLines[0]!
167
162
  }
@@ -169,6 +164,14 @@ export function extractCompleteTagSnippet(lines: string[], startLine: number, ta
169
164
  return snippetLines.join('\n')
170
165
  }
171
166
 
167
+ /** Count global-regex matches on a string without allocating the match array. */
168
+ function countMatches(str: string, pattern: RegExp): number {
169
+ pattern.lastIndex = 0
170
+ let count = 0
171
+ while (pattern.exec(str) !== null) count++
172
+ return count
173
+ }
174
+
172
175
  /**
173
176
  * Extract just the opening tag from source lines (e.g., `<a href="/foo" class="btn">`)
174
177
  * Handles multi-line opening tags.
@@ -199,8 +202,10 @@ export function extractOpeningTagWithLine(
199
202
  ): { snippet: string; startLine: number } | undefined {
200
203
  const escapedTag = escapeRegex(tag)
201
204
  const openTagPattern = new RegExp(`<${escapedTag}(?:[\\s>]|$)`, 'gi')
205
+ // Match `<tag …>` (the closing > of the opening tag), or `<tag … />` (self-closing).
206
+ const openTagMatcher = new RegExp(`<${escapedTag}[^>]*>`, 'i')
207
+ const selfClosingMatcher = new RegExp(`<${escapedTag}[^>]*/\\s*>`, 'i')
202
208
 
203
- // Find the line containing the opening tag
204
209
  let actualStartLine = startLine
205
210
  const startLineContent = lines[startLine] || ''
206
211
  if (!openTagPattern.test(startLineContent)) {
@@ -215,7 +220,6 @@ export function extractOpeningTagWithLine(
215
220
  }
216
221
  }
217
222
 
218
- // Collect lines until we find the closing > of the opening tag
219
223
  const snippetLines: string[] = []
220
224
  for (let i = actualStartLine; i < Math.min(actualStartLine + 10, lines.length); i++) {
221
225
  const line = lines[i]
@@ -224,15 +228,12 @@ export function extractOpeningTagWithLine(
224
228
  snippetLines.push(line)
225
229
  const combined = snippetLines.join('\n')
226
230
 
227
- // Check if we have the complete opening tag (found the closing >)
228
- // Match from <tag to the first > that's not part of => or />
229
- const openTagMatch = combined.match(new RegExp(`<${escapedTag}[^>]*>`, 'i'))
231
+ const openTagMatch = combined.match(openTagMatcher)
230
232
  if (openTagMatch) {
231
233
  return { snippet: openTagMatch[0], startLine: actualStartLine }
232
234
  }
233
235
 
234
- // Also check for self-closing tag
235
- const selfClosingMatch = combined.match(new RegExp(`<${escapedTag}[^>]*/\\s*>`, 'i'))
236
+ const selfClosingMatch = combined.match(selfClosingMatcher)
236
237
  if (selfClosingMatch) {
237
238
  return { snippet: selfClosingMatch[0], startLine: actualStartLine }
238
239
  }
@@ -849,28 +850,30 @@ export async function enhanceManifestWithSourceSnippets(
849
850
  }
850
851
  }
851
852
 
852
- // Missing source info — try the text search index as a last resort.
853
- // Two cases reach here without a sourcePath/sourceLine:
854
- // (1) text rendered through a translation helper (e.g. `{t(locale, 'key')}`),
855
- // which the marking phase can't trace to the originating i18n JSON;
856
- // (2) static template text under runtimes that don't inject
857
- // `data-astro-source-*` attributes. Only (1) needs the translation
858
- // treatment (text edits target the JSON, styling disabled); (2) just
859
- // needs source coordinates so standard snippet extraction below picks
860
- // up the full element.
853
+ // Two cases want the index over the marking phase:
854
+ // (1) text rendered via translation helpers / runtimes that don't inject
855
+ // `data-astro-source-*` (toast i18n the JSON; rendered template gets coords).
856
+ // (2) `{fact.value}`-style expressions where Astro points sourceLine at the
857
+ // JSX template line the real edit target is the variable definition.
861
858
  const trimmedEntryText = entry.text?.trim()
862
- if (!entry.sourceSnippet && trimmedEntryText && entry.tag && (!entry.sourcePath || !entry.sourceLine)) {
863
- const indexHit = findInTextIndex(trimmedEntryText, entry.tag)
864
- if (indexHit) {
865
- if (isTranslationFilePath(indexHit.file)) {
866
- const resolved = await applyTranslationSource(entry, indexHit, entry.attributes, entry.colorClasses)
859
+ const alreadyResolved = entry.sourceSnippet || entry.variableName
860
+ if (!alreadyResolved && trimmedEntryText && entry.tag) {
861
+ const sameFileHit = entry.sourcePath
862
+ ? findVariableHitInFile(trimmedEntryText, entry.tag, entry.sourcePath)
863
+ : undefined
864
+ const noCoords = !entry.sourcePath || !entry.sourceLine
865
+ const winner = sameFileHit ?? (noCoords ? findInTextIndex(trimmedEntryText, entry.tag) : undefined)
866
+
867
+ if (winner) {
868
+ if (isTranslationFilePath(winner.file)) {
869
+ const resolved = await applyTranslationSource(entry, winner, entry.attributes, entry.colorClasses)
867
870
  return [id, resolved] as const
868
871
  }
869
872
  entry = {
870
873
  ...entry,
871
- sourcePath: indexHit.file,
872
- sourceLine: indexHit.line,
873
- ...(indexHit.variableName ? { variableName: indexHit.variableName } : {}),
874
+ sourcePath: winner.file,
875
+ sourceLine: winner.line,
876
+ ...(winner.variableName ? { variableName: winner.variableName } : {}),
874
877
  }
875
878
  }
876
879
  }
@@ -24,10 +24,13 @@ export async function findSourceLocation(
24
24
  ): Promise<SourceLocation | undefined> {
25
25
  // Use index if available (much faster)
26
26
  if (isSearchIndexInitialized()) {
27
- return findInTextIndex(textContent, tag)
27
+ const indexHit = findInTextIndex(textContent, tag)
28
+ if (indexHit) return indexHit
29
+ // Fall through to slow search on miss — covers cases the per-file
30
+ // indexer can't pre-emit, like a child component's `.map()` over a
31
+ // prop array whose source values live in the parent file.
28
32
  }
29
33
 
30
- // Fallback to slow search if index not initialized
31
34
  const srcDir = path.join(getProjectRoot(), 'src')
32
35
 
33
36
  try {