@nuasite/cms 0.29.0 → 0.31.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.
Files changed (42) hide show
  1. package/dist/editor.js +11397 -11351
  2. package/package.json +1 -1
  3. package/src/collection-scanner.ts +87 -25
  4. package/src/editor/components/attribute-editor.tsx +2 -10
  5. package/src/editor/components/bg-image-overlay.tsx +2 -10
  6. package/src/editor/components/collections-browser.tsx +5 -13
  7. package/src/editor/components/color-toolbar.tsx +2 -9
  8. package/src/editor/components/confirm-dialog.tsx +4 -12
  9. package/src/editor/components/create-page-modal.tsx +1 -9
  10. package/src/editor/components/fields.tsx +134 -116
  11. package/src/editor/components/image-overlay.tsx +3 -14
  12. package/src/editor/components/link-edit-popover.tsx +3 -6
  13. package/src/editor/components/markdown-editor-overlay.tsx +31 -37
  14. package/src/editor/components/markdown-inline-editor.tsx +2 -1
  15. package/src/editor/components/mdx-component-picker.tsx +3 -6
  16. package/src/editor/components/media-library.tsx +15 -37
  17. package/src/editor/components/modal-shell.tsx +34 -5
  18. package/src/editor/components/plain-text-chip-utils.ts +14 -0
  19. package/src/editor/components/plain-text-chip.tsx +61 -0
  20. package/src/editor/components/prop-editor.tsx +67 -68
  21. package/src/editor/components/reference-picker.tsx +6 -24
  22. package/src/editor/components/seo-editor.tsx +4 -10
  23. package/src/editor/components/spinner.tsx +17 -0
  24. package/src/editor/components/text-style-toolbar.tsx +2 -15
  25. package/src/editor/components/toolbar.tsx +2 -1
  26. package/src/editor/constants.ts +33 -0
  27. package/src/editor/dom.ts +37 -0
  28. package/src/editor/editor.ts +90 -5
  29. package/src/editor/hooks/index.ts +4 -0
  30. package/src/editor/hooks/useClickOutsideEscape.ts +43 -0
  31. package/src/editor/hooks/useSearchFilter.ts +21 -0
  32. package/src/editor/index.tsx +9 -0
  33. package/src/handlers/source-writer.ts +75 -21
  34. package/src/html-processor.ts +75 -94
  35. package/src/index.ts +5 -0
  36. package/src/rehype-cms-marker.ts +15 -0
  37. package/src/source-finder/ast-extractors.ts +37 -0
  38. package/src/source-finder/cache.ts +23 -0
  39. package/src/source-finder/search-index.ts +304 -13
  40. package/src/source-finder/snippet-utils.ts +179 -2
  41. package/src/source-finder/types.ts +3 -0
  42. package/src/source-finder/variable-extraction.ts +8 -1
@@ -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
  }
@@ -16,7 +16,13 @@ import {
16
16
  } from './collection-finder'
17
17
  import { findAttributeSourceLocation, searchForExpressionProp, searchForPropInParents } from './cross-file-tracker'
18
18
  import { findImageElementNearLine, findImageSourceLocation } from './image-finder'
19
- import { initializeSearchIndex } from './search-index'
19
+ import {
20
+ extractTranslationKeyFromSnippet,
21
+ findInTextIndex,
22
+ findTemplateElementUsingStringLiteral,
23
+ findTranslationByKeyAndText,
24
+ initializeSearchIndex,
25
+ } from './search-index'
20
26
  import type { CachedParsedFile, ImageMatch, SourceLocation } from './types'
21
27
 
22
28
  // ============================================================================
@@ -287,7 +293,18 @@ export async function updateAttributeSources(
287
293
  }
288
294
  }
289
295
 
290
- // Couldn't resolve - return without source info
296
+ // Prefer a template-level miss over falling back to the entry
297
+ // sourcePath, which may be an unrelated file (e.g. an i18n JSON).
298
+ if (sourceFilePath) {
299
+ const attrLine = findAttributeLineInSnippet(attrName, snippetLines, openingTagStartLine)
300
+ return [attrName, {
301
+ value,
302
+ sourcePath: sourceFilePath,
303
+ sourceLine: attrLine,
304
+ sourceSnippet: (attrLine && sourceLines) ? sourceLines[attrLine - 1] || '' : undefined,
305
+ }] as const
306
+ }
307
+
291
308
  return [attrName, { value }] as const
