@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.
- package/dist/editor.js +9738 -9615
- package/package.json +1 -1
- package/src/collection-scanner.ts +87 -25
- package/src/editor/components/image-overlay.tsx +3 -14
- package/src/editor/components/plain-text-chip-utils.ts +14 -0
- package/src/editor/components/plain-text-chip.tsx +61 -0
- package/src/editor/components/text-style-toolbar.tsx +2 -15
- package/src/editor/constants.ts +3 -0
- package/src/editor/dom.ts +40 -0
- package/src/editor/editor.ts +117 -5
- package/src/editor/index.tsx +9 -0
- package/src/field-types.ts +15 -1
- package/src/handlers/source-writer.ts +75 -21
- package/src/source-finder/ast-extractors.ts +37 -0
- package/src/source-finder/cache.ts +23 -0
- package/src/source-finder/search-index.ts +304 -13
- package/src/source-finder/snippet-utils.ts +179 -2
- package/src/source-finder/types.ts +3 -0
- package/src/source-finder/variable-extraction.ts +8 -1
|
@@ -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="${
|
|
220
|
-
const srcPatternSingle = new RegExp(`src='${
|
|
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
|
-
|
|
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
|
-
`(${
|
|
552
|
+
`(${escapeRegex(attributeName)}\\s*=\\s*)"(${escapeRegex(attrOldValue)})"`,
|
|
521
553
|
)
|
|
522
554
|
const singleQuotePattern = new RegExp(
|
|
523
|
-
`(${
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
`(${
|
|
605
|
+
`(${escapeRegex(attributeName)}\\s*=\\s*)"(${escapeRegex(attrOldValue)})"`,
|
|
551
606
|
)
|
|
552
607
|
const singleQuotePattern = new RegExp(
|
|
553
|
-
`(${
|
|
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
|
-
|
|
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
|
["'", '''],
|
|
725
783
|
]
|
|
726
784
|
|
|
727
|
-
let pattern =
|
|
785
|
+
let pattern = escapeRegex(decodedText)
|
|
728
786
|
for (const [char, entity] of entityMap) {
|
|
729
|
-
const escapedChar =
|
|
730
|
-
const escapedEntity =
|
|
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) =>
|
|
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
|
-
//
|
|
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
|
-
*
|
|
764
|
+
* Return true if `absPath` lives under a directory commonly used for
|
|
765
|
+
* translation dictionaries (i18n, locales, translations, dictionaries).
|
|
751
766
|
*/
|
|
752
|
-
export function
|
|
753
|
-
|
|
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
|
|
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
|
-
|
|
758
|
-
|
|
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
|
-
//
|
|
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.
|
|
772
|
-
|
|
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 =
|
|
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 =
|
|
1086
|
+
const result = textEntryToLocation(entry)
|
|
796
1087
|
if (isCollectionFile(entry.file)) return result
|
|
797
1088
|
bestMatch ??= result
|
|
798
1089
|
}
|