@uipkge/nuxt 0.1.13 → 0.1.15

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/README.md CHANGED
@@ -8,7 +8,7 @@ Nuxt module for [i18now](https://i18now.com) — loads translations from the i18
8
8
  - Reloads translations on locale switch (client-side)
9
9
  - In development: detects missing translation keys and syncs them to i18now automatically
10
10
  - Suppresses vue-i18n missing-key warnings in dev — missing keys are expected until published
11
- - API key is never exposed to the browser (proxied through a local Nuxt server route)
11
+ - API key is only present in development builds never shipped to production
12
12
  - Zero bundle impact in production — dev-only code is tree-shaken at build time
13
13
 
14
14
  ## Installation
@@ -47,7 +47,7 @@ export default defineNuxtConfig({
47
47
  | Option | Type | Default | Description |
48
48
  |---------------|------------|----------------------------|-----------------------------------------------------------------------------|
49
49
  | `projectId` | `string` | — | **Required.** Your i18now project ID. |
50
- | `apiKey` | `string` | — | **Required.** Your i18now API key. Never sent to the browser. |
50
+ | `apiKey` | `string` | — | **Required.** Your i18now API key. Only used in dev builds. |
51
51
  | `host` | `string` | `https://i18now.com` | i18now server URL. Set to `http://localhost:3220` for local development. |
52
52
  | `cdnUrl` | `string` | `https://cdn.i18now.com` | CDN base URL where published translations are served. |
53
53
  | `environment` | `string` | `'dev'` | Which published environment to load translations from (`dev`, `stage`, `prod`). |
@@ -71,24 +71,24 @@ The server plugin fetches the published translation JSON from the CDN (`{cdnUrl}
71
71
 
72
72
  ### Development
73
73
 
74
- The client plugin intercepts every `$t()` and `useI18n().t()` call. When a key is missing from the CDN snapshot it batches that key (with its default value) and POSTs it to a local Nuxt server route (`/api/_i18now/sync`). The server route adds the API key and forwards the request to i18now so your API key never appears in browser network requests.
74
+ The client plugin intercepts every `$t()` and `useI18now().t()` call. When a key is missing from the CDN snapshot it batches that key (with its default value) and POSTs it directly to the i18now sync endpoint.
75
75
 
76
76
  ```
77
77
  Browser $t('key', 'Default text')
78
78
  → client plugin detects missing key
79
- → POST /api/_i18now/sync { keys: [{ key, value }] }
80
- Nuxt server adds apiKey
81
- → POST i18now /api/v1/projects/:id/sync
79
+ → POST i18now /api/v1/projects/:id/sync { apiKey, keys }
80
+ i18now creates the key with source value
82
81
  ```
83
82
 
84
- The `useI18n` composable is auto-imported by the module and silently overrides the one from `@nuxtjs/i18n`no changes to existing component code are required.
83
+ The `useI18now` composable is auto-imported by the module. Use it in place of `useI18n` from vue-i18n — it adds sync detection on top of the standard `t()` function.
85
84
 
86
85
  Vue-i18n's missing-key and fallback warnings are suppressed automatically in dev. Keys are expected to be absent until they are published from the i18now dashboard, at which point they load from CDN and no longer trigger warnings.
87
86
 
88
87
  ## Security
89
88
 
90
- - `apiKey` is stored in Nuxt's private (server-only) `runtimeConfig` it is never serialised into the client bundle or sent in any browser request.
89
+ - `apiKey` is only included in `runtimeConfig.public` during **development** builds (`nuxt.options.dev`). In production, the API key is never serialised into the client bundle or `__NUXT__` payload.
91
90
  - The `syncIn` option defaults to `['development']`. **Never add `'production'` or `'staging'`** — doing so would expose your API key to end users and flood your project with traffic.
91
+ - The client sync plugin is only registered in dev mode — it is completely absent from production builds.
92
92
 
93
93
  ## Local development against a local i18now server
94
94
 
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.0.0"
6
6
  },
7
- "version": "0.1.13",
7
+ "version": "0.1.15",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.2",
10
10
  "unbuild": "unknown"
package/dist/module.mjs CHANGED
@@ -21,8 +21,11 @@ const module$1 = defineNuxtModule({
21
21
  if (!options.apiKey) {
22
22
  console.warn("[i18now] apiKey is required. The module will not work until it is set.");
23
23
  }
24
+ if (options.cdnUrl && !options.cdnUrl.startsWith("http")) {
25
+ console.warn("[i18now] cdnUrl should be a valid HTTP(S) URL. Got: " + options.cdnUrl);
26
+ }
24
27
  const dangerousEnvs = (options.syncIn ?? []).filter(
25
- (e) => ["production", "staging", "prod", "stage"].includes(e)
28
+ (e) => ["production", "staging", "prod", "stage"].includes(e.toLowerCase())
26
29
  );
27
30
  if (dangerousEnvs.length > 0) {
28
31
  console.error(
@@ -31,17 +34,26 @@ const module$1 = defineNuxtModule({
31
34
  }
32
35
  nuxt.options.runtimeConfig.public.i18now = {
33
36
  projectId: options.projectId,
34
- apiKey: options.apiKey,
35
- host: options.host,
36
37
  cdnUrl: options.cdnUrl,
37
38
  environment: options.environment,
38
- syncIn: options.syncIn,
39
- locale: options.locale
39
+ locale: options.locale,
40
+ // Dev-only fields — only included when the sync client plugin is active.
41
+ // In production builds these are undefined and the client plugin is not registered.
42
+ ...nuxt.options.dev ? {
43
+ apiKey: options.apiKey,
44
+ host: options.host,
45
+ syncIn: options.syncIn
46
+ } : {}
40
47
  };
41
48
  addPlugin({
42
49
  src: resolver.resolve("./runtime/plugin"),
43
50
  mode: "server"
44
51
  });
52
+ addImports({
53
+ name: "useI18now",
54
+ as: "useI18now",
55
+ from: resolver.resolve("./runtime/composables/useI18n")
56
+ });
45
57
  if (nuxt.options.dev) {
46
58
  addPlugin({
47
59
  src: resolver.resolve("./runtime/plugin.client"),
@@ -50,12 +62,6 @@ const module$1 = defineNuxtModule({
50
62
  addPlugin({
51
63
  src: resolver.resolve("./runtime/plugin.suppress-warnings")
52
64
  });
53
- addImports({
54
- name: "useI18n",
55
- as: "useI18n",
56
- from: resolver.resolve("./runtime/composables/useI18n"),
57
- priority: 2
58
- });
59
65
  }
60
66
  }
61
67
  });
@@ -3,9 +3,11 @@ type UseI18nOptions = Parameters<typeof _useI18n>[0];
3
3
  type UseI18nReturn = ReturnType<typeof _useI18n>;
4
4
  /**
5
5
  * Drop-in replacement for useI18n() that wraps t() to sync missing keys to i18now.
6
- * Auto-imported by the @uipkge/nuxt module — no code changes required.
6
+ * Use this instead of useI18n() — auto-imported by the @uipkge/nuxt module.
7
+ *
8
+ * Migration: find-replace `useI18n()` → `useI18now()` in your components.
7
9
  */
8
- export declare function useI18n(options?: UseI18nOptions): UseI18nReturn & {
10
+ export declare function useI18now(options?: UseI18nOptions): UseI18nReturn & {
9
11
  te: (key: string, locale?: string) => boolean;
10
12
  };
11
13
  export {};
@@ -1,11 +1,11 @@
1
1
  import { useNuxtApp } from "#app";
2
2
  import { useI18n as _useI18n } from "vue-i18n";
3
- export function useI18n(options) {
3
+ export function useI18now(options) {
4
4
  const i18n = _useI18n({ useScope: "global", ...options });
5
5
  const nuxtApp = useNuxtApp();
6
6
  const originalT = i18n.t.bind(i18n);
7
7
  const patchedT = (key, defaultValue, ...rest) => {
8
- const result = typeof defaultValue === "object" && defaultValue !== null ? originalT(key, defaultValue) : originalT(key);
8
+ const result = defaultValue !== void 0 ? originalT(key, defaultValue) : originalT(key);
9
9
  const sync = nuxtApp.$i18nowSync;
10
10
  const defaultStr = typeof defaultValue === "string" ? defaultValue : typeof rest[0] === "string" ? rest[0] : void 0;
11
11
  if (sync && !sync.existingKeys.has(key) && defaultStr !== void 0) {
@@ -67,22 +67,43 @@ export default defineNuxtPlugin(async (nuxtApp) => {
67
67
  debug: import.meta.dev
68
68
  });
69
69
  const locale = i18nGlobal?.locale?.value ?? i18nGlobal?.locale ?? configLocale;
70
+ let loadLocaleController = null;
70
71
  async function loadLocale(targetLocale) {
72
+ if (loadLocaleController) loadLocaleController.abort();
73
+ loadLocaleController = new AbortController();
74
+ const { signal } = loadLocaleController;
71
75
  try {
72
76
  const url = `${cdnUrl}/${projectId}/publish/${environment}/${targetLocale}.json`;
73
- const res = await fetch(url, { signal: AbortSignal.timeout(8e3) });
77
+ const timeout = setTimeout(() => loadLocaleController?.abort(), 5e3);
78
+ const res = await fetch(url, { signal });
79
+ clearTimeout(timeout);
74
80
  if (res.ok) {
75
81
  const data = await res.json();
82
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
83
+ if (import.meta.dev) {
84
+ console.warn(`[i18now] CDN response for locale '${targetLocale}' is not a valid object. Skipping.`);
85
+ }
86
+ syncer.setExistingKeys([]);
87
+ return;
88
+ }
76
89
  syncer.setExistingKeys(Object.keys(data));
77
90
  i18nGlobal?.setLocaleMessage(targetLocale, data);
78
- } else if (import.meta.dev) {
79
- console.warn(
80
- `[i18now] Could not load translations from CDN (${res.status}). All keys will be sent to sync \u2014 this is safe, the backend deduplicates them. URL: ${url}`
81
- );
91
+ } else {
92
+ syncer.setExistingKeys([]);
93
+ if (import.meta.dev) {
94
+ console.warn(
95
+ `[i18now] Could not load translations from CDN (${res.status}). All keys will be sent to sync \u2014 this is safe, the backend deduplicates them. URL: ${url}`
96
+ );
97
+ }
82
98
  }
83
99
  } catch (err) {
100
+ syncer.setExistingKeys([]);
84
101
  if (import.meta.dev) {
85
- console.warn(`[i18now] CDN fetch failed for locale '${targetLocale}':`, err);
102
+ const isTimeout = err instanceof DOMException && err.name === "AbortError";
103
+ console.warn(
104
+ `[i18now] CDN fetch ${isTimeout ? "timed out" : "failed"} for locale '${targetLocale}'${isTimeout ? "" : ":"}`,
105
+ ...isTimeout ? [] : [err]
106
+ );
86
107
  }
87
108
  }
88
109
  }
@@ -90,7 +111,7 @@ export default defineNuxtPlugin(async (nuxtApp) => {
90
111
  const originalGlobalT = nuxtApp.vueApp.config.globalProperties.$t;
91
112
  if (originalGlobalT) {
92
113
  nuxtApp.vueApp.config.globalProperties.$t = function(key, defaultValue, ...rest) {
93
- const result = typeof defaultValue === "object" && defaultValue !== null ? originalGlobalT.call(this, key, defaultValue) : originalGlobalT.call(this, key);
114
+ const result = defaultValue !== void 0 ? originalGlobalT.call(this, key, defaultValue) : originalGlobalT.call(this, key);
94
115
  const defaultStr = typeof defaultValue === "string" ? defaultValue : typeof rest[0] === "string" ? rest[0] : void 0;
95
116
  if (!syncer.existingKeys.has(key) && defaultStr !== void 0) {
96
117
  syncer.syncKey(key, defaultStr);
@@ -6,7 +6,7 @@ export default defineNuxtPlugin(async (nuxtApp) => {
6
6
  async function loadLocale(locale) {
7
7
  try {
8
8
  const url = `${cdnUrl}/${projectId}/publish/${environment}/${locale}.json`;
9
- const res = await fetch(url, { signal: AbortSignal.timeout(8e3) });
9
+ const res = await fetch(url, { signal: AbortSignal.timeout(5e3) });
10
10
  if (!res.ok) {
11
11
  if (import.meta.dev) {
12
12
  console.warn(
@@ -15,7 +15,14 @@ export default defineNuxtPlugin(async (nuxtApp) => {
15
15
  }
16
16
  return;
17
17
  }
18
- const messages = await res.json();
18
+ const data = await res.json();
19
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
20
+ if (import.meta.dev) {
21
+ console.warn(`[i18now] CDN response for locale '${locale}' is not a valid object. Skipping.`);
22
+ }
23
+ return;
24
+ }
25
+ const messages = data;
19
26
  if (i18n) i18n.setLocaleMessage(locale, messages);
20
27
  } catch (err) {
21
28
  if (import.meta.dev) {
@@ -4,7 +4,7 @@ export default defineNuxtPlugin((nuxtApp) => {
4
4
  let symbolGlobal;
5
5
  if (!directGlobal) {
6
6
  const provides = nuxtApp.vueApp._context?.provides ?? {};
7
- const i18nSym = Object.getOwnPropertySymbols(provides).find((s) => s.toString() === "Symbol(vue-i18n)");
7
+ const i18nSym = Object.getOwnPropertySymbols(provides).find((s) => s.toString().startsWith("Symbol(vue-i18n"));
8
8
  const i18nInstance = i18nSym ? provides[i18nSym] : void 0;
9
9
  symbolGlobal = i18nInstance?.global;
10
10
  }
package/package.json CHANGED
@@ -1,27 +1,22 @@
1
1
  {
2
2
  "name": "@uipkge/nuxt",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": {
8
+ "types": "./dist/module.d.mts",
8
9
  "import": "./dist/module.mjs"
9
10
  }
10
11
  },
11
12
  "main": "./dist/module.mjs",
13
+ "types": "./dist/module.d.mts",
14
+ "engines": {
15
+ "node": ">=18.0.0"
16
+ },
12
17
  "files": [
13
18
  "dist"
14
19
  ],
15
- "scripts": {
16
- "build": "nuxt-module-build build",
17
- "dev": "nuxt-module-build prepare",
18
- "dev:playground": "nuxi dev playground",
19
- "prepack": "nuxt-module-build build",
20
- "test": "vitest run",
21
- "test:watch": "vitest",
22
- "test:coverage": "vitest run --coverage",
23
- "release": "vitest run && nuxt-module-build build && pnpm publish --access public --no-git-checks"
24
- },
25
20
  "dependencies": {
26
21
  "@nuxt/kit": "^3.0.0"
27
22
  },
@@ -39,9 +34,18 @@
39
34
  },
40
35
  "devDependencies": {
41
36
  "@nuxt/module-builder": "^1.0.0",
42
- "@uipkge/core": "workspace:*",
43
37
  "happy-dom": "^17.0.0",
44
38
  "nuxt": "^3.0.0",
45
- "vitest": "^4.0.0"
39
+ "vitest": "^4.0.0",
40
+ "@uipkge/core": "0.1.1"
41
+ },
42
+ "scripts": {
43
+ "build": "nuxt-module-build build",
44
+ "dev": "nuxt-module-build prepare",
45
+ "dev:playground": "nuxi dev playground",
46
+ "test": "vitest run",
47
+ "test:watch": "vitest",
48
+ "test:coverage": "vitest run --coverage",
49
+ "release": "vitest run && nuxt-module-build build && pnpm publish --access public --no-git-checks"
46
50
  }
47
- }
51
+ }
@@ -1,2 +0,0 @@
1
- declare const _default: (event: unknown) => unknown;
2
- export default _default;
@@ -1,48 +0,0 @@
1
- import { createError, defineEventHandler, readBody } from "h3";
2
- export default defineEventHandler(async (event) => {
3
- const config = useRuntimeConfig(event);
4
- const publicConfig = config.public?.i18now;
5
- const env = process.env.NODE_ENV ?? "production";
6
- if (!publicConfig?.syncIn?.includes(env)) {
7
- throw createError({ statusCode: 403, statusMessage: "Key sync is disabled in this environment" });
8
- }
9
- if (!publicConfig.projectId || !publicConfig.host) {
10
- throw createError({ statusCode: 500, statusMessage: "i18now module is not configured" });
11
- }
12
- const privateConfig = config.i18now;
13
- const apiKey = privateConfig?.apiKey;
14
- if (!apiKey) {
15
- throw createError({ statusCode: 500, statusMessage: "i18now apiKey is not configured" });
16
- }
17
- let body;
18
- try {
19
- body = await readBody(event);
20
- } catch {
21
- throw createError({ statusCode: 400, statusMessage: "Invalid request body" });
22
- }
23
- if (!Array.isArray(body?.keys)) {
24
- throw createError({ statusCode: 400, statusMessage: 'Request body must contain a "keys" array' });
25
- }
26
- const validKeys = body.keys.filter(
27
- (item) => item !== null && typeof item === "object" && typeof item.key === "string" && item.key.length > 0 && typeof item.value === "string" && item.value.length > 0
28
- );
29
- if (validKeys.length === 0) {
30
- return { synced: 0 };
31
- }
32
- if (validKeys.length > 100) {
33
- throw createError({ statusCode: 400, statusMessage: "Maximum 100 keys per request" });
34
- }
35
- const res = await fetch(
36
- `${publicConfig.host}/api/v1/projects/${publicConfig.projectId}/sync`,
37
- {
38
- method: "POST",
39
- headers: { "Content-Type": "application/json" },
40
- body: JSON.stringify({ apiKey, keys: validKeys })
41
- }
42
- );
43
- const data = await res.json();
44
- if (!res.ok) {
45
- throw createError({ statusCode: res.status, data });
46
- }
47
- return data;
48
- });