feat(magefile): bidirectional translation key validation
Extend the existing check:translations task so it now also reports
"dead" keys - entries present in pkg/i18n/lang/en.json but never
referenced by any i18n.T / i18n.TP call. Dynamic references (where
the key is a runtime value, e.g. from a struct field) are handled via
an allowlist of prefixes so they don't false-positive.
Add a new check:frontend-translations task that performs the same
bidirectional check against frontend/src/i18n/lang/en.json by scanning
.vue / .ts / .js files for $t, t, i18n.t, i18n.global.t, tc, $tc calls
and <i18n-t keypath="...">. Template literals with ${...} interpolation
contribute a usage prefix instead of a single key. String literals that
exactly match a known translation key (or template-literal prefixes
assigned to a variable) are also treated as usage hints, so keys stored
in arrays or built up programmatically aren't flagged as dead.
Register the new task in Check.All so `mage check` covers both.
This commit is contained in:
parent
5c7d2a5e7a
commit
0035be3c12
332
magefile.go
332
magefile.go
|
|
@ -35,6 +35,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
@ -664,12 +665,24 @@ func (Check) GotSwag(ctx context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Translations checks if all translation keys used in the code exist in the English translation file
|
// backendDynamicKeyPrefixes lists prefixes that are used dynamically (with a
|
||||||
|
// non-literal key argument) in the backend. Any translation key starting with
|
||||||
|
// one of these prefixes is considered "used" so the dead-key detection doesn't
|
||||||
|
// false-positive on them. Add a prefix here only after verifying the dynamic
|
||||||
|
// call site actually produces the expected keys.
|
||||||
|
var backendDynamicKeyPrefixes = []string{
|
||||||
|
// pkg/utils/humanize_duration.go uses i18n.TP(lang, chunk.key, ...) where
|
||||||
|
// chunk.key comes from a struct containing the time.since_* keys.
|
||||||
|
"time.since_",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translations checks if all translation keys used in the code exist in the
|
||||||
|
// English translation file, and conversely that no unused keys exist in the
|
||||||
|
// translation file.
|
||||||
func (Check) Translations() error {
|
func (Check) Translations() error {
|
||||||
mg.Deps(initVars)
|
mg.Deps(initVars)
|
||||||
fmt.Println("Checking for missing translation keys...")
|
fmt.Println("Checking backend translation keys...")
|
||||||
|
|
||||||
// Load translations from the English translation file
|
|
||||||
translationFile := "./pkg/i18n/lang/en.json"
|
translationFile := "./pkg/i18n/lang/en.json"
|
||||||
translations, err := loadTranslations(translationFile)
|
translations, err := loadTranslations(translationFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -678,36 +691,56 @@ func (Check) Translations() error {
|
||||||
|
|
||||||
fmt.Printf("Loaded %d translation keys from %s\n", len(translations), translationFile)
|
fmt.Printf("Loaded %d translation keys from %s\n", len(translations), translationFile)
|
||||||
|
|
||||||
// Extract keys from codebase
|
keys, err := walkBackendForTranslationKeys(".")
|
||||||
keys, err := walkCodebaseForTranslationKeys(".")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error walking codebase: %w", err)
|
return fmt.Errorf("error walking codebase: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Found %d translation keys in the codebase\n", len(keys))
|
fmt.Printf("Found %d translation key references in the backend codebase\n", len(keys))
|
||||||
|
|
||||||
// Check for missing keys
|
return reportTranslationResults("backend", translations, keys, backendDynamicKeyPrefixes)
|
||||||
missingKeys := make(map[string][]TranslationKey)
|
}
|
||||||
for _, key := range keys {
|
|
||||||
if !translations[key.Key] {
|
// FrontendTranslations checks that all translation keys used in the frontend
|
||||||
missingKeys[key.Key] = append(missingKeys[key.Key], key)
|
// exist in the frontend English translation file, and that no unused keys
|
||||||
|
// exist in the translation file.
|
||||||
|
func (Check) FrontendTranslations() error {
|
||||||
|
mg.Deps(initVars)
|
||||||
|
fmt.Println("Checking frontend translation keys...")
|
||||||
|
|
||||||
|
translationFile := "./frontend/src/i18n/lang/en.json"
|
||||||
|
translations, err := loadTranslations(translationFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error loading translations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Loaded %d translation keys from %s\n", len(translations), translationFile)
|
||||||
|
|
||||||
|
keys, prefixes, literals, err := walkFrontendForTranslationKeys("./frontend/src")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error walking frontend codebase: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Found %d translation key references in the frontend codebase\n", len(keys))
|
||||||
|
|
||||||
|
// Some keys are referenced indirectly – e.g. stored as string literals in
|
||||||
|
// arrays and looked up by index, or assigned to a variable in the form
|
||||||
|
// `const path = ` + "`error.${code}`". Any literal that matches a known
|
||||||
|
// translation key (or is a prefix of one) is treated as a usage hint, so
|
||||||
|
// those keys aren't flagged as dead.
|
||||||
|
for lit := range literals {
|
||||||
|
if translations[lit] {
|
||||||
|
keys = append(keys, TranslationKey{Key: lit, FilePath: "<frontend literal hint>", Line: 0})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Literals that look like a key prefix (end in ".") are kept as
|
||||||
|
// dynamic prefixes. This catches `const path = `error.${code}``.
|
||||||
|
if strings.HasSuffix(lit, ".") {
|
||||||
|
prefixes = append(prefixes, lit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print results
|
return reportTranslationResults("frontend", translations, keys, prefixes)
|
||||||
if len(missingKeys) > 0 {
|
|
||||||
var errs []error
|
|
||||||
for key, occurrences := range missingKeys {
|
|
||||||
var keyErrs []error
|
|
||||||
for _, occurrence := range occurrences {
|
|
||||||
keyErrs = append(keyErrs, fmt.Errorf("- %s:%d", occurrence.FilePath, occurrence.Line))
|
|
||||||
}
|
|
||||||
errs = append(errs, fmt.Errorf("missing key %s in files:\n%w", key, errors.Join(keyErrs...)))
|
|
||||||
}
|
|
||||||
return fmt.Errorf("found %d missing translation keys:\n%w", len(missingKeys), errors.Join(errs...))
|
|
||||||
}
|
|
||||||
printSuccess("All translation keys are present in the translation file!")
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TranslationKey represents a translation key found in the code
|
// TranslationKey represents a translation key found in the code
|
||||||
|
|
@ -717,7 +750,73 @@ type TranslationKey struct {
|
||||||
Line int
|
Line int
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadTranslations loads the English translation file and returns a flattened map
|
// reportTranslationResults checks both missing keys (used in code but not in
|
||||||
|
// translation file) and dead keys (in translation file but not referenced
|
||||||
|
// anywhere in code). Returns an error if either kind of mismatch is found.
|
||||||
|
func reportTranslationResults(label string, translations map[string]bool, keys []TranslationKey, dynamicPrefixes []string) error {
|
||||||
|
missingKeys := make(map[string][]TranslationKey)
|
||||||
|
usedKeys := make(map[string]bool, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
usedKeys[key.Key] = true
|
||||||
|
if !translations[key.Key] {
|
||||||
|
missingKeys[key.Key] = append(missingKeys[key.Key], key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A translation is used if referenced directly, or if its full dotted key
|
||||||
|
// starts with any prefix produced by a dynamic call site.
|
||||||
|
isCoveredByPrefix := func(k string) bool {
|
||||||
|
for _, p := range dynamicPrefixes {
|
||||||
|
if strings.HasPrefix(k, p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var deadKeys []string
|
||||||
|
for k := range translations {
|
||||||
|
if usedKeys[k] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isCoveredByPrefix(k) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
deadKeys = append(deadKeys, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
var errs []error
|
||||||
|
|
||||||
|
if len(missingKeys) > 0 {
|
||||||
|
var missingErrs []error
|
||||||
|
for key, occurrences := range missingKeys {
|
||||||
|
var keyErrs []error
|
||||||
|
for _, occurrence := range occurrences {
|
||||||
|
keyErrs = append(keyErrs, fmt.Errorf("- %s:%d", occurrence.FilePath, occurrence.Line))
|
||||||
|
}
|
||||||
|
missingErrs = append(missingErrs, fmt.Errorf("missing key %s in files:\n%w", key, errors.Join(keyErrs...)))
|
||||||
|
}
|
||||||
|
errs = append(errs, fmt.Errorf("found %d missing %s translation keys (referenced in code but not in translation file):\n%w", len(missingKeys), label, errors.Join(missingErrs...)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(deadKeys) > 0 {
|
||||||
|
sort.Strings(deadKeys)
|
||||||
|
var deadErrs []error
|
||||||
|
for _, k := range deadKeys {
|
||||||
|
deadErrs = append(deadErrs, fmt.Errorf("- %s", k))
|
||||||
|
}
|
||||||
|
errs = append(errs, fmt.Errorf("found %d dead %s translation keys (present in translation file but unused in code):\n%w", len(deadKeys), label, errors.Join(deadErrs...)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errs) > 0 {
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
printSuccess(fmt.Sprintf("All %s translation keys are in sync between code and the translation file!", label))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadTranslations loads a translation file and returns a flattened map
|
||||||
func loadTranslations(filePath string) (map[string]bool, error) {
|
func loadTranslations(filePath string) (map[string]bool, error) {
|
||||||
data, err := os.ReadFile(filePath)
|
data, err := os.ReadFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -753,8 +852,11 @@ func flattenTranslations(prefix string, src map[string]any, dest map[string]bool
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// walkCodebaseForTranslationKeys walks the codebase and extracts all translation keys
|
// walkBackendForTranslationKeys walks the Go backend and extracts translation
|
||||||
func walkCodebaseForTranslationKeys(rootDir string) ([]TranslationKey, error) {
|
// keys referenced via string-literal arguments to i18n.T / i18n.TP. Dynamic
|
||||||
|
// (non-literal) references are not detected here – add them to
|
||||||
|
// backendDynamicKeyPrefixes instead.
|
||||||
|
func walkBackendForTranslationKeys(rootDir string) ([]TranslationKey, error) {
|
||||||
var allKeys []TranslationKey
|
var allKeys []TranslationKey
|
||||||
|
|
||||||
pkgDir := filepath.Join(rootDir, "pkg")
|
pkgDir := filepath.Join(rootDir, "pkg")
|
||||||
|
|
@ -764,14 +866,12 @@ func walkCodebaseForTranslationKeys(rootDir string) ([]TranslationKey, error) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip hidden directories (starting with .)
|
|
||||||
if info.IsDir() && strings.HasPrefix(info.Name(), ".") {
|
if info.IsDir() && strings.HasPrefix(info.Name(), ".") {
|
||||||
return filepath.SkipDir
|
return filepath.SkipDir
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only process Go files
|
|
||||||
if !info.IsDir() && strings.HasSuffix(path, ".go") {
|
if !info.IsDir() && strings.HasSuffix(path, ".go") {
|
||||||
keys, err := extractTranslationKeysFromFile(path)
|
keys, err := extractBackendTranslationKeysFromFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Warning: %v\n", err)
|
fmt.Printf("Warning: %v\n", err)
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -785,9 +885,10 @@ func walkCodebaseForTranslationKeys(rootDir string) ([]TranslationKey, error) {
|
||||||
return allKeys, err
|
return allKeys, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractTranslationKeysFromFile extracts all i18n.T calls from a file
|
// extractBackendTranslationKeysFromFile extracts all i18n.T/i18n.TP calls with
|
||||||
func extractTranslationKeysFromFile(filePath string) ([]TranslationKey, error) {
|
// a string-literal key. Non-literal (dynamic) keys are not flagged; any
|
||||||
// Read the file content
|
// dynamic-key reference must be allowlisted via backendDynamicKeyPrefixes.
|
||||||
|
func extractBackendTranslationKeysFromFile(filePath string) ([]TranslationKey, error) {
|
||||||
content, err := os.ReadFile(filePath)
|
content, err := os.ReadFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error reading file %s: %w", filePath, err)
|
return nil, fmt.Errorf("error reading file %s: %w", filePath, err)
|
||||||
|
|
@ -795,17 +896,15 @@ func extractTranslationKeysFromFile(filePath string) ([]TranslationKey, error) {
|
||||||
|
|
||||||
var keys []TranslationKey
|
var keys []TranslationKey
|
||||||
|
|
||||||
// Regex to match i18n.T calls
|
// Match i18n.T(ctx, "key") and i18n.TP(ctx, "key", count)
|
||||||
re := regexp.MustCompile(`i18n\.(T)\([^,]+,\s*"([^"]+)"`)
|
re := regexp.MustCompile(`i18n\.(T|TP)\([^,]+,\s*"([^"]+)"`)
|
||||||
matches := re.FindAllSubmatchIndex(content, -1)
|
matches := re.FindAllSubmatchIndex(content, -1)
|
||||||
|
|
||||||
for _, match := range matches {
|
for _, match := range matches {
|
||||||
if len(match) >= 4 {
|
if len(match) >= 6 {
|
||||||
// Extract the key from the match
|
|
||||||
keyStart, keyEnd := match[4], match[5]
|
keyStart, keyEnd := match[4], match[5]
|
||||||
key := string(content[keyStart:keyEnd])
|
key := string(content[keyStart:keyEnd])
|
||||||
|
|
||||||
// Count lines to determine the line number
|
|
||||||
beforeMatch := content[:keyStart]
|
beforeMatch := content[:keyStart]
|
||||||
lineCount := bytes.Count(beforeMatch, []byte("\n")) + 1
|
lineCount := bytes.Count(beforeMatch, []byte("\n")) + 1
|
||||||
|
|
||||||
|
|
@ -820,6 +919,160 @@ func extractTranslationKeysFromFile(filePath string) ([]TranslationKey, error) {
|
||||||
return keys, nil
|
return keys, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// frontendI18nCallReSingle matches translation calls using single-quoted keys:
|
||||||
|
// - $t('k'), $tc('k')
|
||||||
|
// - t('k'), tc('k') (composable)
|
||||||
|
// - i18n.t('k'), i18n.global.t('k')
|
||||||
|
//
|
||||||
|
// Go's RE2 doesn't support backreferences, so we have one regex per quote
|
||||||
|
// style. Template-literal and bare-variable forms are handled separately.
|
||||||
|
var frontendI18nCallReSingle = regexp.MustCompile(`(?:\$t|\$tc|\bt|\btc|\bi18n\.(?:global\.)?t)\(\s*'([^']+)'`)
|
||||||
|
|
||||||
|
// frontendI18nCallReDouble is the double-quoted counterpart of
|
||||||
|
// frontendI18nCallReSingle.
|
||||||
|
var frontendI18nCallReDouble = regexp.MustCompile(`(?:\$t|\$tc|\bt|\btc|\bi18n\.(?:global\.)?t)\(\s*"([^"]+)"`)
|
||||||
|
|
||||||
|
// frontendI18nTemplateLiteralRe matches template-literal calls of the form
|
||||||
|
//
|
||||||
|
// $t(`prefix.${expr}`) / t(`prefix.${...}`) / tc(`...`) etc.
|
||||||
|
//
|
||||||
|
// Capturing group 1 is the portion before the first ${ substitution, which we
|
||||||
|
// treat as a "dynamic prefix": every translation key starting with it is
|
||||||
|
// considered used.
|
||||||
|
var frontendI18nTemplateLiteralRe = regexp.MustCompile("(?:\\$t|\\$tc|\\bt|\\btc|\\bi18n\\.(?:global\\.)?t)\\(\\s*`([^`$]*)\\$\\{")
|
||||||
|
|
||||||
|
// frontendI18nKeypathRe matches Vue template <i18n-t keypath="key.name"> usage.
|
||||||
|
var frontendI18nKeypathRe = regexp.MustCompile(`keypath\s*=\s*"([^"]+)"`)
|
||||||
|
|
||||||
|
// walkFrontendForTranslationKeys scans .vue/.ts/.js files under rootDir and
|
||||||
|
// extracts translation key references, dynamic-key prefixes, and a set of
|
||||||
|
// candidate string-literal usage hints (for indirect references like keys
|
||||||
|
// stored in arrays / template literals assigned to variables).
|
||||||
|
func walkFrontendForTranslationKeys(rootDir string) ([]TranslationKey, []string, map[string]bool, error) {
|
||||||
|
var allKeys []TranslationKey
|
||||||
|
var allPrefixes []string
|
||||||
|
allLiterals := make(map[string]bool)
|
||||||
|
|
||||||
|
err := filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
name := info.Name()
|
||||||
|
// Skip hidden dirs and build artifacts
|
||||||
|
if strings.HasPrefix(name, ".") || name == "node_modules" || name == "dist" {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
// Don't walk into the language files themselves
|
||||||
|
if path == filepath.Join(rootDir, "i18n", "lang") {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := filepath.Ext(path)
|
||||||
|
if ext != ".vue" && ext != ".ts" && ext != ".js" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, prefixes, literals, err := extractFrontendTranslationKeysFromFile(path)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: %v\n", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
allKeys = append(allKeys, keys...)
|
||||||
|
allPrefixes = append(allPrefixes, prefixes...)
|
||||||
|
for l := range literals {
|
||||||
|
allLiterals[l] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return allKeys, allPrefixes, allLiterals, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// frontendStringLiteralRe matches single- or double-quoted strings that look
|
||||||
|
// like dotted translation keys (e.g. "home.welcomeNight").
|
||||||
|
var frontendStringLiteralRe = regexp.MustCompile(`['"]([a-zA-Z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_]+)+)['"]`)
|
||||||
|
|
||||||
|
// frontendTemplatePrefixRe matches any template-literal fragment with a ${…}
|
||||||
|
// interpolation, whether or not it is inside a $t() call. This catches cases
|
||||||
|
// like `const path = ` + "`error.${code}`" where the literal is assigned to
|
||||||
|
// a variable before being passed to a translator.
|
||||||
|
var frontendTemplatePrefixRe = regexp.MustCompile("`([a-zA-Z][a-zA-Z0-9_.]*\\.)\\$\\{")
|
||||||
|
|
||||||
|
// extractFrontendTranslationKeysFromFile extracts static and dynamic
|
||||||
|
// translation key references from a single frontend source file.
|
||||||
|
func extractFrontendTranslationKeysFromFile(filePath string) ([]TranslationKey, []string, map[string]bool, error) {
|
||||||
|
content, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, fmt.Errorf("error reading file %s: %w", filePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var keys []TranslationKey
|
||||||
|
var prefixes []string
|
||||||
|
literals := make(map[string]bool)
|
||||||
|
|
||||||
|
appendKey := func(keyStart int, key string) {
|
||||||
|
beforeMatch := content[:keyStart]
|
||||||
|
lineCount := bytes.Count(beforeMatch, []byte("\n")) + 1
|
||||||
|
keys = append(keys, TranslationKey{
|
||||||
|
Key: key,
|
||||||
|
FilePath: filePath,
|
||||||
|
Line: lineCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static string-literal calls (single- and double-quoted)
|
||||||
|
for _, match := range frontendI18nCallReSingle.FindAllSubmatchIndex(content, -1) {
|
||||||
|
if len(match) >= 4 {
|
||||||
|
appendKey(match[2], string(content[match[2]:match[3]]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, match := range frontendI18nCallReDouble.FindAllSubmatchIndex(content, -1) {
|
||||||
|
if len(match) >= 4 {
|
||||||
|
appendKey(match[2], string(content[match[2]:match[3]]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// <i18n-t keypath="..."> (Vue component usage in templates)
|
||||||
|
for _, match := range frontendI18nKeypathRe.FindAllSubmatchIndex(content, -1) {
|
||||||
|
if len(match) >= 4 {
|
||||||
|
appendKey(match[2], string(content[match[2]:match[3]]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Template-literal calls with interpolation inside a $t(). We extract the
|
||||||
|
// static prefix before ${ and mark every matching key as used.
|
||||||
|
for _, match := range frontendI18nTemplateLiteralRe.FindAllSubmatchIndex(content, -1) {
|
||||||
|
if len(match) >= 4 {
|
||||||
|
prefix := string(content[match[2]:match[3]])
|
||||||
|
if prefix != "" {
|
||||||
|
prefixes = append(prefixes, prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect dotted string literals and interpolated template-literal prefixes
|
||||||
|
// as "usage hints". These are only used to suppress dead-key false positives
|
||||||
|
// for indirect references (keys stored in arrays, prefix assigned to a
|
||||||
|
// variable then passed to a translator, etc).
|
||||||
|
for _, match := range frontendStringLiteralRe.FindAllSubmatchIndex(content, -1) {
|
||||||
|
if len(match) >= 4 {
|
||||||
|
literals[string(content[match[2]:match[3]])] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, match := range frontendTemplatePrefixRe.FindAllSubmatchIndex(content, -1) {
|
||||||
|
if len(match) >= 4 {
|
||||||
|
literals[string(content[match[2]:match[3]])] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys, prefixes, literals, nil
|
||||||
|
}
|
||||||
|
|
||||||
func checkGolangCiLintInstalled(ctx context.Context) error {
|
func checkGolangCiLintInstalled(ctx context.Context) error {
|
||||||
mg.Deps(initVars, ensureFrontendDistExists)
|
mg.Deps(initVars, ensureFrontendDistExists)
|
||||||
if err := exec.CommandContext(ctx, "golangci-lint").Run(); err != nil && strings.Contains(err.Error(), "executable file not found") {
|
if err := exec.CommandContext(ctx, "golangci-lint").Run(); err != nil && strings.Contains(err.Error(), "executable file not found") {
|
||||||
|
|
@ -849,6 +1102,7 @@ func (Check) All() {
|
||||||
Check.Golangci,
|
Check.Golangci,
|
||||||
Check.GotSwag,
|
Check.GotSwag,
|
||||||
Check.Translations,
|
Check.Translations,
|
||||||
|
Check.FrontendTranslations,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue