@uipkge/nuxt 0.1.14 → 0.1.16

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
 
@@ -104,12 +104,23 @@ i18now: {
104
104
 
105
105
  ## Playground
106
106
 
107
- A minimal playground is included in `playground/`. To run it:
107
+ A minimal playground is included in `playground/`. It runs on **port 3227**.
108
108
 
109
109
  ```bash
110
110
  pnpm dev:playground
111
111
  ```
112
112
 
113
+ ### Framework playground ports
114
+
115
+ All SDK playgrounds use fixed ports so they can run simultaneously alongside the i18now server (port 3220):
116
+
117
+ | Package | Port |
118
+ |---|---|
119
+ | `@uipkge/react` | 3224 |
120
+ | `@uipkge/vue` | 3225 |
121
+ | `@uipkge/next` | 3226 |
122
+ | `@uipkge/nuxt` | 3227 |
123
+
113
124
  ## Building locally
114
125
 
115
126
  ```bash
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.0.0"
6
6
  },
7
- "version": "0.1.14",
7
+ "version": "0.1.16",
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,22 +34,33 @@ 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
  });
45
- addImports({
46
- name: "useI18now",
47
- as: "useI18now",
48
- from: resolver.resolve("./runtime/composables/useI18n")
49
- });
52
+ addImports([
53
+ {
54
+ name: "useI18now",
55
+ as: "useI18now",
56
+ from: resolver.resolve("./runtime/composables/useI18n")
57
+ },
58
+ {
59
+ name: "useI18nowLocales",
60
+ as: "useI18nowLocales",
61
+ from: resolver.resolve("./runtime/composables/useI18nowLocales")
62
+ }
63
+ ]);
50
64
  if (nuxt.options.dev) {
51
65
  addPlugin({
52
66
  src: resolver.resolve("./runtime/plugin.client"),
@@ -0,0 +1,34 @@
1
+ export interface I18nowLocale {
2
+ code: string;
3
+ name: string;
4
+ nativeName: string;
5
+ isSource: boolean;
6
+ }
7
+ /**
8
+ * Returns the list of locales published in the i18now dashboard.
9
+ *
10
+ * Fetches `{cdnUrl}/{projectId}/locales.json` (written on every publish)
11
+ * so the app auto-discovers new languages without code changes or redeployment.
12
+ *
13
+ * Usage:
14
+ * ```vue
15
+ * <script setup>
16
+ * const { locales, sourceLocale, pending } = useI18nowLocales()
17
+ * </script>
18
+ *
19
+ * <template>
20
+ * <select @change="setLocale($event.target.value)">
21
+ * <option v-for="l in locales" :key="l.code" :value="l.code">
22
+ * {{ l.nativeName }}
23
+ * </option>
24
+ * </select>
25
+ * </template>
26
+ * ```
27
+ */
28
+ export declare function useI18nowLocales(): {
29
+ locales: import("vue").ComputedRef<any>;
30
+ sourceLocale: import("vue").ComputedRef<any>;
31
+ pending: any;
32
+ error: any;
33
+ refresh: any;
34
+ };
@@ -0,0 +1,21 @@
1
+ import { useAsyncData, useRuntimeConfig } from "#app";
2
+ import { computed } from "vue";
3
+ export function useI18nowLocales() {
4
+ const { projectId, cdnUrl } = useRuntimeConfig().public.i18now;
5
+ const { data, pending, error, refresh } = useAsyncData(
6
+ "i18now-locales",
7
+ async () => {
8
+ if (!projectId) return [];
9
+ const url = `${cdnUrl}/${projectId}/locales.json`;
10
+ const res = await fetch(url, { signal: AbortSignal.timeout(5e3) });
11
+ if (!res.ok) return [];
12
+ const data2 = await res.json();
13
+ if (!Array.isArray(data2)) return [];
14
+ return data2;
15
+ },
16
+ { default: () => [] }
17
+ );
18
+ const locales = computed(() => data.value ?? []);
19
+ const sourceLocale = computed(() => data.value?.find((l) => l.isSource) ?? null);
20
+ return { locales, sourceLocale, pending, error, refresh };
21
+ }
@@ -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
  }
@@ -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.14",
3
+ "version": "0.1.16",
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 --port 3227",
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
- });