@jchat/addon-sdk 1.0.0-alpha.6 → 1.0.0-alpha.8

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.
package/dist/index.mjs CHANGED
@@ -1,5 +1,12 @@
1
+ import { createContext, useContext, useEffect, useMemo, useState } from "react";
2
+ import * as fs$1 from "node:fs";
3
+ import * as path$1 from "node:path";
4
+ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
1
5
  //#region package.json
2
- var version = "1.0.0-alpha.6";
6
+ var version = "1.0.0-alpha.8";
7
+ //#endregion
8
+ //#region ../icon/dist/types.mjs
9
+ const e = `Activity.ArrowLeft.ArrowRight.Ban.Bell.BookOpen.Brush.Calendar.Camera.ChartBar.Check.CheckCheck.CheckCircle2.ChevronDown.ChevronLeft.ChevronRight.ChevronUp.CircleAlert.CircleCheck.CircleOff.CirclePlay.CirclePlus.Clock.Coffee.Contact.Cpu.Database.Dices.Ellipsis.Eraser.ExternalLink.Eye.EyeOff.Flag.Flame.FolderPlus.Footprints.Gamepad2.Gift.Globe.HardDrive.Headphones.Heart.Image.Info.Key.KeyRound.Layers.LayoutDashboard.LayoutList.LayoutTemplate.Link2.ListOrdered.Loader.LoaderCircle.Lock.LogOut.Mail.Maximize.Megaphone.MemoryStick.Menu.MessageCircle.MessageSquare.Mic.Minimize.Minus.Monitor.Moon.MoreHorizontal.Music.Music2.Newspaper.Palette.Paperclip.PartyPopper.Pause.PenLine.Play.Plus.Radio.Reply.RotateCcw.RotateCw.Search.Send.Server.Settings.Shield.ShieldAlert.ShieldCheck.SkipForward.SlidersHorizontal.Smartphone.Smile.Sparkles.Square.SquarePen.Star.Sun.ThumbsDown.ThumbsUp.Timer.Trash.Trash2.Trophy.Upload.User.UserCheck.UserCog.UserMinus.UserPen.UserPlus.UserRoundPlus.Users.UserX.Utensils.Video.VideoOff.Volume.Volume2.VolumeX.X.Youtube.Zap`.split(`.`);
3
10
  //#endregion
4
11
  //#region src/contracts.ts
5
12
  /** Valid locations where an addon can be rendered within the Jchat layout. */
