diff --git a/.i18n-lint.toml b/.i18n-lint.toml new file mode 100644 index 0000000000..dd812ada1a --- /dev/null +++ b/.i18n-lint.toml @@ -0,0 +1,360 @@ +########################################################################################## +# CAUTION: Do not modify this file without a clear understanding of its logic. # +# Check [https://github.com/logseq/logseq-i18n-lint] before proceeding with any changes. # +########################################################################################## + +# Logseq-specific configuration for logseq-i18n-lint. + +# ── Shared settings ──────────────────────────────────────────────────────────── + +# Path from the executable's directory to the Logseq repo root. +# Behaviour is independent of the working directory. +project_root = ".." + +# Directories to scan (relative to project_root). +include_dirs = [ + "src/main/frontend", + "src/main/electron", + "src/main/mobile", + "src/electron", + "deps", +] + +# File extensions to scan. +file_extensions = ["clj", "cljs", "cljc"] + +# Translation functions — calls to these provide translation keys for both subcommands. +i18n_functions = [ + "t", + "tt", + "i18n/t", + "i18n/tt", +] + +# Alert/notification functions. +# lint: the FIRST argument is user-visible text; analyzed in UI context so +# str-concat, conditional-text, and format-string rules apply inside it. +# check-keys: the FIRST keyword argument is a translation key reference. +alert_functions = [ + "notification/show!", +] + +# UI component functions. +# lint: string arguments are user-visible text. +# check-keys: keyword arguments are translation key references. +ui_functions = [ + "ui/button", + "ui/tooltip", + "ui/tooltip-content", + "ui/badge", + "ui/dropdown-menu-item", + "ui/dropdown-menu-sub-trigger", + "ui/loading", + "ui/select-item", + "ui/tabs-trigger", + "ui/form-label", + "ui/form-description", + "ui/card-title", + "ui/alert-title", + "ui/alert-description", + "ui/table-cell", + "ui/table-header", + "ui/link", +] + +# Namespace prefixes where every function is treated as a UI component. +ui_namespaces = [ + "shui", +] + +# HTML/hiccup attributes. +# lint: string values are flagged as user-visible text. +# check-keys: keyword values are treated as translation key references. +ui_attributes = [ + "placeholder", + "title", + "aria-label", + "alt", + "label", +] + +# ── [lint] settings ──────────────────────────────────────────────────────────── + +[lint] + +# Glob patterns for files to skip during lint. +# These patterns are also applied when using --git-changed. +exclude_patterns = [ + "**/test/**", + "**/node_modules/**", + "**/static/**", + "**/target/**", + "**/tmp/**", + "**/cljs-test-runner-out/**", + "**/.nbb/**", + "deps/cli/**", + "deps/publish/**", + "deps/publishing/**", + "deps/db-sync/src/logseq/db_sync/malli_schema.cljs", # Malli protocol schema — wire-protocol message type names + "deps/graph-parser/src/logseq/graph_parser/schema/mldoc.cljc", # mldoc schema — AST node type names (Label, Paragraph, etc.) + "deps/shui/src/logseq/shui/demo*.cljs", # storybook demo UI — intentionally hardcoded + "src/main/frontend/components/profiler.cljs", # Developer profiling tool + "src/main/frontend/db/rtc/debug_ui.cljs", # Developer RTC tool + "src/main/frontend/handler/export/html.cljs", # Raw HTML export — intentional + "src/main/frontend/handler/shell.cljs", # Run shell command + "src/main/frontend/undo_redo/debug_ui.cljs", # Developer undo/redo tool + "src/main/frontend/worker/commands.cljs", # Internal command identifier strings +] + +# Maximum character length of the text preview in output. +text_preview_length = 60 + +# Pure (non-UI) functions — string arguments inside are not reported even in UI context. +pure_functions = [ + # String utilities whose arguments are data, not UI text. + "text-util/cut-by", + "text-util/split-by", + # mldoc/markdown dispatch functions — args are AST node type names, not UI text. + "markup-element-cp", + "markup-elements-cp", + # mldoc inline renderer — first arg is config, second is an AST node vector. + "inline", + # Rum component key wrapper — second arg is a key string, not UI text. + "rum/with-key", + # Shortcut wrapper — positional args are shortcut IDs and positions, not UI text. + "ui/with-shortcut", + # Macro type identifier dispatch. + "macro->text", +] + +# Format/printf functions — ONLY the FIRST argument (the template string) is flagged, +# and ONLY when the call site is inside a UI context (hiccup or UI function call). +format_functions = [ + "format", + "goog.string/format", + "gstring/format", + "util/format", +] + +# Strings to allow (exact match — also matches after trimming whitespace). +# Keep this list SHORT. Add only strings that: +# 1. Are NOT covered by any allow_pattern +# 2. Have a clear, Logseq-specific reason for appearing in ui context +# 3. Are truly non-translatable (brand names, internal IDs, technical constants) +allow_strings = [ + # Brand name — displayed literally in UI, intentionally not translated. + "Logseq", + "Logseq Sync", + "GitHub", + # Typography test string — rendered as a glyph sample, not translatable. + "Ag", + # Column header abbreviation for row index — shown as-is in table view. + "ID:", + # org-mode structural keywords — shown literally in drawer/block syntax. + ":END:", + # Common non-translatable UI labels. + "URL", + # Config directory path shown literally. + "~/.logseq", +] + +# Regex patterns to allow. +allow_patterns = [ + # Logseq macro syntax. + # e.g., {{query ...}}, {{video ...}} + "^\\{\\{", + +# Email addresses. + # e.g., user@example.com, tech.support@domain.org + "^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$", + + # URLs and URI schemes. + # e.g., https://google.com, sfsymbols://icon, file:///path/to/res + "^[a-z]+://[^\\s]*$", + + # Git commands. + # e.g., git commit -m "feat", git push origin main + "^git\\s+[a-z]+(\\s+.*)?$", + + # Tailwind CSS color / shade utility classes. + # e.g., bg-red-500, text-gray-300, border-blue-100 + "^(bg|text|border|ring|shadow|fill|from|via|to|outline|divide|accent|caret|decoration)-[a-z]+-[0-9]+(/[0-9]+)?$", + + # CSS color functions. + # e.g., rgb(255, 255, 255), rgba(0, 0, 0, 0.5) + "^rgba?\\(", + + # CSS custom property access. + # e.g., var(--primary-color), var(--spacing-unit) + "^var\\(--", + + # CSS BEM modifier classes (double-hyphen notation). + # e.g., shortcut-feedback--error, block__title--active + "^[a-z][a-z0-9-]*--[a-z][a-z0-9-]*$", + + # Numeric base prefixes. + # e.g., 0b (binary), 0o (octal), 0x (hex) + "^0[box]$", + + # Regex anchor notation. + # e.g., ^starting-with + "^\\^", + + # Web resource references with specific extensions. + # e.g., script.js, styles.css, module.mjs + "^[a-z][a-z0-9/._-]+\\.(mjs|js|css|wasm)$", + + # MIME types. + # e.g., image/png, application/json, text/html + "^[a-z][a-z0-9+.-]+/[a-z0-9.+*-]+$", + + # Hex colors (3, 4, 6, or 8 digits). + # e.g., #fff, #1a2b3c, #ff00ffaa + "^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$", + + # DOM element IDs. + # e.g., #main-container, #submit-btn + "^#[a-z][a-z0-9-]+$", + + # Dot-notation identifiers (icon library names, SF Symbols, CSS dot-joined classes). + # e.g., person.fill, cloud.sun.rain.fill, bg-red-600.top-1.absolute + "^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)*$", + + # CSS unit values (supports decimals). + # e.g., 10px, 1.5rem, 100%, 500ms + "^[0-9]+(\\.[0-9]+)?(px|em|rem|vh|vw|%|pt|s|ms)$", + + # DOM element ID / React key fragments (start or end with hyphen). + # e.g., tag-, -refs, sidebar-block-, -custom-query-, -add-property + "^-[a-z0-9]+(-[a-z0-9]+)*$", + "^[a-z0-9]+(-[a-z0-9]+)*-$", + + # Strings starting with a dot (file fragments or class selectors). + # e.g., .hidden, .tmp-file + "^\\.", + + # printf-style format templates. + # e.g., %s, [%d%%], #%x + "^[^A-Za-z ]*%", +] + +# Exception/error constructor functions — arguments are developer-facing, not UI text. +exception_functions = [ + "ex-info", + "throw", +] + +# Functions whose arguments are NOT checked. +ignore_context_functions = [ + "js/console.log", + "js/console.error", + "js/console.warn", + "prn", + "println", + "log/debug", + "log/info", + "log/warn", + "log/error", + "re-pattern", + "re-find", + "re-matches", + "require", + "ns", + # shui utilities that take CSS IDs / class utility strings, not user-visible text. + "shui/cn", + "shui/popup-show", + "shui/popup-show!", + "shui/popup-hide", + "shui/popup-hide!", + "shui/popup-hide-all", + "shui/dialog-open", + "shui/dialog-close", + "shui/dialog-close-all", + "shui/dialog-confirm", + "shui/table-get-selection-rows", + "shui/trigger-as", + # CSS class-joining utilities — string arguments are class names, not UI text. + "util/classnames", + "classnames", + # Icon functions — arguments are icon library identifiers (e.g. "trash", + # "arrow-right"), never user-visible text that needs translation. + "ui/icon", + "shui/tabler-icon", + "icon-v2/root", + # Ghost-icon button — the only positional argument is an icon name. + "button-ghost-icon", + # Shortcut display/trigger functions — arguments are key identifiers + # (e.g. "mod+enter", "backspace"), not translatable text. + "shui/shortcut", + "shui/shortcut-press!", +] + +# ── [check-keys] settings ────────────────────────────────────────────────────── + +[check-keys] + +# Glob patterns for files to skip during check-keys. +# NOTE: **/profiler.cljs is intentionally NOT excluded here so that translation +# key references inside profiler.cljs are detected and not reported as unused. +exclude_patterns = [ + "**/test/**", + "**/tests/**", + "**/dev/**", + "**/node_modules/**", + "**/target/**", + "**/static/**", + "**/cljs-test-runner-out/**", + "**/.nbb/**", + "deps/cli/**", + "deps/publish/**", + "deps/publishing/**", +] + +# Directory containing dictionary EDN files (relative to project_root). +dicts_dir = "src/resources/dicts" + +# Primary dictionary file (relative to project_root). +primary_dict = "src/resources/dicts/en.edn" + +# Key patterns always considered "used" — for dynamically generated keys +# that cannot be detected via static analysis. +always_used_key_patterns = [ + # Table view keys used dynamically via (for [[option-key _] options] (t option-key)). + "^:view\\.table/group-journal-date", + "^:view\\.table/group-page", +] + +# Key namespace prefixes excluded from unused-key checking. +ignore_key_namespaces = [ + # Shortcut keys are dynamically assembled via (keyword "command.ns" name). + "command", + # Shortcut category labels. + "shortcut.category", + # Shortcut handler group keys. + "shortcut.handler", + # Color theme keys derived from built-in-colors vector. + "color", + # Date NLP labels derived from nlp-pages vector. + "date.nlp", + # Flashcard FSRS rating keys derived via (keyword "flashcard.rating" ...). + "flashcard.rating", + # Graph validation keys derived from deprecated config keys. + "graph.validation", + # Left sidebar nav keys derived from tag nav entries. + "nav", +] + +# Map attribute keys whose keyword values are translation key references. +# Combined with ui_attributes during check-keys analysis. +translation_key_attributes = ["i18n-key", "prompt-key", "title-key"] + +# Built-in db-ident definition sources. +# Each entry scopes keyword extraction to a specific named def/defonce form, +# preventing false positives from other keyword literals in the same file. +[[check-keys.db_ident_defs]] +file = "deps/db/src/logseq/db/frontend/property.cljs" +def = "built-in-properties" + +[[check-keys.db_ident_defs]] +file = "deps/db/src/logseq/db/frontend/class.cljs" +def = "built-in-classes" diff --git a/bin/logseq-i18n-lint b/bin/logseq-i18n-lint new file mode 100644 index 0000000000..9b29170bce --- /dev/null +++ b/bin/logseq-i18n-lint @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# logseq-i18n-lint launcher +# Detects the current OS/arch and runs the prebuilt binary in the same directory. +# All arguments are forwarded to the binary. +# +# Supported platforms: +# Linux x86_64 -> logseq-i18n-lint-x86_64-linux +# Linux aarch64 -> logseq-i18n-lint-aarch64-linux +# macOS x86_64 -> logseq-i18n-lint-x86_64-macos +# macOS arm64 -> logseq-i18n-lint-aarch64-macos +# Windows x86_64 -> logseq-i18n-lint-x86_64-windows.exe (via Git Bash / MSYS2) +# Windows aarch64 -> logseq-i18n-lint-aarch64-windows.exe + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ── Detect OS ──────────────────────────────────────────────────────────────── + +OS="$(uname -s)" +case "${OS}" in + Linux*) platform="linux" ;; + Darwin*) platform="macos" ;; + MINGW*|MSYS*|CYGWIN*|Windows_NT) + platform="windows" ;; + *) + echo "error: unsupported OS: ${OS}" >&2 + exit 1 + ;; +esac + +# ── Detect architecture ─────────────────────────────────────────────────────── + +ARCH="$(uname -m)" +case "${ARCH}" in + x86_64|amd64) arch="x86_64" ;; + aarch64|arm64) arch="aarch64" ;; + *) + echo "error: unsupported architecture: ${ARCH}" >&2 + exit 1 + ;; +esac + +# ── Resolve binary path ──────────────────────────────────────────────────────── + +if [[ "${platform}" == "windows" ]]; then + bin="${SCRIPT_DIR}/logseq-i18n-lint-${arch}-${platform}.exe" +else + bin="${SCRIPT_DIR}/logseq-i18n-lint-${arch}-${platform}" +fi + +if [[ ! -f "${bin}" ]]; then + echo "error: binary not found: ${bin}" >&2 + echo " Download it from: https://github.com/logseq/logseq-i18n-lint/releases/latest" >&2 + exit 1 +fi + +if [[ ! -x "${bin}" ]]; then + chmod +x "${bin}" +fi + +# ── Run ─────────────────────────────────────────────────────────────────────── + +exec "${bin}" "$@" diff --git a/bin/logseq-i18n-lint-aarch64-linux b/bin/logseq-i18n-lint-aarch64-linux new file mode 100644 index 0000000000..7e489a42b4 Binary files /dev/null and b/bin/logseq-i18n-lint-aarch64-linux differ diff --git a/bin/logseq-i18n-lint-aarch64-macos b/bin/logseq-i18n-lint-aarch64-macos new file mode 100644 index 0000000000..5acdaf7b94 Binary files /dev/null and b/bin/logseq-i18n-lint-aarch64-macos differ diff --git a/bin/logseq-i18n-lint-aarch64-windows.exe b/bin/logseq-i18n-lint-aarch64-windows.exe new file mode 100644 index 0000000000..8bd5286c07 Binary files /dev/null and b/bin/logseq-i18n-lint-aarch64-windows.exe differ diff --git a/bin/logseq-i18n-lint-x86_64-linux b/bin/logseq-i18n-lint-x86_64-linux new file mode 100644 index 0000000000..69169b5e89 Binary files /dev/null and b/bin/logseq-i18n-lint-x86_64-linux differ diff --git a/bin/logseq-i18n-lint-x86_64-macos b/bin/logseq-i18n-lint-x86_64-macos new file mode 100644 index 0000000000..21fe5ecffa Binary files /dev/null and b/bin/logseq-i18n-lint-x86_64-macos differ diff --git a/bin/logseq-i18n-lint-x86_64-windows.exe b/bin/logseq-i18n-lint-x86_64-windows.exe new file mode 100644 index 0000000000..6540cfc883 Binary files /dev/null and b/bin/logseq-i18n-lint-x86_64-windows.exe differ diff --git a/scripts/src/logseq/tasks/lang.clj b/scripts/src/logseq/tasks/lang.clj index c06508db43..c71b52c9a3 100644 --- a/scripts/src/logseq/tasks/lang.clj +++ b/scripts/src/logseq/tasks/lang.clj @@ -89,7 +89,7 @@ language English. Logseq needs to work out of the box with its default language. This catches mistakes where another language has accidentally typoed keys or added ones without updating :en" - [{:keys [fix?]}] + [fix?] (let [dicts (get-dicts) ;; For now defined as :en but clj-kondo analysis could be more thorough valid-keys (set (keys (dicts :en))) @@ -111,173 +111,41 @@ (println "These invalid translation keys have been removed from non-default dictionaries.")) (System/exit 1))))) -(def ^:private direct-translation-call-source-paths - ["src/main" "src/electron"]) +(def ^:private i18n-lint-launcher-path + (fs/absolutize "bin/logseq-i18n-lint")) -(def ^:private translated-code-source-paths - ["deps" "src/main" "src/electron"]) +(def ^:private i18n-lint-config-path + (fs/absolutize ".i18n-lint.toml")) -(def ^:private shortcut-config-path - "src/main/frontend/modules/shortcut/config.cljs") - -;; Matches literal `(t :ns/key)` and `(tt :ns/key)` calls, including alias-qualified -;; forms like `(i18n/t :ns/key)`. -(def ^:private direct-translation-call-rg-pattern - "[(](?:[[:alnum:]._-]+/)?tt?[[:space:]]+:[^ )]+") - -;; Matches literal `:i18n-key :ns/key` entries, including values wrapped onto the -;; next line when `rg` runs in multiline mode. -(def ^:private i18n-key-rg-pattern - ":i18n-key[[:space:]]+:[^ }\n]+") - -;; Matches files containing dynamic translation-call `if`/`or` forms, `i18n-key` -;; `if` forms, literal `:prompt-key`/`:title-key` options, `built-in-colors`, -;; `navs` vectors, date NLP labels, built-in property/class definitions, or -;; `:shortcut.category/*` keys, or supported dynamic `keyword` i18n patterns for -;; later exact extraction. -(def ^:private derived-ui-key-candidate-rg-pattern - "(?:[(](?:[[:alnum:]._-]+/)?tt?[[:space:]]+[(](?:if|or)\\b|:?i18n-key[[:space:]]+[(]if\\b|:prompt-key[[:space:]]+:|:title-key[[:space:]]+:|[(]def[[:space:]]+built-in-colors\\b|\\bnavs[[:space:]]+\\[|[(]def[[:space:]]+nlp-pages\\b|[(]def[[:space:]]+\\^:large-vars/data-var[[:space:]]+built-in-(?:properties|classes)\\b|:shortcut\\.category/|[(]keyword[[:space:]]+\"flashcard\\.rating\")") - -(def ^:private built-in-db-ident-candidate-rg-pattern - "[(]def[[:space:]]+\\^:large-vars/data-var[[:space:]]+built-in-(?:properties|classes)\\b") - -(defn- extract-keyword-match - [value] - (some-> (last (re-seq #":[^ )}\s]+" value)) - (subs 1) - keyword)) - -(defn- rg-output-lines - [paths pattern & {:keys [multiline?]}] - (let [args (concat ["rg"] - (when multiline? ["-U"]) - ["--no-filename" "-o" "-N" pattern] - paths)] - (->> (apply shell {:out :string :continue true} args) - :out - string/split-lines - (remove string/blank?)))) - -(defn- rg-matching-files - [paths pattern] - (let [args (concat ["rg" "-l" - "--glob" "*.clj" - "--glob" "*.cljs" - "--glob" "*.cljc" - pattern] - paths)] - (->> (apply shell {:out :string :continue true} args) - :out - string/split-lines - (remove string/blank?)))) - -(defn- grep-direct-translation-keys - "Grep source paths for literal `(t :ns/key)` and `(tt :ns/key)` calls." +(defn- ensure-i18n-lint-ready! [] - (->> (rg-output-lines direct-translation-call-source-paths direct-translation-call-rg-pattern) - (keep extract-keyword-match) - set)) + (when-not (fs/exists? i18n-lint-launcher-path) + (println "logseq-i18n-lint launcher not found at" (str i18n-lint-launcher-path)) + (System/exit 1)) + (when-not (fs/exists? i18n-lint-config-path) + (println "i18n lint config not found at" (str i18n-lint-config-path)) + (System/exit 1))) -(defn- grep-i18n-payload-keys - "Grep translated code paths for `:i18n-key` payload entries, including cases - where the translation key is wrapped onto the next line." - [] - (->> (rg-output-lines translated-code-source-paths i18n-key-rg-pattern :multiline? true) - (keep extract-keyword-match) - set)) +(defn- run-i18n-lint-command! + [subcommand cli-args] + (ensure-i18n-lint-ready!) + (let [cmd (into ["bash" + (str i18n-lint-launcher-path) + "-c" + (str i18n-lint-config-path) + subcommand] + cli-args) + result (apply shell {:continue true + :out :inherit + :err :inherit} + cmd)] + (when (pos? (:exit result)) + (System/exit (:exit result))))) -(defn- grep-derived-translation-keys - "Scan candidate source files for translation keys derived from supported - dynamic patterns such as `if`/`or` translation calls, option keys, built-in - colors, left-sidebar derived nav labels, and shortcut category labels." - [] - (->> (rg-matching-files translated-code-source-paths derived-ui-key-candidate-rg-pattern) - (mapcat #(lang-lint/derived-translation-keys (slurp %))) - set)) - -(defn- grep-built-in-db-ident-translation-keys - "Derive built-in property/class translation keys from built-in db-ident - definition forms." - [] - (->> (rg-matching-files translated-code-source-paths built-in-db-ident-candidate-rg-pattern) - (mapcat #(lang-lint/built-in-db-ident-translation-keys (slurp %))) - set)) - -(defn- grep-shortcut-command-keys - "Derive `:command.*` translation keys from shortcut ids declared in the - built-in shortcut config." - [] - (lang-lint/shortcut-command-keys (slurp shortcut-config-path))) - -(def ^:private config-deprecation-detailed-keys - [:editor/command-trigger - :arweave/gateway - :preferred-format - :property-pages/enabled? - :block-hidden-properties - :feature/enable-block-timestamps? - :favorites - :default-templates]) - -(defn- config-key->deprecation-i18n-key - [config-key] - (let [ns-str (namespace config-key) - clean-name (string/replace (name config-key) #"\?$" "") - leaf (if ns-str - (str ns-str "-" clean-name) - clean-name)] - (keyword "graph.validation" (str "config-" leaf "-warning")))) - -(defn- grep-config-deprecation-translation-keys - "Derive `:graph.validation/*` deprecation keys from deprecated config keys." - [] - (conj (->> config-deprecation-detailed-keys - (map config-key->deprecation-i18n-key) - set) - :graph.validation/config-unused-in-db-graphs-warning)) - -(defn- delete-not-used-key-from-dict-file - [invalid-keys] - (let [paths (fs/list-dir "src/resources/dicts")] - (doseq [path paths] - (let [result (rewrite/parse-string (String. (fs/read-all-bytes path))) - new-content (str (reduce - (fn [result k] - (rewrite/dissoc result k)) - result invalid-keys))] - (spit (fs/file path) new-content))))) - -(defn- validate-ui-translations-are-used - "This validation checks that translation keys referenced from frontend, Electron, - shortcut config, and translated validation payloads all exist in the default - :en dictionary, and that unused keys in :en can be detected." - [{:keys [fix?]}] - (let [defined-translation-keys (set (keys (:en (get-dicts)))) - built-in-defined-translation-keys (->> (grep-built-in-db-ident-translation-keys) - (set/intersection defined-translation-keys)) - referenced-translation-keys (->> [(grep-direct-translation-keys) - (grep-i18n-payload-keys) - (grep-derived-translation-keys) - built-in-defined-translation-keys - (grep-config-deprecation-translation-keys) - (grep-shortcut-command-keys)] - (apply concat) - set) - undefined-references (set/difference referenced-translation-keys defined-translation-keys) - unreferenced-definitions (set/difference defined-translation-keys referenced-translation-keys)] - (if (and (empty? undefined-references) (empty? unreferenced-definitions)) - (println "All defined :en translation keys match the ones that are used!") - (do - (when (seq undefined-references) - (println "\nThese translation keys are invalid because they are referenced in translated code paths but not defined:") - (task-util/print-table (map #(hash-map :invalid-key %) undefined-references))) - (when (seq unreferenced-definitions) - (println "\nThese translation keys are invalid because they are defined but not referenced in translated code paths:") - (task-util/print-table (map #(hash-map :invalid-key %) unreferenced-definitions)) - (when fix? - (delete-not-used-key-from-dict-file unreferenced-definitions) - (println "These unreferenced translation keys have been removed."))) - (System/exit 1))))) +(defn- check-translation-keys + "Use logseq-i18n-lint to detect unused translation keys." + [args] + (run-i18n-lint-command! "check-keys" args)) (defn- validate-rich-translations "Checks that localized rich translations remain rich zero-arg functions. @@ -309,133 +177,14 @@ (defn validate-translations "Runs multiple translation validations that fail fast if one of them is invalid" [& args] - (validate-non-default-languages {:fix? (contains? (set args) "--fix")}) - (validate-ui-translations-are-used {:fix? (contains? (set args) "--fix")}) + (validate-non-default-languages (contains? (set args) "--fix")) + (check-translation-keys args) (validate-rich-translations) (validate-translation-placeholders)) -(def ^:private hardcoded-default-paths - ["src/main/frontend" - "src/main/mobile" - "src/electron" - "deps" - "packages/ui"]) - -(def ^:private hardcoded-allowed-extensions - #{"clj" "cljs" "cljc" "ts" "tsx"}) - -(def ^:private hardcoded-ignored-segments - ["/test/" "/tests/" "/dev/" "/node_modules/" "/target/" "/static/" "/cljs-test-runner-out/"]) - -;; These files are outside the product UI translation surface even though they -;; contain readable strings: -;; - component demos/playgrounds under `deps/shui` -;; - MCP tool metadata under the CLI implementation -;; - internal command definitions used only for worker-side matching -;; - language autonyms curated outside the translation dictionaries -(def ^:private hardcoded-ignored-path-patterns - [#"^deps/shui/src/logseq/shui/demo\d*\.cljs$" - #"^deps/cli/src/logseq/cli/common/mcp/" - #"^deps/publish/" - #"^deps/db/src/logseq/db/frontend/class\.cljs$" - #"^deps/db/src/logseq/db/frontend/property\.cljs$" - #"^src/main/frontend/worker/commands\.cljs$" - #"^src/main/frontend/dicts\.cljc$"]) - -(def ^:private hardcoded-ignored-dirs - #{"test" "tests" "dev" "node_modules" "target" "static"}) - -(defn- normalize-path - [path] - (-> (str path) - (string/replace "\\" "/") - (string/replace-first #"^\./" ""))) - -(defn- hardcoded-lint-file? - [path] - (let [normalized (normalize-path path)] - (and (hardcoded-allowed-extensions (fs/extension normalized)) - (not-any? #(string/includes? normalized %) hardcoded-ignored-segments) - (not-any? #(re-find % normalized) hardcoded-ignored-path-patterns)))) - -(defn- ignored-dir? - "Return true if the directory should be skipped during lint traversal." - [^java.io.File f] - (let [n (.getName f)] - (or (string/starts-with? n ".") - (hardcoded-ignored-dirs n)))) - -(defn- directory-files - [path] - (let [root (fs/file path) - acc (volatile! [])] - (letfn [(walk [^java.io.File f] - (if (.isDirectory f) - (when-not (ignored-dir? f) - (run! walk (.listFiles f))) - (vswap! acc conj (str f))))] - (walk root)) - @acc)) - -(defn- collect-files - [paths] - (->> paths - (map normalize-path) - (filter fs/exists?) - (mapcat (fn [path] - (if (fs/directory? path) - (directory-files path) - [path]))) - (map normalize-path) - (filter hardcoded-lint-file?) - distinct - sort)) - -(defn- changed-files-from-git-status - [] - (->> (shell {:out :string :continue true} - "git" "status" "--porcelain" "--untracked-files=all") - :out - string/split-lines - (map #(subs % 3)) - (map #(if (string/includes? % " -> ") - (last (string/split % #" -> ")) - %)) - (map normalize-path) - (filter seq))) - -(defn- lint-findings - [paths] - (->> paths - (mapcat #(lang-lint/hardcoded-string-findings % (slurp %))) - (sort-by (juxt :file :line :kind)) - vec)) - (defn lint-hardcoded - "Lint likely hardcoded user-facing strings in UI-oriented source files. - Use --warn-only to report findings without failing and --changed-only to scan - only files changed in git status. Optional positional args limit the scan to - specific files or directories." + "Run logseq-i18n-lint to lint likely hardcoded user-facing strings in UI-oriented source files. + Use -w or --warn-only to report findings without failing and -g or --git-changed to scan + only files changed in git status." [& args] - (let [arg-set (set args) - warn-only? (contains? arg-set "--warn-only") - changed-only? (contains? arg-set "--changed-only") - explicit-paths (remove #(#{"--warn-only" "--changed-only"} %) args) - paths (cond - (seq explicit-paths) (collect-files explicit-paths) - changed-only? (collect-files (changed-files-from-git-status)) - :else (collect-files hardcoded-default-paths)) - findings (lint-findings paths)] - (cond - (empty? paths) - (println "No files matched the hardcoded-string lint scope.") - - (empty? findings) - (println "No hardcoded user-facing string literals found in the selected files.") - - :else - (do - (println "Potential hardcoded user-facing strings:") - (task-util/print-table findings) - (when-not warn-only? - (System/exit 1)))))) + (run-i18n-lint-command! "lint" args)) diff --git a/scripts/src/logseq/tasks/lang_lint.cljc b/scripts/src/logseq/tasks/lang_lint.cljc index 470e115fcb..82a3dcd62d 100644 --- a/scripts/src/logseq/tasks/lang_lint.cljc +++ b/scripts/src/logseq/tasks/lang_lint.cljc @@ -1,150 +1,9 @@ -(ns logseq.tasks.lang-lint - (:require [clojure.string :as string])) - -;; Matches `notification/show!` calls whose first argument is a literal message. -(def ^:private notification-pattern - #"notification/show!\s+\"([^\"\n]+)\"") - -;; Matches supported user-facing Hiccup attributes with literal string values. -(def ^:private user-facing-attr-pattern - #"(?> (string/split-lines content) - (map-indexed (fn [index line] - (line-findings file-path (inc index) line commented-lines))) - (apply concat) - vec))) - (defn translation-placeholders "Return the placeholder indexes referenced by translation string `value`. @@ -156,239 +15,6 @@ set) #{})) -(defn- keyword-literals - ([value] (keyword-literals value #{})) - ([value excluded-keys] - (->> (re-seq translation-key-pattern value) - (map #(keyword (subs % 1))) - (remove excluded-keys) - set))) - -(defn- keyword-literals-in-order - [value] - (->> (re-seq translation-key-pattern value) - (map #(keyword (subs % 1))))) - -(defn- conditional-branch-keys - [value] - (->> (keyword-literals-in-order value) - (remove #{:i18n-key}) - (take-last 2) - set)) - -(defn- extract-balanced-list-form - [content start-index] - (loop [index start-index - depth 0 - in-string? false - escape? false] - (when (< index (count content)) - (let [ch (.charAt content index) - next-escape? (and in-string? (= ch \\) (not escape?)) - next-in-string? (if (and (= ch \") (not escape?)) - (not in-string?) - in-string?) - next-depth (cond - next-in-string? depth - (= ch \() (inc depth) - (= ch \)) (dec depth) - :else depth) - end? (and (= ch \)) (not in-string?) (= next-depth 0))] - (if end? - (subs content start-index (inc index)) - (recur (inc index) next-depth next-in-string? next-escape?)))))) - -(defn- matched-list-form-matches - [pattern list-head content] - (loop [forms [] - search-start 0] - (let [remaining (subs content search-start) - match (re-find pattern remaining)] - (if match - (let [match-start (+ search-start (string/index-of remaining match)) - list-offset (string/index-of match list-head) - list-start (+ match-start list-offset) - form (extract-balanced-list-form content list-start)] - (recur (cond-> forms - form (conj {:start list-start - :form form})) - (inc match-start))) - forms)))) - -(defn- matched-list-forms - [pattern list-head content] - (mapv :form (matched-list-form-matches pattern list-head content))) - -(defn- comment-form-lines - [content] - (->> (matched-list-form-matches comment-form-prefix-pattern "(comment" content) - (mapcat (fn [{:keys [start form]}] - (let [start-line (inc (count (re-seq #"\n" (subs content 0 start)))) - line-count (count (string/split-lines form))] - (range start-line (+ start-line line-count))))) - set)) - -(defn conditional-translation-keys - "Return translation keys referenced by supported `(if ...)` forms. - - This covers direct translation calls like `(t (if ...))` and `:i18n-key` - payload bindings whose then/else branches both resolve to translation keys." - [content] - (->> [translation-call-if-prefix-pattern i18n-key-if-prefix-pattern] - (mapcat #(matched-list-forms % "(if" content)) - (mapcat conditional-branch-keys) - set)) - -(defn translation-call-fallback-keys - "Return fallback translation keys referenced by supported `(t (or ...))` - forms." - [content] - (->> (matched-list-forms translation-call-or-prefix-pattern "(or" content) - (mapcat #(take-last 1 (keyword-literals-in-order %))) - set)) - -(defn option-translation-keys - "Return literal translation keys assigned to option key `option-key`. - - Example option keys include `:prompt-key` and `:title-key`." - [content option-key] - (let [pattern (re-pattern (str ":" (name option-key) "\\s+:[^ )}\\],\\s]+"))] - (->> (re-seq pattern content) - (mapcat #(keyword-literals % #{option-key})) - set))) - -(defn built-in-color-keys - "Return `:color/*` translation keys derived from `built-in-colors`." - [content] - (if-let [[_ colors-content] (re-find built-in-colors-pattern content)] - (->> (re-seq string-literal-pattern colors-content) - (map second) - (map #(keyword "color" %)) - set) - #{})) - -(defn left-sidebar-translation-keys - "Return left sidebar navigation translation keys derived from tag nav entries - in the left sidebar `navs` vector." - [content] - (if-let [[_ navs-content] (re-find left-sidebar-navs-pattern content)] - (->> (re-seq tag-nav-key-pattern navs-content) - (map second) - (map #(keyword "nav" %)) - set) - #{})) - -(defn date-nlp-translation-keys - "Return `:date.nlp/*` translation keys derived from `nlp-pages`." - [content] - (if-let [[_ nlp-pages-content] (re-find date-nlp-pages-pattern content)] - (->> (re-seq string-literal-pattern nlp-pages-content) - (map second) - (map #(keyword "date.nlp" - (-> % - string/lower-case - (string/replace " " "-")))) - set) - #{})) - -(defn- built-in-db-ident->i18n-key - [db-ident] - (let [ns-str (namespace db-ident) - n (name db-ident)] - (cond - (= ns-str "logseq.class") - (keyword "class.built-in" (string/lower-case n)) - - (or (= ns-str "logseq.property") - (and (string? ns-str) - (string/starts-with? ns-str "logseq.property."))) - (let [sub-ns (when (not= ns-str "logseq.property") - (subs ns-str (count "logseq.property."))) - dot-idx (string/index-of n ".") - clean-n (string/replace n #"\?$" "")] - (if dot-idx - (let [prop-part (subs clean-n 0 dot-idx) - choice-part (subs clean-n (inc dot-idx)) - subdomain (if sub-ns (str sub-ns "-" prop-part) prop-part)] - (keyword (str "property." subdomain) choice-part)) - (if sub-ns - (keyword "property.built-in" (str sub-ns "-" clean-n)) - (keyword "property.built-in" clean-n)))) - - (= ns-str "block") - (keyword "property.built-in" (string/replace n #"\?$" "")) - - :else nil))) - -(defn built-in-db-ident-translation-keys - "Return translation keys derived from built-in db-ident keyword literals." - [content] - (->> [(matched-list-forms built-in-properties-form-pattern "(def" content) - (matched-list-forms built-in-classes-form-pattern "(def" content)] - (apply concat) - (mapcat keyword-literals) - (keep built-in-db-ident->i18n-key) - set)) - -(defn shortcut-command-keys - "Return `:command.*` translation keys derived from built-in shortcut ids. - - Shortcut handler ids like `:shortcut.handler/*` are ignored because they are - config group keys, not user-visible command ids. - - The five built-in `:cards/*` review shortcuts are also ignored because their - visible labels come from flashcard UI state, not `:command.*` - descriptions." - [content] - (->> (re-seq shortcut-command-id-pattern content) - (map (fn [[_ shortcut-ns shortcut-name]] - (keyword shortcut-ns shortcut-name))) - (remove (fn [shortcut-id] - (or (string/starts-with? (namespace shortcut-id) "shortcut.handler") - (contains? shortcut-command-translation-exemptions shortcut-id)))) - (map (fn [shortcut-id] - (keyword (str "command." (namespace shortcut-id)) - (name shortcut-id)))) - set)) - -(defn shortcut-category-translation-keys - "Return `:shortcut.category/*` translation keys referenced in shortcut UI - category declarations." - [content] - (->> (re-seq shortcut-category-key-pattern content) - (map #(keyword (subs % 1))) - set)) - -(defn flashcard-rating-translation-keys - "Return `:flashcard.rating/*` keys derived from supported FSRS dynamic - keyword construction." - [content] - (let [ratings [:again :hard :good :easy]] - (into #{} - (concat - (when (re-find flashcard-rating-label-keyword-pattern content) - (map #(keyword "flashcard.rating" (name %)) ratings)) - (when (re-find flashcard-rating-desc-keyword-pattern content) - (map #(keyword "flashcard.rating" (str (name %) "-desc")) ratings)))))) - -(defn derived-translation-keys - "Return translation keys derived from supported non-literal UI patterns. - - This combines conditional calls, fallback calls, option keys, built-in color - labels, left-sidebar derived nav labels, and shortcut category labels." - [content] - (->> [(conditional-translation-keys content) - (translation-call-fallback-keys content) - (option-translation-keys content :prompt-key) - (option-translation-keys content :title-key) - (built-in-color-keys content) - (left-sidebar-translation-keys content) - (date-nlp-translation-keys content) - (shortcut-category-translation-keys content) - (flashcard-rating-translation-keys content)] - (apply concat) - set)) - (defn- placeholders-compatible? [default-value localized-value] (or (not (string? default-value)) diff --git a/scripts/test/logseq/tasks/lang_test.cljs b/scripts/test/logseq/tasks/lang_test.cljs index dc741d8868..6b481d62f7 100644 --- a/scripts/test/logseq/tasks/lang_test.cljs +++ b/scripts/test/logseq/tasks/lang_test.cljs @@ -2,246 +2,19 @@ (:require [cljs.test :refer [deftest is testing]] [logseq.tasks.lang-lint :as lang-lint])) -(deftest hardcoded-string-findings-detect-notification-literals - (let [findings (lang-lint/hardcoded-string-findings - "src/main/frontend/components/example.cljs" - "(ns frontend.components.example)\n(notification/show! \"Copied!\" :success)\n")] - (is (= [{:kind :notification - :file "src/main/frontend/components/example.cljs" - :line 2 - :text "Copied!"}] - findings)))) - -(deftest hardcoded-string-findings-handle-user-facing-attr-pattern - (testing "literal placeholder, title, aria-label, alt, and label values are reported" - (let [findings (lang-lint/hardcoded-string-findings - "src/main/frontend/components/example.cljs" - "(ns frontend.components.example) - [:input {:placeholder \"Type to search\"}] - [:button {:title \"Open item\"} (t :general/open)] - [:button.icon-button {:aria-label \"Search\"}] - [:img {:alt \"Export preview\"}] - [:div {:label \"Select all\"}]")] - (is (= [{:kind :placeholder - :file "src/main/frontend/components/example.cljs" - :line 2 - :text "Type to search"} - {:kind :title - :file "src/main/frontend/components/example.cljs" - :line 3 - :text "Open item"} - {:kind :aria-label - :file "src/main/frontend/components/example.cljs" - :line 4 - :text "Search"} - {:kind :alt - :file "src/main/frontend/components/example.cljs" - :line 5 - :text "Export preview"} - {:kind :label - :file "src/main/frontend/components/example.cljs" - :line 6 - :text "Select all"}] - findings)))) - (testing "non-literal or intentionally ignored attribute values are skipped" - (let [findings (lang-lint/hardcoded-string-findings - "src/main/frontend/components/example.cljs" - "(ns frontend.components.example) - [:input {:placeholder (t :search/placeholder)}] - [:input {:placeholder \"http://\"}] - [:input {:placeholder \"https://\"}] - [:input {:placeholder \"{}\"}] - [:input {:placeholder \"git commit -m ...\"}] - [:button {:title \"Logseq\"}] - (shui/dialog-open! component {:label :app-settings})")] - (is (empty? findings))))) - -(deftest hardcoded-string-findings-handle-hiccup-text-pattern - (testing "literal text in supported tags and tag shorthand is reported" - (let [findings (lang-lint/hardcoded-string-findings - "src/main/mobile/example.cljs" - "(ns mobile.example) - [:button \"Save\"] - [:label \"Graph name\"] - [:div.publish-search-hint \"Up/Down to navigate\"] - [:p \"Troubleshooting steps\"] - [:small \"Current chapter\"] - [:strong \"Writing mode\"]")] - (is (= [{:kind :hiccup-text - :file "src/main/mobile/example.cljs" - :line 2 - :text "Save"} - {:kind :hiccup-text - :file "src/main/mobile/example.cljs" - :line 3 - :text "Graph name"} - {:kind :hiccup-text - :file "src/main/mobile/example.cljs" - :line 4 - :text "Up/Down to navigate"} - {:kind :hiccup-text - :file "src/main/mobile/example.cljs" - :line 5 - :text "Troubleshooting steps"} - {:kind :hiccup-text - :file "src/main/mobile/example.cljs" - :line 6 - :text "Current chapter"} - {:kind :hiccup-text - :file "src/main/mobile/example.cljs" - :line 7 - :text "Writing mode"}] - findings)))) - (testing "supported text children are still reported on lines that also contain user-facing attrs" - (let [findings (lang-lint/hardcoded-string-findings - "src/main/frontend/components/example.cljs" - "(ns frontend.components.example)\n[:div {:title \"Open item\"} [:span \"Visible text\"]]\n")] - (is (= [{:kind :title - :file "src/main/frontend/components/example.cljs" - :line 2 - :text "Open item"} - {:kind :hiccup-text - :file "src/main/frontend/components/example.cljs" - :line 2 - :text "Visible text"}] - findings)))) - (testing "dynamic text and non-localizable glyphs are skipped" - (let [findings (lang-lint/hardcoded-string-findings - "src/main/frontend/components/example.cljs" - "(ns frontend.components.example)\n[:button (t :general/save)]\n[:span (str total \" items\")]\n[:button \"⌘K\"]\n[:span \"→\"]\n")] - (is (empty? findings))))) - -(deftest hardcoded-string-findings-ignore-non-ui-strings - (testing "class names and data attributes are not user-facing strings" - (let [findings (lang-lint/hardcoded-string-findings - "src/main/frontend/components/example.cljs" - "(ns frontend.components.example)\n[:div {:class \"cp__sidebar-main-content\" :data-testid \"settings-panel\"}]\n")] - (is (empty? findings))))) - -(deftest hardcoded-string-findings-ignore-commented-samples - (testing "commented hiccup examples are not source UI strings" - (let [findings (lang-lint/hardcoded-string-findings - "src/main/frontend/components/example.cljs" - "(ns frontend.components.example)\n;;[:p \"Commented text\"]\n;;[:li \"Commented item\"]\n(comment\n[:img {:alt \"Commented image\"}]\n[:p \"Commented paragraph\"])\n")] - (is (empty? findings))))) - (deftest translation-placeholders-detect-placeholder-sets (is (= #{"1" "2"} (lang-lint/translation-placeholders "Open {1} from {2}"))) (is (= #{} (lang-lint/translation-placeholders "Search with Google")))) -(deftest conditional-translation-keys-detect-dynamic-i18n-branches - (let [content "(t (if public? :page/make-private :page/make-public))\n{:payload {:i18n-key (if delete? :outliner/cant-remove-tag-built-in :outliner/cant-set-tag-built-in)}}\n"] - (is (= #{:page/make-private - :page/make-public - :outliner/cant-remove-tag-built-in - :outliner/cant-set-tag-built-in} - (lang-lint/conditional-translation-keys content))))) - -(deftest conditional-translation-keys-detect-local-i18n-key-bindings - (let [content "(let [i18n-key (if (:logseq.property/created-from-property block)\n:outliner/cant-convert-property-value-to-page\n:outliner/cant-convert-block-parent-not-page)])"] - (is (= #{:outliner/cant-convert-property-value-to-page - :outliner/cant-convert-block-parent-not-page} - (lang-lint/conditional-translation-keys content))))) - -(deftest translation-call-fallback-keys-detect-default-or-branches - (let [content "(t (or title-key :views.table/default-title) props)"] - (is (= #{:views.table/default-title} - (lang-lint/translation-call-fallback-keys content))))) - -(deftest option-translation-keys-detect-literal-config-keys - (let [content "{:prompt-key :graph.switch/select-prompt}\n{:title-key :page/table-title}\n"] - (is (= #{:graph.switch/select-prompt} - (lang-lint/option-translation-keys content :prompt-key))) - (is (= #{:page/table-title} - (lang-lint/option-translation-keys content :title-key))))) - -(deftest built-in-color-keys-detect-color-translations - (let [content "(def built-in-colors\n[\"yellow\"\n\"red\"\n\"gray\"])"] - (is (= #{:color/yellow :color/red :color/gray} - (lang-lint/built-in-color-keys content))))) - -(deftest left-sidebar-translation-keys-detect-nav-derived-keys - (let [content "(let [navs [:flashcards :all-pages :graph-view :tag/tasks :tag/assets]])"] - (is (= #{:nav/tasks - :nav/assets} - (lang-lint/left-sidebar-translation-keys content))))) - -(deftest date-nlp-translation-keys-detect-derived-date-labels - (let [content "(def nlp-pages [\"Today\" \"Last Monday\" \"Next Week\"])"] - (is (= #{:date.nlp/today - :date.nlp/last-monday - :date.nlp/next-week} - (lang-lint/date-nlp-translation-keys content))))) - -(deftest built-in-db-ident-translation-keys-detect-built-in-property-and-class-labels - (let [content "(def ^:large-vars/data-var built-in-classes - {:logseq.class/Task {:title \"Task\"}}) - (def ^:large-vars/data-var built-in-properties - {:block/alias {:title \"Alias\"} - :logseq.property/status {:title \"Status\"} - :logseq.property.repeat/recur-unit {:closed-values [[:logseq.property.repeat/recur-unit.day \"Day\"]]} - :logseq.property/view/type {:closed-values [[:logseq.property.view/type.table \"Table View\"]]} - :logseq.property/status.backlog {:title \"Backlog\"}})"] - (is (= #{:class.built-in/task - :property.built-in/alias - :property.built-in/repeat-recur-unit - :property.built-in/status - :property.status/backlog - :property.repeat-recur-unit/day - :property.view-type/table} - (lang-lint/built-in-db-ident-translation-keys content))))) - -(deftest shortcut-command-keys-detect-command-translations-from-shortcut-ids - (let [content ":window/close {:binding \"mod+w\"}\n:editor/copy {:binding \"mod+c\"}\n"] - (is (= #{:command.window/close - :command.editor/copy} - (lang-lint/shortcut-command-keys content))))) - -(deftest shortcut-command-keys-ignore-shortcut-handler-groups - (let [content ":shortcut.handler/misc {:misc/copy (:misc/copy all-built-in-keyboard-shortcuts)}"] - (is (empty? (lang-lint/shortcut-command-keys content))))) - -(deftest shortcut-command-keys-ignore-exempt-cards-shortcuts-only - (let [content ":cards/toggle-answers {:binding \"s\"}\n:cards/again {:binding \"1\"}\n:cards/custom {:binding \"9\"}\n:page/toggle-favorite {:binding \"mod+shift+f\"}\n"] - (is (= #{:command.cards/custom - :command.page/toggle-favorite} - (lang-lint/shortcut-command-keys content))))) - -(deftest shortcut-category-translation-keys-detect-dynamic-category-labels - (let [content "(defonce categories\n(vector :shortcut.category/basics :shortcut.category/others))"] - (is (= #{:shortcut.category/basics - :shortcut.category/others} - (lang-lint/shortcut-category-translation-keys content))))) - -(deftest derived-translation-keys-merge-supported-dynamic-patterns - (let [content "(defonce categories (vector :shortcut.category/basics)) - (def built-in-colors [\"yellow\"]) - (def nlp-pages [\"Today\"]) - (let [navs [:flashcards :tag/tasks] - i18n-key (if delete? :outliner/cant-remove-tag-built-in :outliner/cant-set-tag-built-in)]\n - [(t (or title-key :views.table/default-title) props) - {:prompt-key :graph.switch/select-prompt - :title-key :page/table-title}])"] - (is (= #{:color/yellow - :date.nlp/today - :shortcut.category/basics - :nav/tasks - :outliner/cant-remove-tag-built-in - :outliner/cant-set-tag-built-in - :graph.switch/select-prompt - :page/table-title - :views.table/default-title} - (lang-lint/derived-translation-keys content))))) - (deftest placeholder-mismatch-findings-detect-non-default-locale-errors (testing "a localized value must match English placeholders exactly once it is defined" (let [findings (lang-lint/placeholder-mismatch-findings {:en {:electron/link-open-confirm "Are you sure?\n\n{1}" :electron/write-file-failed-with-backup "Write failed {1} {2} {3}."} :fr {:electron/link-open-confirm "Voulez-vous ouvrir ce lien externe ?" - :electron/write-file-failed-with-backup "Échec de l'écriture. Sauvegarde : {1}"} + :electron/write-file-failed-with-backup "Echec de l'ecriture. Sauvegarde : {1}"} :zh-CN {:electron/link-open-confirm "确定要打开此链接吗?\n\n{1}" :electron/write-file-failed-with-backup "写入文件 {1} 失败,{2}。备份文件已保存到 {3}。"}})] (is (= [{:lang :fr @@ -255,7 +28,7 @@ :expected-placeholders ["1" "2" "3"] :actual-placeholders ["1"] :default-value "Write failed {1} {2} {3}." - :localized-value "Échec de l'écriture. Sauvegarde : {1}"}] + :localized-value "Echec de l'ecriture. Sauvegarde : {1}"}] findings))))) (deftest translation-rich-validation-findings-report-rich-contract-mismatches