@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.
@@ -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
@@ -56,6 +56,9 @@ export interface SearchIndexEntry {
56
56
  definitionLine?: number
57
57
  normalizedText: string
58
58
  tag: string
59
+ /** For i18n JSON entries: the dictionary key (e.g., `nav.prague4`). Enables
60
+ * direct lookups when a template expression references the key by literal. */
61
+ translationKey?: string
59
62
  }
60
63
 
61
64
  export interface ImageIndexEntry {
@@ -2,7 +2,7 @@ import { parse as parseBabel } from '@babel/parser'
2
2
  import fs from 'node:fs/promises'
3
3
  import path from 'node:path'
4
4
 
5
- import { extractArrayElements, extractObjectProperties, getStringValue } from './ast-extractors'
5
+ import { extractArrayElements, extractObjectProperties, extractPossibleStringValues, getStringValue } from './ast-extractors'
6
6
  import type { BabelFile, BabelNode, ImportInfo, VariableDefinition } from './types'
7
7
  import { createFrontmatterLineTransformer, identityLine } from './types'
8
8
 
@@ -37,6 +37,13 @@ export function extractVariableDefinitions(ast: BabelFile, frontmatterStartLine:
37
37
  const stringValue = getStringValue(init)
38
38
  if (stringValue !== null) {
39
39
  definitions.push({ name: varName, value: stringValue, line })
40
+ } else if (init.type === 'ConditionalExpression' || init.type === 'LogicalExpression') {
41
+ // One definition per reachable branch — each carries its own
42
+ // literal's line so edits route to the matching branch.
43
+ const branches = extractPossibleStringValues(init, lineTransformer)
44
+ for (const branch of branches) {
45
+ definitions.push({ name: varName, value: branch.value, line: branch.line })
46
+ }
40
47
  }
41
48
 
42
49
  // Object expression - extract properties recursively