@jchat/addon-sdk 1.0.0-alpha.3 → 1.0.0-alpha.31

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.
@@ -0,0 +1,23 @@
1
+ //#region src/i18n-validator.d.ts
2
+ export interface AddonLocaleValidationResult {
3
+ valid: boolean;
4
+ errors: string[];
5
+ missingFiles: string[];
6
+ missingKeys: Record<string, string[]>;
7
+ extraKeys: Record<string, string[]>;
8
+ }
9
+ export interface ValidateAddonLocalesOptions {
10
+ strict?: boolean;
11
+ }
12
+ /**
13
+ * Validates that an addon's `locales/` directory contains a valid `en.json` base file, and that any
14
+ * provided host language files contain valid JSON and matching keys. Missing translations
15
+ * automatically fall back to English at runtime.
16
+ *
17
+ * @param dirPath - Path to the addon's locales directory.
18
+ * @param domainName - Optional label for error messages (defaults to "Addon").
19
+ * @param options - Optional configuration (e.g. `{ strict: true }` to enforce 100% presence of all
20
+ * 20 language files).
21
+ */
22
+ export declare function validateAddonLocales(dirPath: string, domainName?: string, options?: ValidateAddonLocalesOptions): AddonLocaleValidationResult;
23
+ //#endregion
@@ -0,0 +1,95 @@
1
+ import { HOST_LOCALES } from "./contracts.mjs";
2
+ import "./i18n.mjs";
3
+ import * as path$1 from "node:path";
4
+ import * as fs$1 from "node:fs";
5
+ //#region src/i18n-validator.ts
6
+ function extractNestedKeys(obj, prefix = "") {
7
+ if (!obj || typeof obj !== "object") return [];
8
+ const keys = [];
9
+ for (const [key, value] of Object.entries(obj)) {
10
+ const fullKey = prefix ? `${prefix}.${key}` : key;
11
+ if (value && typeof value === "object" && !Array.isArray(value)) keys.push(...extractNestedKeys(value, fullKey));
12
+ else keys.push(fullKey);
13
+ }
14
+ return keys;
15
+ }
16
+ /**
17
+ * Validates that an addon's `locales/` directory contains a valid `en.json` base file, and that any
18
+ * provided host language files contain valid JSON and matching keys. Missing translations
19
+ * automatically fall back to English at runtime.
20
+ *
21
+ * @param dirPath - Path to the addon's locales directory.
22
+ * @param domainName - Optional label for error messages (defaults to "Addon").
23
+ * @param options - Optional configuration (e.g. `{ strict: true }` to enforce 100% presence of all
24
+ * 20 language files).
25
+ */
26
+ function validateAddonLocales(dirPath, domainName = "Addon", options) {
27
+ const isStrict = Boolean(options?.strict);
28
+ const errors = [];
29
+ const missingFiles = [];
30
+ const missingKeys = {};
31
+ const extraKeys = {};
32
+ const enPath = path$1.join(dirPath, "en.json");
33
+ if (!fs$1.existsSync(enPath)) {
34
+ const errorMsg = `[${domainName}] Missing base locale file: en.json`;
35
+ errors.push(errorMsg);
36
+ missingFiles.push("en.json");
37
+ return {
38
+ valid: false,
39
+ errors,
40
+ missingFiles,
41
+ missingKeys,
42
+ extraKeys
43
+ };
44
+ }
45
+ let enContent;
46
+ try {
47
+ enContent = JSON.parse(fs$1.readFileSync(enPath, "utf-8"));
48
+ } catch (err) {
49
+ const errorMsg = `[${domainName}] Invalid JSON in en.json: ${err instanceof Error ? err.message : String(err)}`;
50
+ errors.push(errorMsg);
51
+ return {
52
+ valid: false,
53
+ errors,
54
+ missingFiles,
55
+ missingKeys,
56
+ extraKeys
57
+ };
58
+ }
59
+ const enKeys = new Set(extractNestedKeys(enContent));
60
+ for (const lang of HOST_LOCALES) {
61
+ if (lang === "en") continue;
62
+ const langFileName = `${lang}.json`;
63
+ const langPath = path$1.join(dirPath, langFileName);
64
+ if (!fs$1.existsSync(langPath)) {
65
+ missingFiles.push(langFileName);
66
+ if (isStrict) errors.push(`[${domainName}] Missing locale file: ${langFileName}`);
67
+ continue;
68
+ }
69
+ try {
70
+ const langContent = JSON.parse(fs$1.readFileSync(langPath, "utf-8"));
71
+ const langKeys = new Set(extractNestedKeys(langContent));
72
+ for (const key of enKeys) if (!langKeys.has(key)) {
73
+ if (!missingKeys[lang]) missingKeys[lang] = [];
74
+ missingKeys[lang].push(key);
75
+ if (isStrict) errors.push(`[${domainName}] Missing key in ${langFileName}: "${key}"`);
76
+ }
77
+ for (const key of langKeys) if (!enKeys.has(key)) {
78
+ errors.push(`[${domainName}] Extra key in ${langFileName} (not in en.json): "${key}"`);
79
+ if (!extraKeys[lang]) extraKeys[lang] = [];
80
+ extraKeys[lang].push(key);
81
+ }
82
+ } catch (err) {
83
+ errors.push(`[${domainName}] Invalid JSON in ${langFileName}: ${err instanceof Error ? err.message : String(err)}`);
84
+ }
85
+ }
86
+ return {
87
+ valid: errors.length === 0,
88
+ errors,
89
+ missingFiles,
90
+ missingKeys,
91
+ extraKeys
92
+ };
93
+ }
94
+ //#endregion
95
+ export { validateAddonLocales };
package/dist/i18n.mjs ADDED
@@ -0,0 +1,169 @@
1
+ import { createContext, useContext, useEffect, useMemo, useState } from "react";
2
+ //#region src/i18n.ts
3
+ function checkIsRtl(code) {
4
+ if (!code) return false;
5
+ const normalized = code.toLowerCase().split("-")[0];
6
+ return normalized === "ar" || normalized === "he" || normalized === "ur";
7
+ }
8
+ function getLanguageDirection(code) {
9
+ return checkIsRtl(code) ? "rtl" : "ltr";
10
+ }
11
+ function interpolate(text, params) {
12
+ if (!params) return text;
13
+ return text.replace(/\{\{\s*(\w+)\s*\}\}|\{\s*(\w+)\s*\}/g, (match, key1, key2) => {
14
+ const key = key1 || key2;
15
+ return key in params ? String(params[key]) : match;
16
+ });
17
+ }
18
+ const LocaleContext = createContext({
19
+ locale: "en",
20
+ dir: "ltr"
21
+ });
22
+ /** Resolves a nested key in an object dictionary using dot-notation (e.g. "buttons.submit") */
23
+ function resolveNestedKey(obj, keyPath) {
24
+ if (!obj || typeof obj !== "object") return void 0;
25
+ const segments = keyPath.split(".");
26
+ let current = obj;
27
+ for (const seg of segments) if (current && typeof current === "object" && seg in current) current = current[seg];
28
+ else return;
29
+ return current;
30
+ }
31
+ /**
32
+ * Creates a standalone translation function for backend server routes (`src/api.ts`) or utilities.
33
+ *
34
+ * @param resources - Dictionary of translation resources (e.g., `{ en: enJson, ta: taJson }`).
35
+ * @param defaultLocale - Default locale to use if not specified per call (defaults to "en").
36
+ * @param fallbackLocale - Fallback locale if a key is missing in the target language (defaults to
37
+ * "en").
38
+ */
39
+ function createTranslator(resources = {}, defaultLocale = "en", fallbackLocale = "en") {
40
+ return (key, paramsOrLang, maybeLang) => {
41
+ let params;
42
+ let lang = defaultLocale;
43
+ if (typeof paramsOrLang === "string") lang = paramsOrLang;
44
+ else if (paramsOrLang && typeof paramsOrLang === "object") {
45
+ params = paramsOrLang;
46
+ if (typeof maybeLang === "string") lang = maybeLang;
47
+ }
48
+ const normalizedLang = (lang || defaultLocale).toLowerCase().split("-")[0];
49
+ const normalizedFallback = fallbackLocale.toLowerCase().split("-")[0];
50
+ const langDict = resources[normalizedLang];
51
+ let resolved = resolveNestedKey(langDict, key);
52
+ if (resolved === void 0 && normalizedLang !== normalizedFallback) {
53
+ const fallbackDict = resources[normalizedFallback];
54
+ resolved = resolveNestedKey(fallbackDict, key);
55
+ }
56
+ if (typeof resolved === "string") return interpolate(resolved, params);
57
+ return typeof resolved === "number" || typeof resolved === "boolean" ? String(resolved) : interpolate(key, params);
58
+ };
59
+ }
60
+ /**
61
+ * Hook to access the current active host locale and text direction in an addon component.
62
+ *
63
+ * @example
64
+ * ;```tsx
65
+ * import { useLocale } from "@jchat/addon-sdk"
66
+ *
67
+ * export default function MyAddon() {
68
+ * const { locale, dir, isRtl } = useLocale()
69
+ * return <div dir={dir}>Current language: {locale}</div>
70
+ * }
71
+ * ```
72
+ */
73
+ function useLocale() {
74
+ const context = useContext(LocaleContext);
75
+ const locale = context?.locale || "en";
76
+ const dir = context?.dir || getLanguageDirection(locale);
77
+ return {
78
+ locale,
79
+ dir,
80
+ isRtl: dir === "rtl" || checkIsRtl(locale)
81
+ };
82
+ }
83
+ const loadedChunkCache = {};
84
+ /**
85
+ * Hook to access translation function and locale metadata inside addon components. Automatically
86
+ * synchronizes with host language switches and falls back to English. Supports static dictionary
87
+ * resources or dynamic async locale loaders.
88
+ *
89
+ * @example
90
+ * ;```tsx
91
+ * import { useTranslation } from "@jchat/addon-sdk"
92
+ * import locales from "./locales"
93
+ *
94
+ * export default function MyAddon() {
95
+ * const { t, locale, dir } = useTranslation(locales)
96
+ * return (
97
+ * <div dir={dir}>
98
+ * <h1>{t("welcome", { name: "Guest" })}</h1>
99
+ * </div>
100
+ * )
101
+ * }
102
+ * ```
103
+ *
104
+ * @param resourcesOrLoader - Addon-scoped translation dictionaries or async chunk loader.
105
+ * @param fallbackLocale - Fallback language code when a string is missing (default: "en").
106
+ */
107
+ function useTranslation(resourcesOrLoader = {}, fallbackLocale = "en") {
108
+ const { locale, dir, isRtl } = useLocale();
109
+ const isLoader = typeof resourcesOrLoader === "function";
110
+ const [dynamicResources, setDynamicResources] = useState(() => {
111
+ return isLoader ? loadedChunkCache : resourcesOrLoader;
112
+ });
113
+ const [isReady, setIsReady] = useState(!isLoader);
114
+ useEffect(() => {
115
+ if (!isLoader) {
116
+ setDynamicResources(resourcesOrLoader);
117
+ setIsReady(true);
118
+ return;
119
+ }
120
+ const loader = resourcesOrLoader;
121
+ const normLocale = locale.toLowerCase().split("-")[0];
122
+ if (loadedChunkCache[normLocale]) {
123
+ setDynamicResources((prev) => ({
124
+ ...prev,
125
+ [normLocale]: loadedChunkCache[normLocale]
126
+ }));
127
+ setIsReady(true);
128
+ return;
129
+ }
130
+ let isMounted = true;
131
+ Promise.resolve(loader(normLocale)).then((dict) => {
132
+ if (!isMounted || !dict) return;
133
+ loadedChunkCache[normLocale] = dict;
134
+ setDynamicResources((prev) => ({
135
+ ...prev,
136
+ [normLocale]: dict
137
+ }));
138
+ setIsReady(true);
139
+ }).catch((err) => {
140
+ console.warn(`[addon-sdk] Failed to dynamically load locale chunk "${normLocale}":`, err);
141
+ if (isMounted) setIsReady(true);
142
+ });
143
+ return () => {
144
+ isMounted = false;
145
+ };
146
+ }, [
147
+ isLoader,
148
+ resourcesOrLoader,
149
+ locale
150
+ ]);
151
+ const translator = useMemo(() => {
152
+ return createTranslator(dynamicResources, locale, fallbackLocale);
153
+ }, [
154
+ dynamicResources,
155
+ locale,
156
+ fallbackLocale
157
+ ]);
158
+ return {
159
+ t: useMemo(() => {
160
+ return (key, params) => translator(key, params, locale);
161
+ }, [translator, locale]),
162
+ locale,
163
+ dir,
164
+ isRtl,
165
+ isReady
166
+ };
167
+ }
168
+ //#endregion
169
+ export { useTranslation as i, createTranslator as n, useLocale as r, LocaleContext as t };