@@ -28,6 +35,8 @@ const MOUNT_POINTS = {
28
35
  CHAT_PLUS_MENU: "chat-plus-menu",
29
36
  /** Custom tab in the emoji/gif selection panel */
30
37
  EMOJI_PICKER_GIF_TAB: "emoji-picker-gif-tab",
38
+ /** Custom tab in the emoji/sticker selection panel */
39
+ EMOJI_PICKER_STICKER_TAB: "emoji-picker-sticker-tab",
31
40
  /** Full-screen or modal dialog specifically for TubeScan */
32
41
  TUBESCAN_DIALOG: "tubescan-dialog",
33
42
  /** Dedicated settings panel rendered in the Owner Dashboard */
@@ -36,6 +45,369 @@ const MOUNT_POINTS = {
36
45
  ADDON_SETTINGS: "addon-settings"
37
46
  };
38
47
  //#endregion
48
+ //#region src/i18n.ts
49
+ function checkIsRtl(code) {
50
+ if (!code) return false;
51
+ const normalized = code.toLowerCase().split("-")[0];
52
+ return normalized === "ar" || normalized === "he" || normalized === "ur";
53
+ }
54
+ function getLanguageDirection(code) {
55
+ return checkIsRtl(code) ? "rtl" : "ltr";
56
+ }
57
+ function interpolate(text, params) {
58
+ if (!params) return text;
59
+ return text.replace(/\{\{\s*(\w+)\s*\}\}|\{\s*(\w+)\s*\}/g, (match, key1, key2) => {
60
+ const key = key1 || key2;
61
+ return key in params ? String(params[key]) : match;
62
+ });
63
+ }
64
+ const HOST_LANGUAGES = [
65
+ "ar",
66
+ "bg",
67
+ "hr",
68
+ "de",
69
+ "el",
70
+ "en",
71
+ "es",
72
+ "fr",
73
+ "he",
74
+ "hi",
75
+ "kn",
76
+ "ml",
77
+ "nl",
78
+ "pt",
79
+ "ro",
80
+ "ru",
81
+ "ta",
82
+ "te",
83
+ "tr",
84
+ "ur"
85
+ ];
86
+ function extractNestedKeys(obj, prefix = "") {
87
+ if (!obj || typeof obj !== "object") return [];
88
+ const keys = [];
89
+ for (const [key, value] of Object.entries(obj)) {
90
+ const fullKey = prefix ? `${prefix}.${key}` : key;
91
+ if (value && typeof value === "object" && !Array.isArray(value)) keys.push(...extractNestedKeys(value, fullKey));
92
+ else keys.push(fullKey);
93
+ }
94
+ return keys;
95
+ }
96
+ /**
97
+ * Validates that an addon's `locales/` directory contains valid translation files for all 11 host
98
+ * languages with 100% key parity against `en.json`.
99
+ *
100
+ * @param dirPath - Path to the addon's locales directory.
101
+ * @param domainName - Optional label for error messages (defaults to "Addon").
102
+ */
103
+ function validateAddonLocales(dirPath, domainName = "Addon") {
104
+ const errors = [];
105
+ const missingFiles = [];
106
+ const missingKeys = {};
107
+ const extraKeys = {};
108
+ const enPath = path$1.join(dirPath, "en.json");
109
+ if (!fs$1.existsSync(enPath)) {
110
+ const errorMsg = `[${domainName}] Missing base locale file: en.json`;
111
+ errors.push(errorMsg);
112
+ missingFiles.push("en.json");
113
+ return {
114
+ valid: false,
115
+ errors,
116
+ missingFiles,
117
+ missingKeys,
118
+ extraKeys
119
+ };
120
+ }
121
+ let enContent;
122
+ try {
123
+ enContent = JSON.parse(fs$1.readFileSync(enPath, "utf-8"));
124
+ } catch (err) {
125
+ const errorMsg = `[${domainName}] Invalid JSON in en.json: ${err instanceof Error ? err.message : String(err)}`;
126
+ errors.push(errorMsg);
127
+ return {
128
+ valid: false,
129
+ errors,
130
+ missingFiles,
131
+ missingKeys,
132
+ extraKeys
133
+ };
134
+ }
135
+ const enKeys = new Set(extractNestedKeys(enContent));
136
+ for (const lang of HOST_LANGUAGES) {
137
+ if (lang === "en") continue;
138
+ const langFileName = `${lang}.json`;
139
+ const langPath = path$1.join(dirPath, langFileName);
140
+ if (!fs$1.existsSync(langPath)) {
141
+ errors.push(`[${domainName}] Missing locale file: ${langFileName}`);
142
+ missingFiles.push(langFileName);
143
+ continue;
144
+ }
145
+ try {
146
+ const langContent = JSON.parse(fs$1.readFileSync(langPath, "utf-8"));
147
+ const langKeys = new Set(extractNestedKeys(langContent));
148
+ for (const key of enKeys) if (!langKeys.has(key)) {
149
+ errors.push(`[${domainName}] Missing key in ${langFileName}: "${key}"`);
150
+ if (!missingKeys[lang]) missingKeys[lang] = [];
151
+ missingKeys[lang].push(key);
152
+ }
153
+ for (const key of langKeys) if (!enKeys.has(key)) {
154
+ errors.push(`[${domainName}] Extra key in ${langFileName} (not in en.json): "${key}"`);
155
+ if (!extraKeys[lang]) extraKeys[lang] = [];
156
+ extraKeys[lang].push(key);
157
+ }
158
+ } catch (err) {
159
+ errors.push(`[${domainName}] Invalid JSON in ${langFileName}: ${err instanceof Error ? err.message : String(err)}`);
160
+ }
161
+ }
162
+ return {
163
+ valid: errors.length === 0,
164
+ errors,
165
+ missingFiles,
166
+ missingKeys,
167
+ extraKeys
168
+ };
169
+ }
170
+ const LocaleContext = createContext({
171
+ locale: "en",
172
+ dir: "ltr"
173
+ });
174
+ /** Resolves a nested key in an object dictionary using dot-notation (e.g. "buttons.submit") */
175
+ function resolveNestedKey(obj, keyPath) {
176
+ if (!obj || typeof obj !== "object") return void 0;
177
+ const segments = keyPath.split(".");
178
+ let current = obj;
179
+ for (const seg of segments) if (current && typeof current === "object" && seg in current) current = current[seg];
180
+ else return;
181
+ return current;
182
+ }
183
+ /**
184
+ * Creates a standalone translation function for backend server routes (`src/api.ts`) or utilities.
185
+ *
186
+ * @param resources - Dictionary of translation resources (e.g., `{ en: enJson, ta: taJson }`).
187
+ * @param defaultLocale - Default locale to use if not specified per call (defaults to "en").
188
+ * @param fallbackLocale - Fallback locale if a key is missing in the target language (defaults to
189
+ * "en").
190
+ */
191
+ function createTranslator(resources = {}, defaultLocale = "en", fallbackLocale = "en") {
192
+ return (key, paramsOrLang, maybeLang) => {
193
+ let params;
194
+ let lang = defaultLocale;
195
+ if (typeof paramsOrLang === "string") lang = paramsOrLang;
196
+ else if (paramsOrLang && typeof paramsOrLang === "object") {
197
+ params = paramsOrLang;
198
+ if (typeof maybeLang === "string") lang = maybeLang;
199
+ }
200
+ const normalizedLang = (lang || defaultLocale).toLowerCase().split("-")[0];
201
+ const normalizedFallback = fallbackLocale.toLowerCase().split("-")[0];
202
+ const langDict = resources[normalizedLang];
203
+ let resolved = resolveNestedKey(langDict, key);
204
+ if (resolved === void 0 && normalizedLang !== normalizedFallback) {
205
+ const fallbackDict = resources[normalizedFallback];
206
+ resolved = resolveNestedKey(fallbackDict, key);
207
+ }
208
+ if (typeof resolved === "string") return interpolate(resolved, params);
209
+ return typeof resolved === "number" || typeof resolved === "boolean" ? String(resolved) : interpolate(key, params);
210
+ };
211
+ }
212
+ /**
213
+ * Hook to access the current active host locale and text direction in an addon component.
214
+ *
215
+ * @example
216
+ * ;```tsx
217
+ * import { useLocale } from "@jchat/addon-sdk"
218
+ *
219
+ * export default function MyAddon() {
220
+ * const { locale, dir, isRtl } = useLocale()
221
+ * return <div dir={dir}>Current language: {locale}</div>
222
+ * }
223
+ * ```
224
+ */
225
+ function useLocale() {
226
+ const context = useContext(LocaleContext);
227
+ const locale = context?.locale || "en";
228
+ const dir = context?.dir || getLanguageDirection(locale);
229
+ return {
230
+ locale,
231
+ dir,
232
+ isRtl: dir === "rtl" || checkIsRtl(locale)
233
+ };
234
+ }
235
+ const loadedChunkCache = {};
236
+ /**
237
+ * Hook to access translation function and locale metadata inside addon components. Automatically
238
+ * synchronizes with host language switches and falls back to English. Supports static dictionary
239
+ * resources or dynamic async locale loaders.
240
+ *
241
+ * @example
242
+ * ;```tsx
243
+ * import { useTranslation } from "@jchat/addon-sdk"
244
+ * import locales from "./locales"
245
+ *
246
+ * export default function MyAddon() {
247
+ * const { t, locale, dir } = useTranslation(locales)
248
+ * return (
249
+ * <div dir={dir}>
250
+ * <h1>{t("welcome", { name: "Guest" })}</h1>
251
+ * </div>
252
+ * )
253
+ * }
254
+ * ```
255
+ *
256
+ * @param resourcesOrLoader - Addon-scoped translation dictionaries or async chunk loader.
257
+ * @param fallbackLocale - Fallback language code when a string is missing (default: "en").
258
+ */
259
+ function useTranslation(resourcesOrLoader = {}, fallbackLocale = "en") {
260
+ const { locale, dir, isRtl } = useLocale();
261
+ const isLoader = typeof resourcesOrLoader === "function";
262
+ const [dynamicResources, setDynamicResources] = useState(() => {
263
+ return isLoader ? loadedChunkCache : resourcesOrLoader;
264
+ });
265
+ const [isReady, setIsReady] = useState(!isLoader);
266
+ useEffect(() => {
267
+ if (!isLoader) {
268
+ setDynamicResources(resourcesOrLoader);
269
+ setIsReady(true);
270
+ return;
271
+ }
272
+ const loader = resourcesOrLoader;
273
+ const normLocale = locale.toLowerCase().split("-")[0];
274
+ if (loadedChunkCache[normLocale]) {
275
+ setDynamicResources((prev) => ({
276
+ ...prev,
277
+ [normLocale]: loadedChunkCache[normLocale]
278
+ }));
279
+ setIsReady(true);
280
+ return;
281
+ }
282
+ let isMounted = true;
283
+ Promise.resolve(loader(normLocale)).then((dict) => {
284
+ if (!isMounted || !dict) return;
285
+ loadedChunkCache[normLocale] = dict;
286
+ setDynamicResources((prev) => ({
287
+ ...prev,
288
+ [normLocale]: dict
289
+ }));
290
+ setIsReady(true);
291
+ }).catch((err) => {
292
+ console.warn(`[addon-sdk] Failed to dynamically load locale chunk "${normLocale}":`, err);
293
+ if (isMounted) setIsReady(true);
294
+ });
295
+ return () => {
296
+ isMounted = false;
297
+ };
298
+ }, [
299
+ isLoader,
300
+ resourcesOrLoader,
301
+ locale
302
+ ]);
303
+ const translator = useMemo(() => {
304
+ return createTranslator(dynamicResources, locale, fallbackLocale);
305
+ }, [
306
+ dynamicResources,
307
+ locale,
308
+ fallbackLocale
309
+ ]);
310
+ return {
311
+ t: useMemo(() => {
312
+ return (key, params) => translator(key, params, locale);
313
+ }, [translator, locale]),
314
+ locale,
315
+ dir,
316
+ isRtl,
317
+ isReady
318
+ };
319
+ }
320
+ //#endregion
321
+ //#region src/query.ts
322
+ /**
323
+ * Perform a declarative, cached GET request using the host's shared React Query instance.
324
+ * Automatically injects authentication headers and prefixes addon paths.
325
+ *
326
+ * @example
327
+ * ;```tsx
328
+ * const { data, isLoading } = useAddonQuery<{ polls: Poll[] }>({
329
+ * path: "/polls",
330
+ * queryKey: [siteId]
331
+ * })
332
+ * ```
333
+ */
334
+ function useAddonQuery(options) {
335
+ const sdk = useAddonSDK();
336
+ const { path, queryKey = [], queryFn, ...queryOptions } = options;
337
+ const effectiveKey = path !== void 0 ? [path, ...queryKey] : queryKey;
338
+ const effectiveFn = queryFn ?? (() => {
339
+ if (!path) throw new Error("useAddonQuery requires either a `path` or a custom `queryFn`");
340
+ return sdk.api.get(path);
341
+ });
342
+ return useQuery({
343
+ ...queryOptions,
344
+ queryKey: effectiveKey,
345
+ queryFn: effectiveFn
346
+ });
347
+ }
348
+ /**
349
+ * Perform a paginated or infinite scrolling request using the host's shared React Query instance.
350
+ *
351
+ * @example
352
+ * ;```tsx
353
+ * const { data, fetchNextPage, hasNextPage } = useAddonInfiniteQuery<GifResponse, Error, number>({
354
+ * path: (page) => `/trending?offset=${page * 20}`,
355
+ * initialPageParam: 0,
356
+ * getNextPageParam: (lastPage, allPages) => (lastPage.hasMore ? allPages.length : undefined)
357
+ * })
358
+ * ```
359
+ */
360
+ function useAddonInfiniteQuery(options) {
361
+ const sdk = useAddonSDK();
362
+ const { path, queryKey = [], queryFn, ...infiniteOptions } = options;
363
+ const effectiveKey = [typeof path === "string" ? path : "infinite", ...queryKey];
364
+ const effectiveFn = queryFn ?? (async (context) => {
365
+ const pageParam = context.pageParam;
366
+ const resolvedPath = typeof path === "function" ? path(pageParam) : path;
367
+ if (!resolvedPath) throw new Error("useAddonInfiniteQuery requires either a `path` or a custom `queryFn`");
368
+ return sdk.api.get(resolvedPath);
369
+ });
370
+ return useInfiniteQuery({
371
+ ...infiniteOptions,
372
+ queryKey: effectiveKey,
373
+ queryFn: effectiveFn
374
+ });
375
+ }
376
+ /**
377
+ * Perform a declarative mutation using the host's shared React Query instance. Automatically
378
+ * executes the request and can invalidate specified query keys upon success.
379
+ *
380
+ * @example
381
+ * ;```tsx
382
+ * const { mutate, isPending } = useAddonMutation<Poll, CreatePollInput>({
383
+ * path: "/polls",
384
+ * method: "POST",
385
+ * invalidateQueries: [["/polls"]],
386
+ * onSuccess: () => sdk.toast("Poll created!", "success")
387
+ * })
388
+ * ```
389
+ */
390
+ function useAddonMutation(options) {
391
+ const sdk = useAddonSDK();
392
+ const queryClient = useQueryClient();
393
+ const { path, method = "POST", invalidateQueries = [], mutationFn, onSuccess, ...mutationOptions } = options;
394
+ const effectiveFn = mutationFn ?? (async (variables) => {
395
+ const resolvedPath = typeof path === "function" ? path(variables) : path ?? "";
396
+ if (!resolvedPath) throw new Error("useAddonMutation requires a valid 'path' or 'mutationFn'.");
397
+ if (method === "PUT") return sdk.api.put(resolvedPath, variables);
398
+ if (method === "DELETE") return sdk.api.delete(resolvedPath);
399
+ return sdk.api.post(resolvedPath, variables);
400
+ });
401
+ return useMutation({
402
+ ...mutationOptions,
403
+ mutationFn: effectiveFn,
404
+ onSuccess: async (...args) => {
405
+ if (invalidateQueries.length > 0) await Promise.all(invalidateQueries.map((key) => queryClient.invalidateQueries({ queryKey: key })));
406
+ if (onSuccess) await onSuccess(...args);
407
+ }
408
+ });
409
+ }
410
+ //#endregion
39
411
  //#region src/index.ts
40
412
  /** Define a manifest with full validation-friendly typing while preserving literal values. */
41
413
  function defineAddonManifest(manifest) {
@@ -106,4 +478,4 @@ function getAddonSdkVersion() {
106
478
  return version;
107
479
  }
108
480
  //#endregion
109
- export { MOUNT_POINTS, defineAddonManifest, defineAddonServer, getAddonSdkVersion, useAddonSDK, useIcons };
481
+ export { HOST_LANGUAGES, e as ICON_NAMES, LocaleContext, MOUNT_POINTS, createTranslator, defineAddonManifest, defineAddonServer, getAddonSdkVersion, useAddonInfiniteQuery, useAddonMutation, useAddonQuery, useAddonSDK, useIcons, useLocale, useQueryClient, useTranslation, validateAddonLocales };