react-autolocale 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  import { t as BANNER_EN } from "./suggestion-CkZQqMvZ.js";
2
2
  import { r as PROVIDER_NAMES } from "./config-ChdN65Rg.js";
3
- import path from "node:path";
4
3
  import fs from "node:fs";
4
+ import path from "node:path";
5
5
  import { spawnSync } from "node:child_process";
6
6
  import { createRequire } from "node:module";
7
7
  import os from "node:os";
@@ -10,8 +10,8 @@ import { createHash } from "node:crypto";
10
10
  import { JSDOM } from "jsdom";
11
11
  import { parse } from "@babel/parser";
12
12
  import _traverse from "@babel/traverse";
13
- import _generate from "@babel/generator";
14
13
  import * as t from "@babel/types";
14
+ import _generate from "@babel/generator";
15
15
  const byCode = new Map([
16
16
  {
17
17
  code: "en",
@@ -661,8 +661,208 @@ function translateHead(doc, dict) {
661
661
  }
662
662
  }
663
663
  //#endregion
664
+ //#region src/core/walk.ts
665
+ const EXTENSIONS = /* @__PURE__ */ new Set([
666
+ ".tsx",
667
+ ".jsx",
668
+ ".ts",
669
+ ".js"
670
+ ]);
671
+ const IGNORED_DIRS = /* @__PURE__ */ new Set([
672
+ "node_modules",
673
+ "dist",
674
+ "build",
675
+ "coverage"
676
+ ]);
677
+ function isSourceFile(file) {
678
+ return EXTENSIONS.has(path.extname(file)) && !file.endsWith(".d.ts") && !/\.(test|spec|stories|story)\./.test(file);
679
+ }
680
+ /** Source files under `dir`. `skip` lists absolute folders to leave out (the generated catalogs, internal metadata). */
681
+ function* walk(dir, skip = [], matches = isSourceFile) {
682
+ if (!fs.existsSync(dir)) return;
683
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
684
+ if (entry.name.startsWith(".") || IGNORED_DIRS.has(entry.name)) continue;
685
+ const full = path.join(dir, entry.name);
686
+ if (skip.some((d) => path.resolve(d) === full)) continue;
687
+ if (entry.isDirectory()) yield* walk(full, skip, matches);
688
+ else if (matches(full)) yield full;
689
+ }
690
+ }
691
+ //#endregion
692
+ //#region src/core/classify.ts
693
+ /**
694
+ * Decides whether a string written in code is text a person reads, or a code value (a URL, a class name, a CSS
695
+ * value, an identifier, ...). Text in JSX and in known display attributes is always translated; this is for
696
+ * strings found in data, props, ternaries and the like, where being wrong in either direction matters.
697
+ */
698
+ const NOT_COPY_WORDS = /* @__PURE__ */ new Set([
699
+ "GET",
700
+ "POST",
701
+ "PUT",
702
+ "PATCH",
703
+ "DELETE",
704
+ "HEAD",
705
+ "OPTIONS",
706
+ "JSON",
707
+ "UTF-8",
708
+ "ASC",
709
+ "DESC",
710
+ "NULL",
711
+ "TRUE",
712
+ "FALSE",
713
+ "LTR",
714
+ "RTL",
715
+ "CSS",
716
+ "HTML",
717
+ "NONE",
718
+ "AUTO",
719
+ "DEFAULT",
720
+ "UNKNOWN",
721
+ "ERROR",
722
+ "SUCCESS",
723
+ "WARNING",
724
+ "INFO",
725
+ "DEBUG"
726
+ ]);
727
+ /** Letters in any script. */
728
+ const HAS_LETTER = /\p{L}/u;
729
+ /** A single token: no spaces. */
730
+ function singleToken(text) {
731
+ if (/^\p{Lu}\p{Ll}+$/u.test(text)) return true;
732
+ if (/^\p{Lu}{2,}$/u.test(text) && !NOT_COPY_WORDS.has(text)) return true;
733
+ if (/[^\x00-\x7f]/.test(text) && !/[_$\-./:]/.test(text) && !/\d/.test(text)) return true;
734
+ return false;
735
+ }
736
+ function looksLikeCopy(raw) {
737
+ const text = raw.trim();
738
+ if (text.length < 2 || !HAS_LETTER.test(text)) return false;
739
+ if (/^(?:https?:|ftp:|mailto:|tel:|sms:|data:|blob:|file:|javascript:|\/\/|\/|\.{1,2}\/|#|@|~\/)/i.test(text)) return false;
740
+ if (/:\/\//.test(text)) return false;
741
+ if (/^[\w.+-]+@[\w-]+\.[\w.-]+$/.test(text)) return false;
742
+ if (!/\s/.test(text) && /^[\w\-./@]+\.[a-z0-9]{1,5}$/i.test(text)) return false;
743
+ if (/^[a-z]+\/[a-z0-9.+-]+$/i.test(text)) return false;
744
+ if (/^[a-z]{2,3}-[A-Z][A-Za-z]{1,3}$/.test(text)) return false;
745
+ if (/^(?:rgba?|hsla?|var|calc|url|translate[XYZ3d]*|rotate[XYZ]?|scale[XYZ]?|matrix|linear-gradient|radial-gradient|conic-gradient|cubic-bezier|steps|polygon|inset|circle|ellipse)\(/i.test(text)) return false;
746
+ if (/(?:sans-serif|serif|monospace|system-ui|cursive)\b/i.test(text)) return false;
747
+ if (/^[\d.]+(?:px|rem|em|vh|vw|vmin|vmax|ms|s|deg|fr|ch|pt|cm|mm)(?:\s|$)/i.test(text)) return false;
748
+ if (/^[MmLlHhVvCcSsQqTtAaZz]\s*-?[\d.]/.test(text)) return false;
749
+ if (/[\w-]+\s*:\s*[^;]+;/.test(text)) return false;
750
+ if (/=>|<\/?[a-z][\w-]*[\s>/]|\{\s*\}|\(\s*\)\s*;|^function\b|^import\s|^const\s/.test(text)) return false;
751
+ if (/^[YMDHhmsaAZ\-/:.\s,]+$/.test(text)) return false;
752
+ if (!/\s/.test(text)) return singleToken(text.replace(/^[("“'\[]+|[.…!?:;,)"”'\]]+$/g, ""));
753
+ const tokens = text.split(/\s+/);
754
+ if (tokens.every((w) => /^[a-z][a-z0-9_:\-\/\[\].%!#]*$/.test(w)) && tokens.some((w) => /[-:_\[\]\/%!#\d]/.test(w))) return false;
755
+ if (tokens.length <= 3 && tokens.every((w) => /^[a-z][a-z0-9]*$|[a-z][A-Z]|_/.test(w) && /^[A-Za-z_$][\w$.]*$/.test(w)) && tokens.some((w) => /[a-z][A-Z]|_/.test(w))) return false;
756
+ return true;
757
+ }
758
+ /** Attributes on any element or component whose value is code, never text. */
759
+ const NON_DISPLAY_ATTRS = /* @__PURE__ */ new Set([
760
+ "className",
761
+ "class",
762
+ "classes",
763
+ "style",
764
+ "id",
765
+ "key",
766
+ "ref",
767
+ "href",
768
+ "src",
769
+ "srcSet",
770
+ "srcset",
771
+ "to",
772
+ "path",
773
+ "htmlFor",
774
+ "type",
775
+ "rel",
776
+ "target",
777
+ "method",
778
+ "action",
779
+ "lang",
780
+ "dir",
781
+ "charSet",
782
+ "value",
783
+ "defaultValue",
784
+ "checked",
785
+ "d",
786
+ "viewBox",
787
+ "fill",
788
+ "stroke",
789
+ "transform",
790
+ "xmlns",
791
+ "width",
792
+ "height",
793
+ "size",
794
+ "variant",
795
+ "color",
796
+ "align",
797
+ "position",
798
+ "mode",
799
+ "kind",
800
+ "intent",
801
+ "tone",
802
+ "as",
803
+ "icon",
804
+ "iconName",
805
+ "image",
806
+ "img",
807
+ "src2",
808
+ "autoComplete",
809
+ "inputMode",
810
+ "accept",
811
+ "pattern",
812
+ "media",
813
+ "sizes",
814
+ "loading",
815
+ "crossOrigin",
816
+ "decoding",
817
+ "preserveAspectRatio",
818
+ "fontFamily",
819
+ "queryKey",
820
+ "testId",
821
+ "locale",
822
+ "format",
823
+ "selector",
824
+ "event"
825
+ ]);
826
+ /** Object keys whose value is code, never text. */
827
+ const NON_COPY_KEYS = /* @__PURE__ */ new Set([
828
+ ...NON_DISPLAY_ATTRS,
829
+ "url",
830
+ "uri",
831
+ "route",
832
+ "slug",
833
+ "logo",
834
+ "bg",
835
+ "background",
836
+ "colour",
837
+ "font",
838
+ "transition",
839
+ "animation",
840
+ "display",
841
+ "cursor",
842
+ "overflow",
843
+ "justify",
844
+ "points",
845
+ "mask",
846
+ "filter",
847
+ "clipPath",
848
+ "mime",
849
+ "regex",
850
+ "hash",
851
+ "token",
852
+ "secret",
853
+ "apiKey",
854
+ "env",
855
+ "mutationKey",
856
+ "values",
857
+ "option",
858
+ "initialValue",
859
+ "labelKey",
860
+ "valueKey",
861
+ "imageUrl"
862
+ ]);
863
+ //#endregion
664
864
  //#region src/core/transform.ts
665
- const traverse$1 = _traverse.default ?? _traverse;
865
+ const traverse$2 = _traverse.default ?? _traverse;
666
866
  const generate = _generate.default ?? _generate;
667
867
  /** Attributes whose string value is user-visible text. */
668
868
  const TRANSLATABLE_ATTRS = /* @__PURE__ */ new Set([
@@ -688,7 +888,155 @@ const T_IMPORT = "__RLT";
688
888
  const TD_IMPORT = "__RLTD";
689
889
  const HOOK_IMPORT = "__rlUseT";
690
890
  const HOOK_VAR = "__rl";
891
+ const GLOBAL_TEXT = "__rlText";
892
+ const GLOBAL_FMT = "__rlFmt";
691
893
  const RUNTIME = "react-autolocale";
894
+ /** Calls whose string arguments are code: selectors, event names, storage keys, paths, class helpers, logging, analytics. */
895
+ const NON_COPY_CALLS = /* @__PURE__ */ new Set([
896
+ "require",
897
+ "import",
898
+ "fetch",
899
+ "axios",
900
+ "get",
901
+ "post",
902
+ "put",
903
+ "patch",
904
+ "delete",
905
+ "request",
906
+ "querySelector",
907
+ "querySelectorAll",
908
+ "getElementById",
909
+ "getElementsByClassName",
910
+ "getElementsByTagName",
911
+ "getAttribute",
912
+ "setAttribute",
913
+ "removeAttribute",
914
+ "hasAttribute",
915
+ "closest",
916
+ "matches",
917
+ "matchMedia",
918
+ "addEventListener",
919
+ "removeEventListener",
920
+ "dispatchEvent",
921
+ "emit",
922
+ "on",
923
+ "off",
924
+ "once",
925
+ "createElementNS",
926
+ "classList",
927
+ "add",
928
+ "remove",
929
+ "toggle",
930
+ "contains",
931
+ "clsx",
932
+ "classnames",
933
+ "classNames",
934
+ "cn",
935
+ "cx",
936
+ "twMerge",
937
+ "tw",
938
+ "css",
939
+ "styled",
940
+ "keyframes",
941
+ "createContext",
942
+ "getItem",
943
+ "setItem",
944
+ "removeItem",
945
+ "stringify",
946
+ "parse",
947
+ "parseInt",
948
+ "parseFloat",
949
+ "Number",
950
+ "String",
951
+ "Boolean",
952
+ "Date",
953
+ "URL",
954
+ "URLSearchParams",
955
+ "RegExp",
956
+ "Symbol",
957
+ "Intl",
958
+ "NumberFormat",
959
+ "DateTimeFormat",
960
+ "split",
961
+ "join",
962
+ "replace",
963
+ "replaceAll",
964
+ "includes",
965
+ "indexOf",
966
+ "lastIndexOf",
967
+ "startsWith",
968
+ "endsWith",
969
+ "match",
970
+ "matchAll",
971
+ "search",
972
+ "test",
973
+ "exec",
974
+ "localeCompare",
975
+ "padStart",
976
+ "padEnd",
977
+ "repeat",
978
+ "normalize",
979
+ "charAt",
980
+ "log",
981
+ "warn",
982
+ "error",
983
+ "info",
984
+ "debug",
985
+ "trace",
986
+ "assert",
987
+ "table",
988
+ "group",
989
+ "defineProperty",
990
+ "hasOwnProperty",
991
+ "has",
992
+ "set",
993
+ "oneOf",
994
+ "oneOfType",
995
+ "enum",
996
+ "literal",
997
+ "nativeEnum",
998
+ "useQuery",
999
+ "useQueries",
1000
+ "useInfiniteQuery",
1001
+ "useMutation",
1002
+ "invalidateQueries",
1003
+ "setQueryData",
1004
+ "getQueryData",
1005
+ "prefetchQuery",
1006
+ "track",
1007
+ "trackEvent",
1008
+ "logEvent",
1009
+ "capture",
1010
+ "captureMessage",
1011
+ "captureException",
1012
+ "gtag",
1013
+ "navigate",
1014
+ "redirect",
1015
+ "createSlice",
1016
+ "createAction",
1017
+ "createAsyncThunk",
1018
+ "registerPlugin",
1019
+ "lazy",
1020
+ "loadScript",
1021
+ "useRef",
1022
+ "useId",
1023
+ "useMedia"
1024
+ ]);
1025
+ /** Variable names that hold code. */
1026
+ const NON_COPY_VARS = /(?:class|style|url|href|src|path|color|font|slug|route|selector|regex|pattern|format|mime|token|secret|env|event|icon|image|img|logo|key|id)s?$/i;
1027
+ const COMPARISON_OPS = /* @__PURE__ */ new Set([
1028
+ "===",
1029
+ "!==",
1030
+ "==",
1031
+ "!=",
1032
+ "<",
1033
+ ">",
1034
+ "<=",
1035
+ ">=",
1036
+ "in",
1037
+ "instanceof"
1038
+ ]);
1039
+ const IGNORE_COMMENT = /(?:i18n|autolocale|auto-locale)[- ]ignore|translate\s*:\s*no/i;
692
1040
  function parserPlugins(file) {
693
1041
  if (/\.tsx$/.test(file)) return ["typescript", "jsx"];
694
1042
  if (/\.ts$/.test(file)) return ["typescript"];
@@ -718,25 +1066,137 @@ function isTranslatable(text) {
718
1066
  function isComponentName(name) {
719
1067
  return !!name && /^[A-Z]/.test(name);
720
1068
  }
721
- /** Finds the function that is a React component (PascalCase name, possibly wrapped in memo/forwardRef). */
1069
+ /** A function that may call hooks: a component, or a custom hook named useX. */
1070
+ function canCallHooks(name) {
1071
+ return isComponentName(name) || !!name && /^use[A-Z0-9]/.test(name);
1072
+ }
1073
+ function functionName(fn) {
1074
+ if ((fn.node.type === "FunctionDeclaration" || fn.node.type === "FunctionExpression") && fn.node.id) return fn.node.id.name;
1075
+ let up = fn.parentPath;
1076
+ while (up && up.isCallExpression()) up = up.parentPath;
1077
+ if (up?.isVariableDeclarator() && t.isIdentifier(up.node.id)) return up.node.id.name;
1078
+ }
1079
+ /**
1080
+ * The component (or custom hook) whose body contains `path`, so a translation hook can be added to it. Callbacks
1081
+ * inside a component count; a default parameter value does not (it runs before the hook line), and neither does
1082
+ * module-level code: those use the global lookup instead.
1083
+ */
722
1084
  function enclosingComponent(path) {
723
- let p = path.parentPath;
724
- while (p) {
725
- if (p.isFunction()) {
726
- const fn = p;
727
- let name;
728
- if ((fn.node.type === "FunctionDeclaration" || fn.node.type === "FunctionExpression") && fn.node.id) name = fn.node.id.name;
729
- if (!name) {
730
- let up = fn.parentPath;
731
- while (up && up.isCallExpression()) up = up.parentPath;
732
- if (up?.isVariableDeclarator() && t.isIdentifier(up.node.id)) name = up.node.id.name;
733
- }
734
- if (isComponentName(name)) return fn;
735
- }
736
- p = p.parentPath;
1085
+ let prev = path;
1086
+ for (let p = path.parentPath; p; prev = p, p = p.parentPath) {
1087
+ if (!p.isFunction()) continue;
1088
+ if (prev.listKey === "params") return null;
1089
+ if (canCallHooks(functionName(p))) return p;
737
1090
  }
738
1091
  return null;
739
1092
  }
1093
+ function calleeName(callee) {
1094
+ if (t.isIdentifier(callee)) return callee.name;
1095
+ if ((t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && !callee.computed && t.isIdentifier(callee.property)) return callee.property.name;
1096
+ return "";
1097
+ }
1098
+ const HTML_DISPLAY_ATTRS = /* @__PURE__ */ new Set([
1099
+ ...TRANSLATABLE_ATTRS,
1100
+ "aria-roledescription",
1101
+ "aria-valuetext",
1102
+ "label"
1103
+ ]);
1104
+ /** data-* attributes that tooltip libraries show to people. */
1105
+ const DISPLAY_DATA_ATTR = /^data-(?:[\w-]*(?:tooltip|tip|title|label|content))/;
1106
+ const isDisplayAttr = (name, html) => (html ? HTML_DISPLAY_ATTRS.has(name) : false) || DISPLAY_DATA_ATTR.test(name);
1107
+ /** True where the code needs the exact value of a string: comparisons, keys, types, selectors, paths, class helpers... */
1108
+ function inCodePosition(path) {
1109
+ const parent = path.parent;
1110
+ if (path.findParent((p) => p.isTSType() || p.isTSTypeAnnotation() || p.isTSInterfaceDeclaration() || p.isTSTypeAliasDeclaration() || p.isTSEnumDeclaration() || p.isTSModuleDeclaration())) return true;
1111
+ if (t.isImportDeclaration(parent) || t.isExportNamedDeclaration(parent) || t.isExportAllDeclaration(parent)) return true;
1112
+ if ((t.isObjectProperty(parent) || t.isObjectMethod(parent) || t.isClassProperty(parent) || t.isClassMethod(parent)) && path.key === "key" && !parent.computed) return true;
1113
+ if (t.isMemberExpression(parent) && parent.computed && path.key === "property") return true;
1114
+ if (t.isBinaryExpression(parent) && COMPARISON_OPS.has(parent.operator)) return true;
1115
+ if (t.isSwitchCase(parent) && path.key === "test") return true;
1116
+ if (t.isTaggedTemplateExpression(parent)) return true;
1117
+ if (t.isAssignmentExpression(parent) && path.key === "right") {
1118
+ const l = parent.left;
1119
+ const nm = t.isMemberExpression(l) && !l.computed && t.isIdentifier(l.property) ? l.property.name : "";
1120
+ if (NON_COPY_KEYS.has(nm) || nm === "cookie") return true;
1121
+ }
1122
+ let prev = path;
1123
+ for (let p = path.parentPath; p; prev = p, p = p.parentPath) {
1124
+ if (p.isFunction()) break;
1125
+ if (p.isJSXAttribute()) {
1126
+ const n = t.isJSXIdentifier(p.node.name) ? p.node.name.name : "";
1127
+ const opening = p.parent;
1128
+ const html = t.isJSXIdentifier(opening.name) && /^[a-z]/.test(opening.name.name);
1129
+ if (NON_DISPLAY_ATTRS.has(n) || /^on[A-Z]/.test(n)) return true;
1130
+ if (n.startsWith("data-") && !DISPLAY_DATA_ATTR.test(n)) return true;
1131
+ if (html && !isDisplayAttr(n, true)) return true;
1132
+ break;
1133
+ }
1134
+ if ((p.isCallExpression() || p.isNewExpression()) && prev.listKey === "arguments" && NON_COPY_CALLS.has(calleeName(p.node.callee))) return true;
1135
+ if (p.isThrowStatement()) return true;
1136
+ if (p.isObjectProperty() && prev.key === "value") {
1137
+ const k = p.node.key;
1138
+ const name = t.isIdentifier(k) ? k.name : t.isStringLiteral(k) ? k.value : "";
1139
+ if (NON_COPY_KEYS.has(name)) return true;
1140
+ }
1141
+ if (p.isVariableDeclarator() && prev.key === "init" && t.isIdentifier(p.node.id) && NON_COPY_VARS.test(p.node.id.name)) return true;
1142
+ if (p.isBinaryExpression() && COMPARISON_OPS.has(p.node.operator)) return true;
1143
+ }
1144
+ return false;
1145
+ }
1146
+ /** Hook calls whose arguments are read once or kept between renders, so a hook-based lookup would go stale. */
1147
+ const DEFERRED_HOOKS = /* @__PURE__ */ new Set([
1148
+ "useState",
1149
+ "useReducer",
1150
+ "useRef",
1151
+ "useMemo",
1152
+ "useCallback",
1153
+ "useEffect",
1154
+ "useLayoutEffect",
1155
+ "useInsertionEffect",
1156
+ "useImperativeHandle",
1157
+ "useSyncExternalStore",
1158
+ "useTransition",
1159
+ "useDeferredValue",
1160
+ "createContext",
1161
+ "createRef",
1162
+ "memo",
1163
+ "forwardRef"
1164
+ ]);
1165
+ /** Is the string inside the arguments of one of those calls (up to the component that owns it)? */
1166
+ function inDeferredHookArgs(path) {
1167
+ for (let p = path.parentPath; p; p = p.parentPath) {
1168
+ if (p.isCallExpression() && DEFERRED_HOOKS.has(calleeName(p.node.callee))) return true;
1169
+ if (p.isFunction() && canCallHooks(functionName(p))) break;
1170
+ }
1171
+ return false;
1172
+ }
1173
+ /** `// i18n-ignore` (or autolocale-ignore) on the string or its statement leaves it alone. */
1174
+ function hasIgnoreComment(path) {
1175
+ for (let p = path; p; p = p.parentPath) {
1176
+ const n = p.node;
1177
+ if ([...n.leadingComments ?? [], ...n.trailingComments ?? []].some((c) => IGNORE_COMMENT.test(c.value))) return true;
1178
+ if (p.isStatement()) break;
1179
+ }
1180
+ return false;
1181
+ }
1182
+ /** The element, attribute, property or call a string belongs to, so engines can translate it with context. */
1183
+ function contextOf(path) {
1184
+ for (let p = path.parentPath; p; p = p.parentPath) {
1185
+ if (p.isJSXAttribute() && t.isJSXIdentifier(p.node.name)) return p.node.name.name;
1186
+ if (p.isObjectProperty()) {
1187
+ const k = p.node.key;
1188
+ if (t.isIdentifier(k)) return k.name;
1189
+ if (t.isStringLiteral(k)) return k.value;
1190
+ }
1191
+ if (p.isJSXElement() && t.isJSXIdentifier(p.node.openingElement.name)) return p.node.openingElement.name.name;
1192
+ if (p.isCallExpression()) {
1193
+ const n = calleeName(p.node.callee);
1194
+ if (n) return n;
1195
+ }
1196
+ if (p.isFunction()) break;
1197
+ }
1198
+ return "text";
1199
+ }
740
1200
  /** `translate="no"` on an element (or any ancestor) keeps its text and attributes exactly as written. */
741
1201
  function hasNoTranslate(opening) {
742
1202
  return opening.attributes.some((a) => t.isJSXAttribute(a) && t.isJSXIdentifier(a.name, { name: "translate" }) && (t.isStringLiteral(a.value, { value: "no" }) || t.isJSXExpressionContainer(a.value) && t.isStringLiteral(a.value.expression, { value: "no" })));
@@ -745,43 +1205,78 @@ function insideNoTranslate(path) {
745
1205
  for (let p = path; p; p = p.parentPath) if (p.isJSXElement() && hasNoTranslate(p.node.openingElement)) return true;
746
1206
  return false;
747
1207
  }
748
- /** A value we can safely hand to the runtime: `name`, `user.name`, `this.props.count`, `items?.[0]`. */
1208
+ /** A readable placeholder name for `name`, `user.name`, `this.props.count`; null for anything else. */
749
1209
  function simpleValueName(node) {
750
1210
  const root = (n) => t.isIdentifier(n) || t.isThisExpression(n) || (t.isMemberExpression(n) || t.isOptionalMemberExpression(n)) && root(n.object) && (n.computed ? t.isNumericLiteral(n.property) || t.isStringLiteral(n.property) : t.isIdentifier(n.property));
751
1211
  if (!root(node)) return null;
752
1212
  if (t.isIdentifier(node)) return node.name;
753
1213
  if ((t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) && !node.computed && t.isIdentifier(node.property)) return node.property.name;
754
- return "value";
1214
+ return null;
1215
+ }
1216
+ /** Hands out placeholder names: readable ones where possible ({name}), otherwise {1}, {2}, ... */
1217
+ function namer() {
1218
+ const used = /* @__PURE__ */ new Set();
1219
+ let n = 0;
1220
+ return (expr) => {
1221
+ const base = simpleValueName(expr);
1222
+ if (base) {
1223
+ let name = base;
1224
+ for (let i = 2; used.has(name); i++) name = `${base}_${i}`;
1225
+ used.add(name);
1226
+ return name;
1227
+ }
1228
+ let name;
1229
+ do
1230
+ name = String(++n);
1231
+ while (used.has(name));
1232
+ used.add(name);
1233
+ return name;
1234
+ };
1235
+ }
1236
+ /** Does this JSX element show any text of its own (as opposed to being an icon, image or spacer)? */
1237
+ function carriesText(el) {
1238
+ return el.children.some((c) => t.isJSXText(c) && isTranslatable(cleanJsxText(c.value)) || t.isJSXExpressionContainer(c) && !t.isJSXEmptyExpression(c.expression) && !t.isStringLiteral(c.expression) || t.isJSXElement(c) && carriesText(c));
755
1239
  }
756
1240
  /**
757
- * "Hello {name}!" -> template "Hello {name}!" plus the values to fill in at runtime.
758
- * Only text mixed with plain variables qualifies: no nested elements, calls, conditions or JSX.
1241
+ * A sentence made of text plus variables, links, bold words or other elements becomes one message with
1242
+ * placeholders: "Read the {1} now", "Welcome, {name}". The pieces keep their own markup and are filled in at
1243
+ * runtime, so word order can change per language. Elements that only decorate (icons, images) stay out of it.
759
1244
  */
760
1245
  function buildTemplate(node) {
761
1246
  let text = "";
762
1247
  const values = [];
763
- const used = /* @__PURE__ */ new Set();
764
- for (const child of node.children) if (t.isJSXText(child)) text += cleanJsxText(child.value);
765
- else if (t.isJSXExpressionContainer(child)) {
1248
+ const name = namer();
1249
+ let hasTextRun = false;
1250
+ let hasMoving = false;
1251
+ for (const child of node.children) if (t.isJSXText(child)) {
1252
+ const run = cleanJsxText(child.value);
1253
+ if (isTranslatable(run)) hasTextRun = true;
1254
+ text += run;
1255
+ } else if (t.isJSXExpressionContainer(child)) {
766
1256
  const e = child.expression;
767
1257
  if (t.isJSXEmptyExpression(e)) continue;
768
1258
  if (t.isStringLiteral(e)) {
769
1259
  text += e.value;
770
1260
  continue;
771
1261
  }
772
- const base = simpleValueName(e);
773
- if (!base) return null;
774
- let name = base;
775
- for (let i = 2; used.has(name); i++) name = `${base}_${i}`;
776
- used.add(name);
1262
+ const n = name(e);
777
1263
  values.push({
778
- name,
1264
+ name: n,
779
1265
  expr: e
780
1266
  });
781
- text += `{${name}}`;
1267
+ text += `{${n}}`;
1268
+ hasMoving = true;
1269
+ } else if (t.isJSXElement(child) || t.isJSXFragment(child)) {
1270
+ const n = name(child);
1271
+ values.push({
1272
+ name: n,
1273
+ expr: child
1274
+ });
1275
+ text += `{${n}}`;
1276
+ if (carriesText(child)) hasMoving = true;
782
1277
  } else return null;
783
1278
  const letters = text.replace(/\{\w+\}/g, "");
784
- if (!values.length || !isTranslatable(letters)) return null;
1279
+ if (!values.length || !hasTextRun || !hasMoving || !isTranslatable(letters)) return null;
785
1280
  return {
786
1281
  text: text.trim(),
787
1282
  values,
@@ -789,10 +1284,11 @@ function buildTemplate(node) {
789
1284
  trail: /\s$/.test(text)
790
1285
  };
791
1286
  }
792
- /** Parses a file, extracts translatable strings, and rewrites JSX to render translations. */
1287
+ /** Parses a file, extracts translatable strings, and rewrites the code to look them up at runtime. */
793
1288
  function transformJsx(code, file, opts = {}) {
794
1289
  const rewrite = opts.rewrite !== false;
795
- if (!code.includes("<")) return null;
1290
+ const codeValues = opts.facts?.codeValues ?? /* @__PURE__ */ new Set();
1291
+ if (!/["'`<]/.test(code)) return null;
796
1292
  let ast;
797
1293
  try {
798
1294
  ast = parse(code, {
@@ -811,33 +1307,78 @@ function transformJsx(code, file, opts = {}) {
811
1307
  contexts[text] ??= context;
812
1308
  };
813
1309
  const hookTargets = /* @__PURE__ */ new Set();
1310
+ const generated = /* @__PURE__ */ new WeakSet();
814
1311
  let usedT = false;
815
1312
  let usedTD = false;
1313
+ let usedGlobal = false;
1314
+ let usedGlobalFmt = false;
816
1315
  const space = () => t.jsxExpressionContainer(t.stringLiteral(" "));
817
- traverse$1(ast, {
1316
+ /** Local names of `msg` imported from react-autolocale: msg("text") always means "translate this". */
1317
+ const msgNames = /* @__PURE__ */ new Set();
1318
+ for (const node of ast.program.body) {
1319
+ if (!t.isImportDeclaration(node) || node.source.value !== RUNTIME) continue;
1320
+ for (const sp of node.specifiers) if (t.isImportSpecifier(sp) && (t.isIdentifier(sp.imported, { name: "msg" }) || t.isStringLiteral(sp.imported, { value: "msg" }))) msgNames.add(sp.local.name);
1321
+ }
1322
+ const lit = (v) => {
1323
+ const n = t.stringLiteral(v);
1324
+ generated.add(n);
1325
+ return n;
1326
+ };
1327
+ /** `__rl(id, text)` inside a component (follows language changes), `__rlText(id, text)` anywhere else. */
1328
+ const lookup = (path, text) => {
1329
+ const host = inDeferredHookArgs(path) ? null : enclosingComponent(path);
1330
+ if (host) {
1331
+ hookTargets.add(host);
1332
+ return t.callExpression(t.identifier(HOOK_VAR), [lit(messageId(text)), lit(text)]);
1333
+ }
1334
+ usedGlobal = true;
1335
+ return t.callExpression(t.identifier(GLOBAL_TEXT), [lit(messageId(text)), lit(text)]);
1336
+ };
1337
+ const lookupFmt = (path, text, values) => {
1338
+ const vars = t.objectExpression(values.map((v) => t.objectProperty(/^\d+$/.test(v.name) ? t.numericLiteral(Number(v.name)) : t.identifier(v.name), v.expr)));
1339
+ const host = inDeferredHookArgs(path) ? null : enclosingComponent(path);
1340
+ if (host) {
1341
+ hookTargets.add(host);
1342
+ return t.callExpression(t.memberExpression(t.identifier(HOOK_VAR), t.identifier("f")), [
1343
+ lit(messageId(text)),
1344
+ lit(text),
1345
+ vars
1346
+ ]);
1347
+ }
1348
+ usedGlobalFmt = true;
1349
+ return t.callExpression(t.identifier(GLOBAL_FMT), [
1350
+ lit(messageId(text)),
1351
+ lit(text),
1352
+ vars
1353
+ ]);
1354
+ };
1355
+ /** Common gate for a string found in code (not JSX text, which is always text). */
1356
+ const isCopyHere = (path, text) => looksLikeCopy(text) && !codeValues.has(text.trim()) && !inCodePosition(path) && !hasIgnoreComment(path) && !insideNoTranslate(path);
1357
+ traverse$2(ast, {
818
1358
  "JSXElement|JSXFragment"(path) {
819
1359
  const node = path.node;
820
1360
  const name = t.isJSXElement(node) && t.isJSXIdentifier(node.openingElement.name) ? node.openingElement.name.name : null;
821
- if (name && SKIP_ELEMENTS.has(name)) return;
1361
+ if (name && (SKIP_ELEMENTS.has(name) || name === T_IMPORT || name === TD_IMPORT)) return;
822
1362
  if (insideNoTranslate(path)) return;
823
1363
  const context = name ?? "text";
824
- if (node.children.some((c) => t.isJSXExpressionContainer(c) && !t.isJSXEmptyExpression(c.expression) && !t.isStringLiteral(c.expression))) {
1364
+ if (node.children.some((c) => t.isJSXElement(c) || t.isJSXFragment(c) || t.isJSXExpressionContainer(c) && !t.isJSXEmptyExpression(c.expression) && !t.isStringLiteral(c.expression))) {
825
1365
  const tpl = buildTemplate(node);
826
- if (!tpl) return;
827
- record(tpl.text, context);
828
- if (!rewrite) return;
829
- const valueObject = t.objectExpression(tpl.values.map((v) => t.objectProperty(t.identifier(v.name), t.cloneNode(v.expr))));
830
- node.children = [
831
- ...tpl.lead ? [space()] : [],
832
- t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier(TD_IMPORT), [
833
- t.jsxAttribute(t.jsxIdentifier("i"), t.stringLiteral(messageId(tpl.text))),
834
- t.jsxAttribute(t.jsxIdentifier("s"), t.stringLiteral(tpl.text)),
835
- t.jsxAttribute(t.jsxIdentifier("v"), t.jsxExpressionContainer(valueObject))
836
- ], true), null, []),
837
- ...tpl.trail ? [space()] : []
838
- ];
839
- usedTD = true;
840
- return;
1366
+ if (tpl) {
1367
+ record(tpl.text, context);
1368
+ if (!rewrite) return;
1369
+ const valueObject = t.objectExpression(tpl.values.map((v) => t.objectProperty(/^\d+$/.test(v.name) ? t.numericLiteral(Number(v.name)) : t.identifier(v.name), t.cloneNode(v.expr))));
1370
+ node.children = [
1371
+ ...tpl.lead ? [space()] : [],
1372
+ t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier(TD_IMPORT), [
1373
+ t.jsxAttribute(t.jsxIdentifier("i"), t.stringLiteral(messageId(tpl.text))),
1374
+ t.jsxAttribute(t.jsxIdentifier("s"), t.stringLiteral(tpl.text)),
1375
+ t.jsxAttribute(t.jsxIdentifier("v"), t.jsxExpressionContainer(valueObject))
1376
+ ], true), null, []),
1377
+ ...tpl.trail ? [space()] : []
1378
+ ];
1379
+ usedTD = true;
1380
+ return;
1381
+ }
841
1382
  }
842
1383
  const children = [];
843
1384
  let changed = false;
@@ -863,19 +1404,130 @@ function transformJsx(code, file, opts = {}) {
863
1404
  },
864
1405
  JSXAttribute(path) {
865
1406
  const { name, value } = path.node;
866
- if (!t.isJSXIdentifier(name) || !TRANSLATABLE_ATTRS.has(name.name)) return;
867
- if (!t.isStringLiteral(value)) return;
1407
+ if (!t.isJSXIdentifier(name) || !t.isStringLiteral(value)) return;
868
1408
  const opening = path.parent;
869
- if (!t.isJSXIdentifier(opening.name) || !/^[a-z]/.test(opening.name.name)) return;
870
- if (insideNoTranslate(path.parentPath.parentPath)) return;
1409
+ if (!t.isJSXIdentifier(opening.name)) return;
1410
+ const tag = opening.name.name;
1411
+ if (tag === T_IMPORT || tag === TD_IMPORT) return;
1412
+ if (insideNoTranslate(path.parentPath.parentPath) || hasIgnoreComment(path)) return;
1413
+ const html = /^[a-z]/.test(tag);
871
1414
  const text = value.value.trim();
872
1415
  if (!text || !isTranslatable(text)) return;
873
- const component = enclosingComponent(path);
874
- if (!component) return;
1416
+ const attr = (n) => opening.attributes.find((a) => t.isJSXAttribute(a) && t.isJSXIdentifier(a.name, { name: n }));
1417
+ const literalOf = (a) => a && t.isStringLiteral(a.value) ? a.value.value : void 0;
1418
+ let translate = false;
1419
+ if (html) {
1420
+ translate = isDisplayAttr(name.name, true);
1421
+ if (name.name === "value" && tag === "input" && [
1422
+ "submit",
1423
+ "button",
1424
+ "reset"
1425
+ ].includes(literalOf(attr("type")) ?? "")) translate = true;
1426
+ if (name.name === "content" && tag === "meta") {
1427
+ const who = literalOf(attr("name")) ?? literalOf(attr("property")) ?? "";
1428
+ translate = [
1429
+ "description",
1430
+ "og:title",
1431
+ "og:description",
1432
+ "twitter:title",
1433
+ "twitter:description"
1434
+ ].includes(who);
1435
+ }
1436
+ } else if (!NON_DISPLAY_ATTRS.has(name.name) && !/^on[A-Z]/.test(name.name) && !(name.name.startsWith("data-") && !DISPLAY_DATA_ATTR.test(name.name))) translate = looksLikeCopy(text) && !codeValues.has(text);
1437
+ if (!translate) return;
875
1438
  record(text, name.name);
876
1439
  if (!rewrite) return;
877
- path.node.value = t.jsxExpressionContainer(t.callExpression(t.identifier(HOOK_VAR), [t.stringLiteral(messageId(text)), t.stringLiteral(text)]));
878
- hookTargets.add(component);
1440
+ path.node.value = t.jsxExpressionContainer(lookup(path, text));
1441
+ },
1442
+ CallExpression(path) {
1443
+ const callee = path.node.callee;
1444
+ if (!t.isIdentifier(callee) || !msgNames.has(callee.name)) return;
1445
+ const [arg, vars] = path.node.arguments;
1446
+ let text = null;
1447
+ let values = [];
1448
+ let object = t.isObjectExpression(vars) ? vars : null;
1449
+ if (t.isStringLiteral(arg)) text = arg.value;
1450
+ else if (t.isTemplateLiteral(arg)) {
1451
+ const name = namer();
1452
+ text = arg.quasis[0].value.cooked ?? "";
1453
+ arg.expressions.forEach((e, i) => {
1454
+ const n = name(e);
1455
+ values.push({
1456
+ name: n,
1457
+ expr: e
1458
+ });
1459
+ text += `{${n}}` + (arg.quasis[i + 1].value.cooked ?? "");
1460
+ });
1461
+ }
1462
+ if (text === null || !/\S/.test(text)) return;
1463
+ generated.add(arg);
1464
+ record(text, "msg");
1465
+ if (!rewrite) return;
1466
+ const vs = object ? object.properties.flatMap((p) => t.isObjectProperty(p) && !p.computed && (t.isIdentifier(p.key) || t.isStringLiteral(p.key)) ? [{
1467
+ name: t.isIdentifier(p.key) ? p.key.name : p.key.value,
1468
+ expr: p.value
1469
+ }] : []) : values;
1470
+ path.replaceWith(vs.length ? lookupFmt(path, text, vs) : lookup(path, text));
1471
+ },
1472
+ StringLiteral(path) {
1473
+ if (generated.has(path.node) || t.isJSXAttribute(path.parent)) return;
1474
+ const text = path.node.value;
1475
+ if (text !== text.trim() || !isCopyHere(path, text)) return;
1476
+ record(text, contextOf(path));
1477
+ if (rewrite) path.replaceWith(lookup(path, text));
1478
+ },
1479
+ TemplateLiteral(path) {
1480
+ if (generated.has(path.node)) return;
1481
+ const { quasis, expressions } = path.node;
1482
+ const name = namer();
1483
+ let text = quasis[0].value.cooked ?? "";
1484
+ const values = [];
1485
+ expressions.forEach((e, i) => {
1486
+ if (t.isJSXElement(e) || t.isJSXFragment(e) || t.isTSType(e)) return;
1487
+ const n = name(e);
1488
+ values.push({
1489
+ name: n,
1490
+ expr: e
1491
+ });
1492
+ text += `{${n}}` + (quasis[i + 1].value.cooked ?? "");
1493
+ });
1494
+ if (values.length !== expressions.length) return;
1495
+ if (text !== text.trim() || !isCopyHere(path, text.replace(/\{\w+\}/g, "0"))) return;
1496
+ record(text, contextOf(path));
1497
+ if (rewrite) path.replaceWith(values.length ? lookupFmt(path, text, values) : lookup(path, text));
1498
+ },
1499
+ BinaryExpression(path) {
1500
+ if (path.node.operator !== "+" || generated.has(path.node)) return;
1501
+ if (t.isBinaryExpression(path.parent, { operator: "+" })) return;
1502
+ const leaves = [];
1503
+ const flatten = (n) => {
1504
+ if (t.isBinaryExpression(n, { operator: "+" })) {
1505
+ flatten(n.left);
1506
+ flatten(n.right);
1507
+ } else leaves.push(n);
1508
+ };
1509
+ flatten(path.node);
1510
+ const isText = (n) => t.isStringLiteral(n) || t.isTemplateLiteral(n) && n.expressions.length === 0;
1511
+ const firstText = leaves.findIndex(isText);
1512
+ if (firstText < 0 || firstText > 1) return;
1513
+ if (leaves.some((n) => t.isJSXElement(n) || t.isJSXFragment(n))) return;
1514
+ const name = namer();
1515
+ let text = "";
1516
+ const values = [];
1517
+ for (const leaf of leaves) if (t.isStringLiteral(leaf)) text += leaf.value;
1518
+ else if (t.isTemplateLiteral(leaf) && leaf.expressions.length === 0) text += leaf.quasis[0].value.cooked ?? "";
1519
+ else {
1520
+ const n = name(leaf);
1521
+ values.push({
1522
+ name: n,
1523
+ expr: leaf
1524
+ });
1525
+ text += `{${n}}`;
1526
+ }
1527
+ if (text !== text.trim() || !isCopyHere(path, text.replace(/\{\w+\}/g, "0"))) return;
1528
+ record(text, contextOf(path));
1529
+ leaves.forEach((l) => generated.add(l));
1530
+ if (rewrite) path.replaceWith(values.length ? lookupFmt(path, text, values) : lookup(path, text));
879
1531
  }
880
1532
  });
881
1533
  if (strings.size === 0) return null;
@@ -893,6 +1545,8 @@ function transformJsx(code, file, opts = {}) {
893
1545
  if (usedT) specifiers.push(t.importSpecifier(t.identifier(T_IMPORT), t.identifier("__T")));
894
1546
  if (usedTD) specifiers.push(t.importSpecifier(t.identifier(TD_IMPORT), t.identifier("__TD")));
895
1547
  if (hookTargets.size) specifiers.push(t.importSpecifier(t.identifier(HOOK_IMPORT), t.identifier("__useT")));
1548
+ if (usedGlobal) specifiers.push(t.importSpecifier(t.identifier(GLOBAL_TEXT), t.identifier("__rlText")));
1549
+ if (usedGlobalFmt) specifiers.push(t.importSpecifier(t.identifier(GLOBAL_FMT), t.identifier("__rlFmt")));
896
1550
  ast.program.body.unshift(t.importDeclaration(specifiers, t.stringLiteral(RUNTIME)));
897
1551
  const out = generate(ast, {
898
1552
  sourceMaps: true,
@@ -906,55 +1560,261 @@ function transformJsx(code, file, opts = {}) {
906
1560
  };
907
1561
  }
908
1562
  /** Strings (and where they appear), without rewriting. */
909
- function extractItems(code, file) {
910
- const r = transformJsx(code, file, { rewrite: false });
1563
+ function extractItems(code, file, facts) {
1564
+ const r = transformJsx(code, file, {
1565
+ rewrite: false,
1566
+ facts
1567
+ });
911
1568
  return {
912
1569
  strings: r?.strings ?? [],
913
1570
  contexts: r?.contexts ?? {}
914
1571
  };
915
1572
  }
916
1573
  //#endregion
917
- //#region src/core/scan.ts
918
- const EXTENSIONS = /* @__PURE__ */ new Set([
919
- ".tsx",
920
- ".jsx",
921
- ".ts",
922
- ".js"
1574
+ //#region src/core/facts.ts
1575
+ const traverse$1 = _traverse.default ?? _traverse;
1576
+ /** Methods whose string argument is compared against something. */
1577
+ const COMPARING_METHODS = /* @__PURE__ */ new Set([
1578
+ "includes",
1579
+ "indexOf",
1580
+ "lastIndexOf",
1581
+ "startsWith",
1582
+ "endsWith",
1583
+ "has",
1584
+ "equals",
1585
+ "localeCompare"
923
1586
  ]);
924
- const IGNORED_DIRS = /* @__PURE__ */ new Set([
925
- "node_modules",
926
- "dist",
927
- "build",
928
- "coverage"
1587
+ const COMPARISONS = /* @__PURE__ */ new Set([
1588
+ "===",
1589
+ "!==",
1590
+ "==",
1591
+ "!=",
1592
+ "in",
1593
+ "instanceof"
929
1594
  ]);
930
- function isSourceFile(file) {
931
- return EXTENSIONS.has(path.extname(file)) && !file.endsWith(".d.ts") && !/\.(test|spec)\./.test(file);
1595
+ /** String values this file uses as code. */
1596
+ function codeValuesIn(code, file) {
1597
+ const out = /* @__PURE__ */ new Set();
1598
+ let ast;
1599
+ try {
1600
+ ast = parse(code, {
1601
+ sourceType: "module",
1602
+ plugins: parserPlugins(file)
1603
+ });
1604
+ } catch {
1605
+ return out;
1606
+ }
1607
+ const add = (n) => {
1608
+ if (t.isStringLiteral(n)) out.add(n.value);
1609
+ else if (t.isTemplateLiteral(n) && n.expressions.length === 0) out.add(n.quasis[0].value.cooked ?? "");
1610
+ };
1611
+ traverse$1(ast, {
1612
+ BinaryExpression(p) {
1613
+ if (COMPARISONS.has(p.node.operator)) {
1614
+ add(p.node.left);
1615
+ add(p.node.right);
1616
+ }
1617
+ },
1618
+ SwitchCase(p) {
1619
+ add(p.node.test);
1620
+ },
1621
+ ObjectProperty(p) {
1622
+ const k = p.node.key;
1623
+ if (!p.node.computed && t.isIdentifier(k)) out.add(k.name);
1624
+ else add(k);
1625
+ },
1626
+ ObjectMethod(p) {
1627
+ if (!p.node.computed && t.isIdentifier(p.node.key)) out.add(p.node.key.name);
1628
+ },
1629
+ MemberExpression(p) {
1630
+ if (p.node.computed) add(p.node.property);
1631
+ },
1632
+ CallExpression(p) {
1633
+ const c = p.node.callee;
1634
+ const name = t.isIdentifier(c) ? c.name : t.isMemberExpression(c) && t.isIdentifier(c.property) ? c.property.name : "";
1635
+ if (COMPARING_METHODS.has(name)) p.node.arguments.forEach((a) => add(a));
1636
+ if (t.isMemberExpression(c) && t.isIdentifier(c.property) && COMPARING_METHODS.has(c.property.name) && t.isArrayExpression(c.object)) c.object.elements.forEach((e) => add(e));
1637
+ },
1638
+ TSLiteralType(p) {
1639
+ add(p.node.literal);
1640
+ },
1641
+ TSEnumMember(p) {
1642
+ add(p.node.initializer);
1643
+ if (t.isIdentifier(p.node.id)) out.add(p.node.id.name);
1644
+ },
1645
+ TSAsExpression(p) {
1646
+ if (t.isTSTypeReference(p.node.typeAnnotation) && t.isIdentifier(p.node.typeAnnotation.typeName, { name: "const" })) {
1647
+ const e = p.node.expression;
1648
+ if (t.isArrayExpression(e)) e.elements.forEach((x) => add(x));
1649
+ if (t.isObjectExpression(e)) e.properties.forEach((pr) => t.isObjectProperty(pr) && add(pr.value));
1650
+ }
1651
+ }
1652
+ });
1653
+ return out;
932
1654
  }
933
- function* walk(dir) {
934
- if (!fs.existsSync(dir)) return;
935
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
936
- if (entry.name.startsWith(".") || IGNORED_DIRS.has(entry.name)) continue;
937
- const full = path.join(dir, entry.name);
938
- if (entry.isDirectory()) yield* walk(full);
939
- else if (isSourceFile(full)) yield full;
1655
+ /** Per-file code values, so one edited file can be re-read without scanning the project again. */
1656
+ var FactsIndex = class {
1657
+ perFile = /* @__PURE__ */ new Map();
1658
+ union = null;
1659
+ set(file, code) {
1660
+ this.perFile.set(file, codeValuesIn(code, file));
1661
+ this.union = null;
1662
+ }
1663
+ delete(file) {
1664
+ this.perFile.delete(file);
1665
+ this.union = null;
1666
+ }
1667
+ facts() {
1668
+ this.union ??= new Set([...this.perFile.values()].flatMap((s) => [...s]));
1669
+ return { codeValues: this.union };
1670
+ }
1671
+ };
1672
+ /** Reads every source file under `include` once. */
1673
+ function buildFactsIndex(root, include) {
1674
+ const index = new FactsIndex();
1675
+ for (const dir of include) for (const file of walk(path.resolve(root, dir))) index.set(file, fs.readFileSync(file, "utf8"));
1676
+ return index;
1677
+ }
1678
+ function collectProjectFacts(root, include) {
1679
+ return buildFactsIndex(root, include).facts();
1680
+ }
1681
+ //#endregion
1682
+ //#region src/core/json.ts
1683
+ /**
1684
+ * Text kept in JSON data files (`import content from "./content.json"`): products, FAQs, team members, page copy.
1685
+ * A string value is translated when it reads like text and its key is not a code key (id, url, type, ...).
1686
+ */
1687
+ /** JSON files that are configuration, not content. */
1688
+ const NOT_CONTENT = /(?:^|\/)(?:package(?:-lock)?|tsconfig[\w.-]*|jsconfig|composer|\.?eslintrc|\.?prettierrc|vercel|netlify|firebase|\w+\.config|[\w.-]+\.schema|manifest)\.json$/i;
1689
+ function isContentJson(file, skipDirs = []) {
1690
+ const norm = file.split(path.sep).join("/");
1691
+ if (!norm.endsWith(".json") || NOT_CONTENT.test(norm) || norm.includes("/node_modules/")) return false;
1692
+ return !skipDirs.some((d) => norm.startsWith(d.split(path.sep).join("/").replace(/\/?$/, "/")));
1693
+ }
1694
+ function eligible(text, key, facts) {
1695
+ return text === text.trim() && !NON_COPY_KEYS.has(key) && !key.startsWith("$") && looksLikeCopy(text) && !facts?.codeValues.has(text);
1696
+ }
1697
+ /** The translatable strings in a JSON document, in document order. */
1698
+ function jsonStrings(raw, facts) {
1699
+ let data;
1700
+ try {
1701
+ data = JSON.parse(raw);
1702
+ } catch {
1703
+ return [];
1704
+ }
1705
+ const out = [];
1706
+ const visit = (v, key) => {
1707
+ if (typeof v === "string") {
1708
+ if (eligible(v, key, facts)) out.push({
1709
+ text: v,
1710
+ key
1711
+ });
1712
+ } else if (Array.isArray(v)) v.forEach((x) => visit(x, key));
1713
+ else if (v && typeof v === "object") for (const [k, x] of Object.entries(v)) visit(x, k);
1714
+ };
1715
+ visit(data, "");
1716
+ return out;
1717
+ }
1718
+ const IDENT = /^[A-Za-z_$][\w$]*$/;
1719
+ const RESERVED = /* @__PURE__ */ new Set([
1720
+ "default",
1721
+ "class",
1722
+ "function",
1723
+ "var",
1724
+ "let",
1725
+ "const",
1726
+ "new",
1727
+ "delete",
1728
+ "return",
1729
+ "if",
1730
+ "else",
1731
+ "for",
1732
+ "while",
1733
+ "do",
1734
+ "switch",
1735
+ "case",
1736
+ "this",
1737
+ "typeof",
1738
+ "void",
1739
+ "with",
1740
+ "yield",
1741
+ "import",
1742
+ "export",
1743
+ "enum",
1744
+ "null",
1745
+ "true",
1746
+ "false"
1747
+ ]);
1748
+ /**
1749
+ * A JavaScript module equivalent to Vite's own JSON import (default export plus a named export per key), with
1750
+ * translatable strings wrapped in lookups that run when the module loads.
1751
+ */
1752
+ function jsonModule(raw, facts) {
1753
+ let data;
1754
+ try {
1755
+ data = JSON.parse(raw);
1756
+ } catch {
1757
+ return null;
940
1758
  }
1759
+ if (/"__proto__"\s*:/.test(raw)) return null;
1760
+ let used = false;
1761
+ const write = (v, key) => {
1762
+ if (typeof v === "string") {
1763
+ if (!eligible(v, key, facts)) return JSON.stringify(v);
1764
+ used = true;
1765
+ return `__rlText(${JSON.stringify(messageId(v))},${JSON.stringify(v)})`;
1766
+ }
1767
+ if (Array.isArray(v)) return `[${v.map((x) => write(x, key)).join(",")}]`;
1768
+ if (v && typeof v === "object") return `{${Object.entries(v).map(([k, x]) => `${JSON.stringify(k)}:${write(x, k)}`).join(",")}}`;
1769
+ return JSON.stringify(v);
1770
+ };
1771
+ const body = write(data, "");
1772
+ if (!used) return null;
1773
+ return `import { __rlText } from "react-autolocale";\nconst __data = ${body};\nexport default __data;\n${(data && typeof data === "object" && !Array.isArray(data) ? Object.keys(data).filter((k) => IDENT.test(k) && !RESERVED.has(k)).map((k) => `export const ${k} = __data[${JSON.stringify(k)}];`) : []).join("\n")}\n`;
941
1774
  }
1775
+ //#endregion
1776
+ //#region src/core/scan.ts
1777
+ /** `from "./data.json"`, `import("./data.json")`, `require("./data.json")` */
1778
+ const JSON_IMPORT = /(?:from\s*|import\s*\(?\s*|require\s*\(\s*)["']([^"']+\.json)["']/g;
942
1779
  /** Every translatable string under the configured folders, in stable file order, and where each appears. */
943
- function scanProjectDetailed(root, include) {
1780
+ function scanProjectDetailed(root, include, opts = {}) {
1781
+ const skip = opts.skip ?? [];
944
1782
  const found = /* @__PURE__ */ new Set();
945
1783
  const contexts = {};
946
1784
  const files = {};
947
1785
  let fileCount = 0;
948
- for (const dir of include) for (const file of walk(path.resolve(root, dir))) {
1786
+ const facts = collectProjectFacts(root, include);
1787
+ const add = (text, context, rel) => {
1788
+ found.add(text);
1789
+ contexts[text] ??= context;
1790
+ const list = files[text] ??= [];
1791
+ if (!list.includes(rel)) list.push(rel);
1792
+ };
1793
+ const sourceFiles = [];
1794
+ for (const dir of include) sourceFiles.push(...walk(path.resolve(root, dir), skip));
1795
+ const jsonOnDisk = include.flatMap((dir) => [...walk(path.resolve(root, dir), skip, (f) => isContentJson(f, skip))]);
1796
+ const importedJson = /* @__PURE__ */ new Set();
1797
+ for (const file of sourceFiles) {
949
1798
  fileCount++;
1799
+ const code = fs.readFileSync(file, "utf8");
950
1800
  const rel = path.relative(root, file).split(path.sep).join("/");
951
- const items = extractItems(fs.readFileSync(file, "utf8"), file);
952
- for (const s of items.strings) {
953
- found.add(s);
954
- contexts[s] ??= items.contexts[s] ?? "";
955
- (files[s] ??= []).push(rel);
1801
+ const items = extractItems(code, file, facts);
1802
+ for (const s of items.strings) add(s, items.contexts[s] ?? "", rel);
1803
+ for (const m of code.matchAll(JSON_IMPORT)) {
1804
+ const spec = m[1];
1805
+ if (spec.startsWith(".")) {
1806
+ const abs = path.resolve(path.dirname(file), spec);
1807
+ if (jsonOnDisk.includes(abs)) importedJson.add(abs);
1808
+ } else {
1809
+ const tail = "/" + spec.replace(/^[@~]\/?|^\/+/, "");
1810
+ for (const j of jsonOnDisk) if (j.split(path.sep).join("/").endsWith(tail)) importedJson.add(j);
1811
+ }
956
1812
  }
957
1813
  }
1814
+ for (const file of importedJson) {
1815
+ const rel = path.relative(root, file).split(path.sep).join("/");
1816
+ for (const { text, key } of jsonStrings(fs.readFileSync(file, "utf8"), facts)) add(text, key || "text", rel);
1817
+ }
958
1818
  return {
959
1819
  strings: [...found],
960
1820
  contexts,
@@ -973,7 +1833,7 @@ function readManifest(project) {
973
1833
  /** Scans the source, updates .auto-locale/manifest.json and the source-language catalog. Never translates. */
974
1834
  function scanProject(project) {
975
1835
  const { root, site } = project;
976
- const scan = scanProjectDetailed(root, project.include);
1836
+ const scan = scanProjectDetailed(root, project.include, { skip: [project.output, project.meta] });
977
1837
  if (site.seo) {
978
1838
  const html = path.join(root, "index.html");
979
1839
  if (fs.existsSync(html)) for (const s of headStrings(fs.readFileSync(html, "utf8"))) {
@@ -1397,4 +2257,4 @@ async function loadProject(root, opts = {}) {
1397
2257
  };
1398
2258
  }
1399
2259
  //#endregion
1400
- export { readBanner as A, isSourceFile as C, translateHead as D, transformJsx as E, toTranslator as F, getLanguage as I, TranslationError as M, createLocalTranslator as N, messageId as O, ollama as P, scanProject as S, parserPlugins as T, loadDicts as _, findSite as a, manifestFile as b, hasRootSrc as c, OVERRIDES_FILE as d, exportTranslations as f, importLegacyCache as g, catalogFile as h, defaultInclude as i, writeJson as j, ensureBanner as k, resolveOutput as l, readOverrides as m, loadConfigFile as n, META_DIR as o, mergeOverrides as p, loadProject as r, defaultOutput as s, findConfigFile as t, toProjectPath as u, removeFromCatalogs as v, walk as w, readManifest as x, translateMissing as y };
2260
+ export { walk as A, getLanguage as B, isContentJson as C, parserPlugins as D, buildFactsIndex as E, writeJson as F, TranslationError as I, createLocalTranslator as L, messageId as M, ensureBanner as N, transformJsx as O, readBanner as P, ollama as R, scanProject as S, FactsIndex as T, loadDicts as _, findSite as a, manifestFile as b, hasRootSrc as c, OVERRIDES_FILE as d, exportTranslations as f, importLegacyCache as g, catalogFile as h, defaultInclude as i, translateHead as j, isSourceFile as k, resolveOutput as l, readOverrides as m, loadConfigFile as n, META_DIR as o, mergeOverrides as p, loadProject as r, defaultOutput as s, findConfigFile as t, toProjectPath as u, removeFromCatalogs as v, jsonModule as w, readManifest as x, translateMissing as y, toTranslator as z };