@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
|
@@ -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
|
|
@@ -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
|