@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.
- package/dist/editor.js +11397 -11351
- package/package.json +1 -1
- package/src/collection-scanner.ts +87 -25
- package/src/editor/components/attribute-editor.tsx +2 -10
- package/src/editor/components/bg-image-overlay.tsx +2 -10
- package/src/editor/components/collections-browser.tsx +5 -13
- package/src/editor/components/color-toolbar.tsx +2 -9
- package/src/editor/components/confirm-dialog.tsx +4 -12
- package/src/editor/components/create-page-modal.tsx +1 -9
- package/src/editor/components/fields.tsx +134 -116
- package/src/editor/components/image-overlay.tsx +3 -14
- package/src/editor/components/link-edit-popover.tsx +3 -6
- package/src/editor/components/markdown-editor-overlay.tsx +31 -37
- package/src/editor/components/markdown-inline-editor.tsx +2 -1
- package/src/editor/components/mdx-component-picker.tsx +3 -6
- package/src/editor/components/media-library.tsx +15 -37
- package/src/editor/components/modal-shell.tsx +34 -5
- 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/prop-editor.tsx +67 -68
- package/src/editor/components/reference-picker.tsx +6 -24
- package/src/editor/components/seo-editor.tsx +4 -10
- package/src/editor/components/spinner.tsx +17 -0
- package/src/editor/components/text-style-toolbar.tsx +2 -15
- package/src/editor/components/toolbar.tsx +2 -1
- package/src/editor/constants.ts +33 -0
- package/src/editor/dom.ts +37 -0
- package/src/editor/editor.ts +90 -5
- package/src/editor/hooks/index.ts +4 -0
- package/src/editor/hooks/useClickOutsideEscape.ts +43 -0
- package/src/editor/hooks/useSearchFilter.ts +21 -0
- package/src/editor/index.tsx +9 -0
- package/src/handlers/source-writer.ts +75 -21
- package/src/html-processor.ts +75 -94
- package/src/index.ts +5 -0
- package/src/rehype-cms-marker.ts +15 -0
- 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
|
@@ -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
|
}
|
|
@@ -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 {
|
|
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
|
-
//
|
|
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
|