292
309
  }
293
310
 
@@ -466,6 +483,138 @@ export async function extractSourceSnippet(
466
483
  // Manifest Enhancement
467
484
  // ============================================================================
468
485
 
486
+ /**
487
+ * Build the manifest entry produced when rendered text resolved to a
488
+ * translation-dictionary file (e.g. `src/i18n/cs.json`).
489
+ *
490
+ * Text edits target the JSON entry (`sourcePath`/`sourceLine`/`sourceSnippet`),
491
+ * while `attributes.*` and `colorClasses.*` keep pointing at the template's
492
+ * `<tag>` — those edits belong on the element, not the dictionary.
493
+ */
494
+ async function applyTranslationSource(
495
+ entry: ManifestEntry,
496
+ indexHit: SourceLocation,
497
+ attributes: ManifestEntry['attributes'],
498
+ colorClasses: ManifestEntry['colorClasses'],
499
+ ): Promise<ManifestEntry> {
500
+ const hitSnippet = indexHit.snippet ?? ''
501
+ let resolvedAttributes = attributes
502
+ let resolvedColorClasses = colorClasses
503
+
504
+ // When the hit comes from a JSON dictionary, look up the template element
505
+ // that references the translation key so attr/class edits can target it.
506
+ const needsTemplateLookup = indexHit.file.endsWith('.json')
507
+ && ((attributes && !hasAnySourcePath(attributes)) || (colorClasses && !hasAnySourcePath(colorClasses)))
508
+
509
+ if (needsTemplateLookup && entry.tag) {
510
+ const translationKey = extractTranslationKeyFromSnippet(hitSnippet)
511
+ if (translationKey) {
512
+ const templateLoc = await findTemplateElementUsingStringLiteral(translationKey, entry.tag)
513
+ if (templateLoc) {
514
+ const openingTagInfo = extractOpeningTagWithLine(templateLoc.lines, templateLoc.line - 1, entry.tag)
515
+ if (openingTagInfo) {
516
+ const openingStartLine = openingTagInfo.startLine + 1
517
+ if (attributes) {
518
+ resolvedAttributes = await updateAttributeSources(
519
+ openingTagInfo.snippet,
520
+ attributes,
521
+ templateLoc.file,
522
+ openingStartLine,
523
+ templateLoc.lines,
524
+ )
525
+ }
526
+ if (colorClasses) {
527
+ resolvedColorClasses = updateColorClassSources(
528
+ openingTagInfo.snippet,
529
+ colorClasses,
530
+ templateLoc.file,
531
+ openingStartLine,
532
+ templateLoc.lines,
533
+ )
534
+ }
535
+ }
536
+ }
537
+ }
538
+ }
539
+
540
+ return {
541
+ ...entry,
542
+ sourcePath: indexHit.file,
543
+ sourceLine: indexHit.line,
544
+ sourceSnippet: hitSnippet,
545
+ variableName: indexHit.variableName,
546
+ allowStyling: false,
547
+ attributes: resolvedAttributes,
548
+ colorClasses: resolvedColorClasses,
549
+ sourceHash: generateSourceHash(hitSnippet || entry.text || ''),
550
+ }
551
+ }
552
+
553
+ /** True when any of the attribute/colorClass values already carries a sourcePath. */
554
+ function hasAnySourcePath(bag: Record<string, { sourcePath?: string }>): boolean {
555
+ for (const key in bag) {
556
+ if (bag[key]?.sourcePath) return true
557
+ }
558
+ return false
559
+ }
560
+
561
+ /**
562
+ * Extract string literals from every `{…}` expression in an Astro snippet.
563
+ * Intended to recover translation keys from patterns like:
564
+ * {t(locale, 'nav.prague4')}
565
+ * {cs['nav.prague4']}
566
+ * {dict.nav['prague4']}
567
+ *
568
+ * Nested braces inside template strings can fool the naive brace tracker but
569
+ * not in ways that matter here — we only care about literal string arguments.
570
+ */
571
+ export function extractStringLiteralsFromExpressions(snippet: string): string[] {
572
+ const literals: string[] = []
573
+ let depth = 0
574
+ let exprStart = -1
575
+ for (let i = 0; i < snippet.length; i++) {
576
+ const ch = snippet[i]
577
+ if (ch === '{') {
578
+ if (depth === 0) exprStart = i + 1
579
+ depth++
580
+ } else if (ch === '}') {
581
+ depth--
582
+ if (depth === 0 && exprStart >= 0) {
583
+ const expr = snippet.slice(exprStart, i)
584
+ const pattern = /'((?:[^'\\]|\\.)+)'|"((?:[^"\\]|\\.)+)"|`((?:[^`\\$]|\\.)+)`/g
585
+ let m: RegExpExecArray | null
586
+ while ((m = pattern.exec(expr)) !== null) {
587
+ const s = m[1] ?? m[2] ?? m[3]
588
+ if (s) literals.push(s)
589
+ }
590
+ exprStart = -1
591
+ }
592
+ }
593
+ }
594
+ return literals
595
+ }
596
+
597
+ /**
598
+ * When the template expression references a literal translation key (e.g.
599
+ * `{t(locale, 'nav.prague4')}`), look the key up directly in the i18n index
600
+ * and return the JSON location. Falls back to value-based heuristics only via
601
+ * the caller's other branches — this path is only taken when the key match
602
+ * is unambiguous, which is the authoritative signal from the template.
603
+ */
604
+ function resolveTranslationKeyFromSnippet(
605
+ snippet: string,
606
+ entryText: string,
607
+ ): SourceLocation | undefined {
608
+ const literals = extractStringLiteralsFromExpressions(snippet)
609
+ if (literals.length === 0) return undefined
610
+ const normalizedText = normalizeText(entryText)
611
+ for (const literal of literals) {
612
+ const hit = findTranslationByKeyAndText(literal, normalizedText)
613
+ if (hit) return hit
614
+ }
615
+ return undefined
616
+ }
617
+
469
618
  /**
470
619
  * Enhance manifest entries with actual source snippets from source files.
471
620
  * This reads the source files and extracts the innerHTML at the specified locations.
@@ -674,6 +823,18 @@ export async function enhanceManifestWithSourceSnippets(
674
823
  }
675
824
  }
676
825
 
826
+ // Missing source info — try the text search index as a last resort.
827
+ // Templates that render text through helpers (e.g. `{t(locale, 'key')}`)
828
+ // don't get resolved to the originating i18n JSON in the marking phase,
829
+ // so the entry arrives here without a sourcePath/sourceLine.
830
+ if (!entry.sourceSnippet && entry.text?.trim() && entry.tag && (!entry.sourcePath || !entry.sourceLine)) {
831
+ const indexHit = findInTextIndex(entry.text.trim(), entry.tag)
832
+ if (indexHit) {
833
+ const resolved = await applyTranslationSource(entry, indexHit, entry.attributes, entry.colorClasses)
834
+ return [id, resolved] as const
835
+ }
836
+ }
837
+
677
838
  // Skip if already has sourceSnippet or missing source info
678
839
  if (entry.sourceSnippet || !entry.sourcePath || !entry.sourceLine || !entry.tag) {
679
840
  return [id, entry] as const
@@ -828,6 +989,14 @@ export async function enhanceManifestWithSourceSnippets(
828
989
  }
829
990
  }
830
991
 
992
+ // An explicit `{t(locale, 'key')}`-style reference is a stronger
993
+ // signal than value-based collection/text-index matches below.
994
+ const i18nKeySource = resolveTranslationKeyFromSnippet(sourceSnippet, trimmedText)
995
+ if (i18nKeySource) {
996
+ const resolved = await applyTranslationSource(entry, i18nKeySource, attributes, colorClasses)
997
+ return [id, resolved] as const
998
+ }
999
+
831
1000
  // Search collection frontmatter — text rendered on listing pages
832
1001
  // from collection entries (e.g. {post.data.title}) won't be found
833
1002
  // through AST or prop lookups since the value lives in a .md file
@@ -844,6 +1013,14 @@ export async function enhanceManifestWithSourceSnippets(
844
1013
  ] as const
845
1014
  }
846
1015
  }
1016
+
1017
+ // Last resort — consult the text index (covers i18n JSON dictionaries
1018
+ // and any other indexed text that shares no tag with the rendered element).
1019
+ const indexHit = findInTextIndex(trimmedText, entry.tag)
1020
+ if (indexHit && indexHit.file !== entry.sourcePath) {
1021
+ const resolved = await applyTranslationSource(entry, indexHit, attributes, colorClasses)
1022
+ return [id, resolved] as const
1023
+ }
847
1024
  }
848
1025
 
849
1026
  // Original static content path