@nuasite/cms 0.30.0 → 0.32.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.
@@ -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="${escapeRegExp(srcToFind)}"`)
220
- const srcPatternSingle = new RegExp(`src='${escapeRegExp(srcToFind)}'`)
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
- function applyAttributeChanges(
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
- `(${escapeRegExp(attributeName)}\\s*=\\s*)"(${escapeRegExp(attrOldValue)})"`,
552
+ `(${escapeRegex(attributeName)}\\s*=\\s*)"(${escapeRegex(attrOldValue)})"`,
521
553
  )
522
554
  const singleQuotePattern = new RegExp(
523
- `(${escapeRegExp(attributeName)}\\s*=\\s*)'(${escapeRegExp(attrOldValue)})'`,
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
- failedChanges.push({
537
- cmsId: change.cmsId,
538
- error: `Attribute '${attributeName}="${attrOldValue}"' not found on line ${targetLine}`,
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
- `(${escapeRegExp(attributeName)}\\s*=\\s*)"(${escapeRegExp(attrOldValue)})"`,
605
+ `(${escapeRegex(attributeName)}\\s*=\\s*)"(${escapeRegex(attrOldValue)})"`,
551
606
  )
552
607
  const singleQuotePattern = new RegExp(
553
- `(${escapeRegExp(attributeName)}\\s*=\\s*)'(${escapeRegExp(attrOldValue)})'`,
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
- const newText = htmlValue ?? newValue
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
  ["'", '&apos;'],
725
783
  ]
726
784
 
727
- let pattern = escapeRegExp(decodedText)
785
+ let pattern = escapeRegex(decodedText)
728
786
  for (const [char, entity] of entityMap) {
729
- const escapedChar = escapeRegExp(char)
730
- const escapedEntity = escapeRegExp(entity)
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) => escapeRegExp(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
- }
@@ -21,6 +21,43 @@ export function getStringValue(node: BabelNode): string | null {
21
21
  return null
22
22
  }
23
23
 
24
+ /**
25
+ * Collect every string-literal value reachable from a node, each paired with
26
+ * the line the literal itself sits on. Handles:
27
+ * - StringLiteral / TemplateLiteral (no substitutions) — a single value
28
+ * - ConditionalExpression (`a ? b : c`) — recurses into both branches
29
+ * - LogicalExpression (`a || b`, `a && b`, `a ?? b`) — recurses into both sides
30
+ *
31
+ * The per-literal line is what lets `findAttributeSourceLocation` route edits
32
+ * to the *specific* branch whose value matches the rendered attribute.
33
+ */
34
+ export function extractPossibleStringValues(
35
+ node: BabelNode,
36
+ lineTransformer: LineTransformer,
37
+ ): Array<{ value: string; line: number }> {
38
+ const results: Array<{ value: string; line: number }> = []
39
+ collect(node)
40
+ return results
41
+
42
+ function collect(n: BabelNode): void {
43
+ const simple = getStringValue(n)
44
+ if (simple !== null) {
45
+ const loc = n.loc as { start: { line: number } } | undefined
46
+ results.push({ value: simple, line: lineTransformer(loc?.start.line ?? 1) })
47
+ return
48
+ }
49
+ if (n.type === 'ConditionalExpression') {
50
+ collect(n.consequent as BabelNode)
51
+ collect(n.alternate as BabelNode)
52
+ return
53
+ }
54
+ if (n.type === 'LogicalExpression') {
55
+ collect(n.left as BabelNode)
56
+ collect(n.right as BabelNode)
57
+ }
58
+ }
59
+ }
60
+
24
61
  // ============================================================================
25
62
  // Object and Array Extraction
26
63
  // ============================================================================
@@ -21,6 +21,9 @@ let searchIndexInitialized = false
21
21
  /** Pre-built reverse index: normalizedText → SourceLocation[] (collection data files) */
22
22
  let collectionTextIndex: Map<string, SourceLocation[]> | null = null
23
23
 
24
+ /** Lazy reverse index on i18n entries: translationKey → SearchIndexEntry[]. Rebuilt on demand after any mutation. */
25
+ let translationKeyIndex: Map<string, SearchIndexEntry[]> | null = null
26
+
24
27
  /** Files that changed since last indexing — tracked by Vite watcher */
25
28
  const dirtyFiles = new Set<string>()
26
29
 
@@ -58,6 +61,24 @@ export function setSearchIndexInitialized(value: boolean): void {
58
61
 
59
62
  export function addToTextSearchIndex(entry: SearchIndexEntry): void {
60
63
  textSearchIndex.push(entry)
64
+ translationKeyIndex = null
65
+ }
66
+
67
+ /**
68
+ * Reverse-index i18n entries by their dictionary key, rebuilt lazily after
69
+ * mutations. Callers should treat the returned Map as read-only.
70
+ */
71
+ export function getTranslationKeyIndex(): Map<string, SearchIndexEntry[]> {
72
+ if (translationKeyIndex) return translationKeyIndex
73
+ const map = new Map<string, SearchIndexEntry[]>()
74
+ for (const entry of textSearchIndex) {
75
+ if (!entry.translationKey) continue
76
+ const existing = map.get(entry.translationKey)
77
+ if (existing) existing.push(entry)
78
+ else map.set(entry.translationKey, [entry])
79
+ }
80
+ translationKeyIndex = map
81
+ return map
61
82
  }
62
83
 
63
84
  export function addToImageSearchIndex(entry: ImageIndexEntry): void {
@@ -102,6 +123,7 @@ export function clearDirtyFiles(): void {
102
123
  export function removeFileFromIndexes(relFile: string): void {
103
124
  filterInPlace(textSearchIndex, (e) => e.file !== relFile)
104
125
  filterInPlace(imageSearchIndex, (e) => e.file !== relFile)
126
+ translationKeyIndex = null
105
127
  }
106
128
 
107
129
  /** Remove non-matching elements in-place (single pass, no per-element splice). */
@@ -132,4 +154,5 @@ export function clearSourceFinderCache(): void {
132
154
  imageSearchIndex.length = 0
133
155
  searchIndexInitialized = false
134
156
  collectionTextIndex = null
157
+ translationKeyIndex = null
135
158
  }
@@ -15,6 +15,7 @@ import {
15
15
  getImageSearchIndex,
16
16
  getMarkdownFileCache,
17
17
  getTextSearchIndex,
18
+ getTranslationKeyIndex,
18
19
  isSearchIndexInitialized,
19
20
  removeFileFromIndexes,
20
21
  setCollectionTextIndex,
@@ -127,6 +128,10 @@ async function doInitializeSearchIndex(): Promise<void> {
127
128
  // Index image-like values from content collection data files (JSON/YAML)
128
129
  await indexContentCollectionImages()
129
130
 
131
+ // Index text values from translation dictionary files (JSON) under i18n/locales folders.
132
+ // Enables lookups for `{t(locale, 'key')}`-rendered content whose text lives in JSON.
133
+ await indexTranslationFiles()
134
+
130
135
  setSearchIndexInitialized(true)
131
136
  }
132
137
 
@@ -192,6 +197,9 @@ async function doReindexDirtyFiles(): Promise<void> {
192
197
  const content = await fs.readFile(absPath, 'utf-8')
193
198
  if (absPath.endsWith('.json')) {
194
199
  indexJsonImages(content, relFile)
200
+ if (isTranslationFilePath(absPath)) {
201
+ indexJsonTextValues(content, relFile)
202
+ }
195
203
  } else if (absPath.endsWith('.yaml') || absPath.endsWith('.yml')) {
196
204
  indexYamlImages(content, relFile)
197
205
  } else if (absPath.endsWith('.md') || absPath.endsWith('.mdx')) {
@@ -743,19 +751,269 @@ function indexYamlLikeLines(lines: string[], relFile: string, lineOffset: number
743
751
  }
744
752
 
745
753
  // ============================================================================
746
- // Index Lookup
754
+ // Translation Dictionary Indexing (i18n JSON files)
747
755
  // ============================================================================
748
756
 
757
+ /** Directory names conventionally used for translation dictionaries */
758
+ const I18N_DIR_NAMES = new Set(['i18n', 'locales', 'locale', 'translations', 'dictionaries'])
759
+
760
+ /** Tag marker for entries that have no single originating template tag */
761
+ const TRANSLATION_TAG_MARKER = '*'
762
+
749
763
  /**
750
- * Fast text lookup using pre-built index
764
+ * Return true if `absPath` lives under a directory commonly used for
765
+ * translation dictionaries (i18n, locales, translations, dictionaries).
751
766
  */
752
- export function findInTextIndex(textContent: string, tag: string): SourceLocation | undefined {
753
- const normalizedSearch = normalizeText(textContent)
767
+ export function isTranslationFilePath(absPath: string): boolean {
768
+ if (!absPath.endsWith('.json')) return false
769
+ const segments = absPath.split(path.sep)
770
+ return segments.some((segment) => I18N_DIR_NAMES.has(segment.toLowerCase()))
771
+ }
772
+
773
+ /**
774
+ * Index text string values from JSON dictionaries under `src/i18n`, `src/locales`, etc.
775
+ * These cover templates that render strings through helpers like `{t(locale, 'key')}` —
776
+ * the rendered text lives in a JSON file rather than the template itself, so without
777
+ * this index the source finder has no way to associate the two.
778
+ */
779
+ async function indexTranslationFiles(): Promise<void> {
780
+ const srcDir = path.join(getProjectRoot(), 'src')
781
+ const translationFiles: string[] = []
782
+ await collectTranslationFiles(srcDir, translationFiles)
783
+
784
+ await Promise.all(translationFiles.map(async (filePath) => {
785
+ try {
786
+ const content = await fs.readFile(filePath, 'utf-8')
787
+ const relFile = path.relative(getProjectRoot(), filePath)
788
+ indexJsonTextValues(content, relFile)
789
+ } catch {
790
+ // Skip unreadable files
791
+ }
792
+ }))
793
+ }
794
+
795
+ /**
796
+ * Walk `dir` and push any .json files that live inside a conventional i18n folder
797
+ * (at any depth) into `results`.
798
+ */
799
+ async function collectTranslationFiles(dir: string, results: string[]): Promise<void> {
800
+ try {
801
+ const entries = await fs.readdir(dir, { withFileTypes: true })
802
+ await Promise.all(entries.map(async (entry) => {
803
+ const fullPath = path.join(dir, entry.name)
804
+ if (entry.isDirectory()) {
805
+ if (I18N_DIR_NAMES.has(entry.name.toLowerCase())) {
806
+ await collectJsonFilesRecursive(fullPath, results)
807
+ } else if (entry.name !== 'node_modules' && !entry.name.startsWith('.')) {
808
+ await collectTranslationFiles(fullPath, results)
809
+ }
810
+ }
811
+ }))
812
+ } catch {
813
+ // Directory doesn't exist
814
+ }
815
+ }
816
+
817
+ async function collectJsonFilesRecursive(dir: string, results: string[]): Promise<void> {
818
+ try {
819
+ const entries = await fs.readdir(dir, { withFileTypes: true })
820
+ await Promise.all(entries.map(async (entry) => {
821
+ const fullPath = path.join(dir, entry.name)
822
+ if (entry.isDirectory()) {
823
+ await collectJsonFilesRecursive(fullPath, results)
824
+ } else if (entry.isFile() && entry.name.endsWith('.json')) {
825
+ results.push(fullPath)
826
+ }
827
+ }))
828
+ } catch {
829
+ // Directory doesn't exist
830
+ }
831
+ }
832
+
833
+ /** JSON escape sequences that decode to a single character */
834
+ const JSON_ESCAPE_MAP: Record<string, string> = {
835
+ '\\"': '"',
836
+ '\\\\': '\\',
837
+ '\\/': '/',
838
+ '\\n': '\n',
839
+ '\\r': '\r',
840
+ '\\t': '\t',
841
+ '\\b': '\b',
842
+ '\\f': '\f',
843
+ }
844
+
845
+ function unescapeJsonString(raw: string): string {
846
+ return raw.replace(/\\["\\/nrtbf]|\\u[0-9a-fA-F]{4}/g, (match) => {
847
+ if (match.startsWith('\\u')) {
848
+ return String.fromCharCode(parseInt(match.slice(2), 16))
849
+ }
850
+ return JSON_ESCAPE_MAP[match] ?? match
851
+ })
852
+ }
853
+
854
+ /**
855
+ * Find a template element with the given tag whose descendant expression
856
+ * references the given string literal (e.g. a translation key passed to
857
+ * `t(locale, 'nav.key')` or an object lookup like `cs['nav.key']`).
858
+ *
859
+ * Used to recover the template source location for an element whose rendered
860
+ * text came from a translation dictionary — class/attribute edits need to
861
+ * point at the template even when text edits point at the JSON.
862
+ */
863
+ export async function findTemplateElementUsingStringLiteral(
864
+ stringLiteral: string,
865
+ tag: string,
866
+ ): Promise<{ file: string; line: number; lines: string[] } | undefined> {
867
+ const srcDir = path.join(getProjectRoot(), 'src')
868
+ const searchDirs = [
869
+ path.join(srcDir, 'components'),
870
+ path.join(srcDir, 'layouts'),
871
+ path.join(srcDir, 'pages'),
872
+ ]
873
+
874
+ for (const dir of searchDirs) {
875
+ const result = await searchDirForStringLiteral(dir, stringLiteral, tag)
876
+ if (result) return result
877
+ }
878
+ return undefined
879
+ }
880
+
881
+ async function searchDirForStringLiteral(
882
+ dir: string,
883
+ stringLiteral: string,
884
+ tag: string,
885
+ ): Promise<{ file: string; line: number; lines: string[] } | undefined> {
886
+ try {
887
+ const entries = await fs.readdir(dir, { withFileTypes: true })
888
+ for (const entry of entries) {
889
+ const fullPath = path.join(dir, entry.name)
890
+ if (entry.isDirectory()) {
891
+ const result = await searchDirForStringLiteral(fullPath, stringLiteral, tag)
892
+ if (result) return result
893
+ } else if (entry.isFile() && entry.name.endsWith('.astro')) {
894
+ const cached = await getCachedParsedFile(fullPath)
895
+ if (!cached) continue
896
+ const line = findElementLineUsingStringLiteral(cached.ast, stringLiteral, tag)
897
+ if (line !== undefined) {
898
+ return {
899
+ file: path.relative(getProjectRoot(), fullPath),
900
+ line,
901
+ lines: cached.lines,
902
+ }
903
+ }
904
+ }
905
+ }
906
+ } catch {
907
+ // Directory doesn't exist
908
+ }
909
+ return undefined
910
+ }
911
+
912
+ function findElementLineUsingStringLiteral(
913
+ ast: AstroNode,
914
+ stringLiteral: string,
915
+ tag: string,
916
+ ): number | undefined {
754
917
  const tagLower = tag.toLowerCase()
755
- const index = getTextSearchIndex()
918
+ const quotedPatterns = [`'${stringLiteral}'`, `"${stringLiteral}"`, `\`${stringLiteral}\``]
919
+
920
+ let result: number | undefined
921
+
922
+ function visit(node: AstroNode) {
923
+ if (result !== undefined) return
924
+ if ((node.type === 'element' || node.type === 'component') && node.name.toLowerCase() === tagLower) {
925
+ const elemNode = node as ElementNode | ComponentNode
926
+ const exprText = getExpressionText(elemNode)
927
+ if (exprText && quotedPatterns.some((p) => exprText.includes(p))) {
928
+ result = elemNode.position?.start.line
929
+ return
930
+ }
931
+ }
932
+ if ('children' in node && Array.isArray(node.children)) {
933
+ for (const child of node.children) {
934
+ if (result !== undefined) break
935
+ visit(child)
936
+ }
937
+ }
938
+ }
939
+
940
+ visit(ast)
941
+ return result
942
+ }
943
+
944
+ /** Collect the combined text of every expression descendant of `node`. */
945
+ function getExpressionText(node: AstroNode): string {
946
+ let text = ''
947
+ function visit(n: AstroNode) {
948
+ if (n.type === 'expression') {
949
+ text += getTextContent(n)
950
+ return
951
+ }
952
+ if ('children' in n && Array.isArray(n.children)) {
953
+ for (const child of n.children) visit(child)
954
+ }
955
+ }
956
+ visit(node)
957
+ return text
958
+ }
959
+
960
+ /**
961
+ * Extract the JSON dictionary key from a translation-file snippet.
962
+ * Expects a line shaped like ` "nav.whatsHappening": "Co se děje v EduArt?",`
963
+ * and returns `"nav.whatsHappening"`.
964
+ */
965
+ export function extractTranslationKeyFromSnippet(snippet: string): string | undefined {
966
+ const match = snippet.match(/^\s*"((?:[^"\\]|\\.)*)"\s*:/)
967
+ return match?.[1]
968
+ }
756
969
 
