mirror of
https://github.com/logseq/logseq.git
synced 2026-05-17 01:12:28 +00:00
refactor(lang-lint): leverage logseq-i18n-lint for superior accuracy and performance
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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
|
||||
#"(?<!\[):(placeholder|title|aria-label|label|alt)\s+\"([^\"\n]+)\"")
|
||||
|
||||
;; Matches literal text children in supported Hiccup tags, including tag
|
||||
;; shorthand such as `:div.foo` or `:button#id`.
|
||||
(def ^:private hiccup-text-pattern
|
||||
#"\[:(?:button|span|div|label|a|p|small|strong|li|h1|h2|h3|h4|h5|h6)(?:[#.][^\"\s\[\{]+)*\s+\"([A-Za-z][^\"\n]*)\"")
|
||||
(ns logseq.tasks.lang-lint)
|
||||
|
||||
;; Matches numbered placeholders like `{1}` in translation strings.
|
||||
(def ^:private translation-placeholder-pattern
|
||||
#"\{(\d+)\}")
|
||||
|
||||
;; Matches keyword literals embedded in source text.
|
||||
(def ^:private translation-key-pattern
|
||||
#":[^ )}\],\s]+")
|
||||
|
||||
;; Matches translation calls whose first argument starts with an `if` form.
|
||||
(def ^:private translation-call-if-prefix-pattern
|
||||
#"\((?:[[:alnum:]._-]+/)?tt?\s+\(if\b")
|
||||
|
||||
;; Matches translation calls whose first argument starts with an `or` form.
|
||||
(def ^:private translation-call-or-prefix-pattern
|
||||
#"\((?:[[:alnum:]._-]+/)?tt?\s+\(or\b")
|
||||
|
||||
;; Matches literal `i18n-key` assignments backed by an `if` form.
|
||||
(def ^:private i18n-key-if-prefix-pattern
|
||||
#":?i18n-key\s+\(if\b")
|
||||
|
||||
;; Matches the `built-in-colors` vector body for later string extraction.
|
||||
(def ^:private built-in-colors-pattern
|
||||
#"(?s)\(def\s+built-in-colors\s+\[(.*?)\]\)")
|
||||
|
||||
;; Matches quoted string literals inside extracted list or vector content.
|
||||
(def ^:private string-literal-pattern
|
||||
#"\"([^\"]+)\"")
|
||||
|
||||
;; Matches the left sidebar `navs` vector body for derived key extraction.
|
||||
(def ^:private left-sidebar-navs-pattern
|
||||
#"(?s)\bnavs\s+\[(.*?)\]")
|
||||
|
||||
;; Matches the `nlp-pages` vector body for derived date NLP translation keys.
|
||||
(def ^:private date-nlp-pages-pattern
|
||||
#"(?s)\(def\s+nlp-pages\s+\[(.*?)\]\)")
|
||||
|
||||
;; Matches built-in property/class definition forms for db-ident derived keys.
|
||||
(def ^:private built-in-properties-form-pattern
|
||||
#"\(def\s+\^:large-vars/data-var\s+built-in-properties\b")
|
||||
|
||||
(def ^:private built-in-classes-form-pattern
|
||||
#"\(def\s+\^:large-vars/data-var\s+built-in-classes\b")
|
||||
|
||||
;; Matches `:tag/*` entries inside left sidebar nav definitions.
|
||||
(def ^:private tag-nav-key-pattern
|
||||
#":tag/([A-Za-z][A-Za-z0-9._-]*)")
|
||||
|
||||
;; Matches namespaced built-in shortcut ids declared as map keys.
|
||||
(def ^:private shortcut-command-id-pattern
|
||||
#"(?m)^\s*\{?:([A-Za-z0-9._-]+)/([A-Za-z0-9._-]+)\s+\{")
|
||||
|
||||
;; Built-in card review shortcuts are local-only actions whose visible labels
|
||||
;; come from flashcard UI state instead of `:command.*` descriptions.
|
||||
(def ^:private shortcut-command-translation-exemptions
|
||||
#{:cards/again
|
||||
:cards/easy
|
||||
:cards/good
|
||||
:cards/hard
|
||||
:cards/toggle-answers})
|
||||
|
||||
;; Matches literal shortcut category translation keys.
|
||||
(def ^:private shortcut-category-key-pattern
|
||||
#":shortcut\.category/[A-Za-z0-9._-]+")
|
||||
|
||||
;; Matches FSRS flashcard rating translation keys derived with
|
||||
;; `(keyword "flashcard.rating" ...)`.
|
||||
(def ^:private flashcard-rating-label-keyword-pattern
|
||||
#"\(keyword\s+\"flashcard\.rating\"\s+\(name\s+[^\)]+\)\)")
|
||||
|
||||
(def ^:private flashcard-rating-desc-keyword-pattern
|
||||
#"\(keyword\s+\"flashcard\.rating\"\s+\(str\s+\(name\s+[^\)]+\)\s+\"-desc\"\)\)")
|
||||
|
||||
;; Matches the start of `(comment ...)` forms.
|
||||
(def ^:private comment-form-prefix-pattern
|
||||
#"\(comment\b")
|
||||
|
||||
;; Lists literal strings that are intentionally excluded from hardcoded UI findings.
|
||||
(def ^:private ignored-hardcoded-texts
|
||||
#{"Logseq"
|
||||
"Logseq "
|
||||
"ID: "
|
||||
"http://"
|
||||
"https://"
|
||||
"{}"
|
||||
"Ag"
|
||||
"git commit -m ..."})
|
||||
|
||||
(defn- make-finding
|
||||
[kind file-path line text]
|
||||
{:kind kind
|
||||
:file file-path
|
||||
:line line
|
||||
:text text})
|
||||
|
||||
(defn- ignorable-hardcoded-text?
|
||||
[text]
|
||||
(contains? ignored-hardcoded-texts text))
|
||||
|
||||
(defn- line-findings
|
||||
[file-path line-number line commented-lines]
|
||||
(if (or (commented-lines line-number)
|
||||
(string/starts-with? (string/triml line) ";"))
|
||||
[]
|
||||
(let [notification-findings
|
||||
(for [[_ text] (re-seq notification-pattern line)]
|
||||
(when-not (ignorable-hardcoded-text? text)
|
||||
(make-finding :notification file-path line-number text)))
|
||||
attr-findings
|
||||
(for [[_ attr text] (re-seq user-facing-attr-pattern line)]
|
||||
(when-not (ignorable-hardcoded-text? text)
|
||||
(make-finding (keyword attr) file-path line-number text)))
|
||||
hiccup-findings
|
||||
(for [[_ text] (re-seq hiccup-text-pattern line)]
|
||||
(when-not (ignorable-hardcoded-text? text)
|
||||
(make-finding :hiccup-text file-path line-number text)))]
|
||||
(into [] (remove nil? (concat notification-findings attr-findings hiccup-findings))))))
|
||||
|
||||
(declare comment-form-lines)
|
||||
|
||||
(defn hardcoded-string-findings
|
||||
"Return user-facing hardcoded string findings for `content`.
|
||||
|
||||
Each finding contains `:kind`, `:file`, `:line`, and `:text`."
|
||||
[file-path content]
|
||||
(let [commented-lines (comment-form-lines content)]
|
||||
(->> (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))
|
||||
|
||||
Reference in New Issue
Block a user