757
- // Helper to build SourceLocation from a text index entry
758
- const toLocation = (entry: SearchIndexEntry): SourceLocation => ({
970
+ /**
971
+ * Extract all string key/value pairs from a JSON file and add each one to the
972
+ * text search index with a wildcard tag. The dictionary key is retained on
973
+ * each entry so a template expression like `{t(locale, 'nav.prague4')}` can
974
+ * resolve directly to the JSON line via the literal key. Non-user-facing
975
+ * values (paths, URLs, image assets, single characters) are skipped.
976
+ */
977
+ export function indexJsonTextValues(content: string, relFile: string): void {
978
+ const lines = content.split('\n')
979
+ // Match `"key": "value"` pairs on a single line, handling backslash escapes.
980
+ // Nested objects aren't matched (their `"key":` is followed by `{`, not `"`).
981
+ const pattern = /"((?:[^"\\]|\\.)*)"\s*:\s*"((?:[^"\\]|\\.)*)"/g
982
+ for (let i = 0; i < lines.length; i++) {
983
+ const line = lines[i]!
984
+ pattern.lastIndex = 0
985
+ let match: RegExpExecArray | null
986
+ while ((match = pattern.exec(line)) !== null) {
987
+ const key = unescapeJsonString(match[1]!)
988
+ const value = unescapeJsonString(match[2]!)
989
+ if (!value) continue
990
+ if (IMAGE_EXTENSIONS.test(value)) continue
991
+ if (value.startsWith('/') || value.startsWith('./') || value.startsWith('../')) continue
992
+ if (/^https?:\/\//.test(value)) continue
993
+
994
+ const normalized = normalizeText(value)
995
+ if (normalized.length < 2) continue
996
+
997
+ addToTextSearchIndex({
998
+ file: relFile,
999
+ line: i + 1,
1000
+ snippet: line,
1001
+ type: 'static',
1002
+ normalizedText: normalized,
1003
+ tag: TRANSLATION_TAG_MARKER,
1004
+ translationKey: key,
1005
+ })
1006
+ }
1007
+ }
1008
+ }
1009
+
1010
+ // ============================================================================
1011
+ // Index Lookup
1012
+ // ============================================================================
1013
+
1014
+ /** Helper to build SourceLocation from a text index entry */
1015
+ function textEntryToLocation(entry: SearchIndexEntry): SourceLocation {
1016
+ return {
759
1017
  file: entry.file,
760
1018
  line: entry.line,
761
1019
  snippet: entry.snippet,
@@ -763,17 +1021,50 @@ export function findInTextIndex(textContent: string, tag: string): SourceLocatio
763
1021
  type: entry.type,
764
1022
  variableName: entry.variableName,
765
1023
  definitionLine: entry.definitionLine,
766
- })
1024
+ }
1025
+ }
1026
+
1027
+ /**
1028
+ * Look up an i18n dictionary entry by its literal key (e.g. `nav.prague4`),
1029
+ * preferring the match whose value equals `normalizedText` so the right locale
1030
+ * wins when multiple dictionaries share the same key.
1031
+ */
1032
+ export function findTranslationByKeyAndText(key: string, normalizedText: string): SourceLocation | undefined {
1033
+ const entries = getTranslationKeyIndex().get(key)
1034
+ if (!entries) return undefined
1035
+ let fallback: SourceLocation | undefined
1036
+ for (const entry of entries) {
1037
+ if (entry.normalizedText === normalizedText) return textEntryToLocation(entry)
1038
+ fallback ??= textEntryToLocation(entry)
1039
+ }
1040
+ return fallback
1041
+ }
1042
+
1043
+ /**
1044
+ * Fast text lookup using pre-built index
1045
+ */
1046
+ export function findInTextIndex(textContent: string, tag: string): SourceLocation | undefined {
1047
+ const normalizedSearch = normalizeText(textContent)
1048
+ const tagLower = tag.toLowerCase()
1049
+ const index = getTextSearchIndex()
767
1050
 
768
- // First try exact match with same tag prefer collection data files
1051
+ // Single pass for exact matches: collect the best same-tag template hit
1052
+ // *and* any i18n dictionary hit at once. A JSON dictionary entry is an
1053
+ // authoritative translatable signal, so it beats a non-collection
1054
+ // template match (which is often a coincidental same-text element).
769
1055
  let bestMatch: SourceLocation | undefined
1056
+ let translationHit: SourceLocation | undefined
770
1057
  for (const entry of index) {
771
- if (entry.tag === tagLower && entry.normalizedText === normalizedSearch) {
772
- const result = toLocation(entry)
1058
+ if (entry.normalizedText !== normalizedSearch) continue
1059
+ if (entry.tag === tagLower) {
1060
+ const result = textEntryToLocation(entry)
773
1061
  if (isCollectionFile(entry.file)) return result
774
1062
  bestMatch ??= result
1063
+ } else if (entry.tag === TRANSLATION_TAG_MARKER) {
1064
+ translationHit ??= textEntryToLocation(entry)
775
1065
  }
776
1066
  }
1067
+ if (translationHit) return translationHit
777
1068
  if (bestMatch) return bestMatch
778
1069
 
779
1070
  // Then try partial match for longer text — prefer collection data files
@@ -781,7 +1072,7 @@ export function findInTextIndex(textContent: string, tag: string): SourceLocatio
781
1072
  const textPreview = normalizedSearch.slice(0, Math.min(30, normalizedSearch.length))
782
1073
  for (const entry of index) {
783
1074
  if (entry.tag === tagLower && entry.normalizedText.includes(textPreview)) {
784
- const result = toLocation(entry)
1075
+ const result = textEntryToLocation(entry)
785
1076
  if (isCollectionFile(entry.file)) return result
786
1077
  bestMatch ??= result
787
1078
  }
@@ -792,7 +1083,7 @@ export function findInTextIndex(textContent: string, tag: string): SourceLocatio
792
1083
  // Try any tag match — prefer collection data files
793
1084
  for (const entry of index) {
794
1085
  if (entry.normalizedText === normalizedSearch) {
795
- const result = toLocation(entry)
1086
+ const result = textEntryToLocation(entry)
796
1087
  if (isCollectionFile(entry.file)) return result
797
1088
  bestMatch ??= result
798
1089
  }