@uipkge/nuxt 0.1.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.
package/README.md ADDED
@@ -0,0 +1,130 @@
1
+ # @uipkge/nuxt
2
+
3
+ Nuxt module for [i18now](https://i18now.com) — loads translations from the i18now CDN and automatically syncs new keys to your project during development.
4
+
5
+ ## Features
6
+
7
+ - Loads published translations from the i18now CDN at SSR time
8
+ - Reloads translations on locale switch (client-side)
9
+ - In development: detects missing translation keys and syncs them to i18now automatically
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)
12
+ - Zero bundle impact in production — dev-only code is tree-shaken at build time
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install @uipkge/nuxt
18
+ # or
19
+ pnpm add @uipkge/nuxt
20
+ ```
21
+
22
+ `@nuxtjs/i18n` is a dependency and will be installed automatically.
23
+
24
+ ## Setup
25
+
26
+ Add the module to `nuxt.config.ts`:
27
+
28
+ ```ts
29
+ export default defineNuxtConfig({
30
+ modules: ['@uipkge/nuxt'],
31
+
32
+ i18now: {
33
+ projectId: process.env.I18NOW_PROJECT_ID ?? '',
34
+ apiKey: process.env.I18NOW_API_KEY ?? '',
35
+ },
36
+
37
+ i18n: {
38
+ defaultLocale: 'en',
39
+ locales: ['en', 'es', 'fr'],
40
+ bundle: {
41
+ optimizeTranslationDirective: false, // buggy, deprecated in v10
42
+ },
43
+ },
44
+ })
45
+ ```
46
+
47
+ ## Configuration
48
+
49
+ | Option | Type | Default | Description |
50
+ |---------------|------------|----------------------------|-----------------------------------------------------------------------------|
51
+ | `projectId` | `string` | — | **Required.** Your i18now project ID. |
52
+ | `apiKey` | `string` | — | **Required.** Your i18now API key. Never sent to the browser. |
53
+ | `host` | `string` | `https://i18now.com` | i18now server URL. Set to `http://localhost:3220` for local development. |
54
+ | `cdnUrl` | `string` | `https://cdn.i18now.com` | CDN base URL where published translations are served. |
55
+ | `environment` | `string` | `'dev'` | Which published environment to load translations from (`dev`, `stage`, `prod`). |
56
+ | `syncIn` | `string[]` | `['development']` | Node environments where missing-key sync is active. Keep as `['development']`. |
57
+ | `locale` | `string` | `'en'` | Fallback locale used before `@nuxtjs/i18n` initialises. |
58
+
59
+ ### Environment variables
60
+
61
+ Store secrets in `.env`:
62
+
63
+ ```env
64
+ I18NOW_PROJECT_ID=your-project-id
65
+ I18NOW_API_KEY=your-api-key
66
+ ```
67
+
68
+ ## How it works
69
+
70
+ ### Production
71
+
72
+ The server plugin fetches the published translation JSON from the CDN (`{cdnUrl}/{projectId}/publish/{environment}/{locale}.json`) during SSR and injects it into vue-i18n. Translations are refreshed on locale switch.
73
+
74
+ ### Development
75
+
76
+ 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.
77
+
78
+ ```
79
+ Browser $t('key', 'Default text')
80
+ → client plugin detects missing key
81
+ → POST /api/_i18now/sync { keys: [{ key, value }] }
82
+ → Nuxt server adds apiKey
83
+ → POST i18now /api/v1/projects/:id/sync
84
+ ```
85
+
86
+ 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.
87
+
88
+ 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.
89
+
90
+ ## Security
91
+
92
+ - `apiKey` is stored in Nuxt's private (server-only) `runtimeConfig` — it is never serialised into the client bundle or sent in any browser request.
93
+ - 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.
94
+
95
+ ## Local development against a local i18now server
96
+
97
+ ```ts
98
+ // nuxt.config.ts
99
+ i18now: {
100
+ projectId: 'your-project-id',
101
+ apiKey: 'your-api-key',
102
+ host: 'http://localhost:3220',
103
+ cdnUrl: 'http://localhost:3220', // if serving snapshots locally
104
+ environment: 'dev',
105
+ },
106
+ ```
107
+
108
+ ## Playground
109
+
110
+ A minimal playground is included in `playground/`. To run it:
111
+
112
+ ```bash
113
+ pnpm dev:playground
114
+ ```
115
+
116
+ ## Building locally
117
+
118
+ ```bash
119
+ pnpm build
120
+ ```
121
+
122
+ To use the built package in another local project, add a `file:` reference:
123
+
124
+ ```json
125
+ {
126
+ "dependencies": {
127
+ "@uipkge/nuxt": "file:../path/to/i18now/packages/nuxt"
128
+ }
129
+ }
130
+ ```
@@ -0,0 +1,22 @@
1
+ import * as nuxt_schema from 'nuxt/schema';
2
+
3
+ interface ModuleOptions {
4
+ projectId: string;
5
+ apiKey: string;
6
+ host?: string;
7
+ /** CDN base URL where published translations are served. Default: https://cdn.i18now.com */
8
+ cdnUrl?: string;
9
+ /** Which published environment to load translations from. Default: 'dev' */
10
+ environment?: string;
11
+ /** Environments where key sync is active. Default: ['development'] */
12
+ syncIn?: string[];
13
+ /**
14
+ * Fallback locale to load from CDN when @nuxtjs/i18n has not yet initialised.
15
+ * Default: 'en'
16
+ */
17
+ locale?: string;
18
+ }
19
+ declare const _default: nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
20
+
21
+ export { _default as default };
22
+ export type { ModuleOptions };
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@uipkge/nuxt",
3
+ "configKey": "i18now",
4
+ "compatibility": {
5
+ "nuxt": ">=3.0.0"
6
+ },
7
+ "version": "0.1.0",
8
+ "builder": {
9
+ "@nuxt/module-builder": "1.0.2",
10
+ "unbuild": "unknown"
11
+ }
12
+ }
@@ -0,0 +1,75 @@
1
+ import { defineNuxtModule, createResolver, addPlugin, addServerHandler, addImports } from '@nuxt/kit';
2
+
3
+ const module$1 = defineNuxtModule({
4
+ meta: {
5
+ name: "@uipkge/nuxt",
6
+ configKey: "i18now",
7
+ compatibility: { nuxt: ">=3.0.0" }
8
+ },
9
+ // Auto-install @nuxtjs/i18n when the user hasn't added it themselves.
10
+ // It ships as a dependency of @uipkge/nuxt so no separate install is needed.
11
+ moduleDependencies: {
12
+ "@nuxtjs/i18n": { version: "^9.0.0" }
13
+ },
14
+ defaults: {
15
+ host: "https://i18now.com",
16
+ cdnUrl: "https://cdn.i18now.com",
17
+ environment: "dev",
18
+ syncIn: ["development"],
19
+ locale: "en"
20
+ },
21
+ async setup(options, nuxt) {
22
+ const resolver = createResolver(import.meta.url);
23
+ if (!options.projectId) {
24
+ console.warn("[i18now] projectId is required. The module will not work until it is set.");
25
+ }
26
+ if (!options.apiKey) {
27
+ console.warn("[i18now] apiKey is required. The module will not work until it is set.");
28
+ }
29
+ const dangerousEnvs = (options.syncIn ?? []).filter(
30
+ (e) => ["production", "staging", "prod", "stage"].includes(e)
31
+ );
32
+ if (dangerousEnvs.length > 0) {
33
+ console.error(
34
+ `[i18now] DANGER: syncIn includes "${dangerousEnvs.join('", "')}" \u2014 key sync is enabled in a production-like environment. This will expose your API key to users and flood i18now with production traffic. syncIn should only contain "development".`
35
+ );
36
+ }
37
+ nuxt.options.runtimeConfig.i18now = {
38
+ apiKey: options.apiKey
39
+ };
40
+ nuxt.options.runtimeConfig.public.i18now = {
41
+ projectId: options.projectId,
42
+ host: options.host,
43
+ cdnUrl: options.cdnUrl,
44
+ environment: options.environment,
45
+ syncIn: options.syncIn,
46
+ locale: options.locale
47
+ };
48
+ addPlugin({
49
+ src: resolver.resolve("./runtime/plugin"),
50
+ mode: "server"
51
+ });
52
+ if (nuxt.options.dev) {
53
+ addServerHandler({
54
+ route: "/api/_i18now/sync",
55
+ handler: resolver.resolve("./runtime/server/sync"),
56
+ method: "post"
57
+ });
58
+ addPlugin({
59
+ src: resolver.resolve("./runtime/plugin.client"),
60
+ mode: "client"
61
+ });
62
+ addPlugin({
63
+ src: resolver.resolve("./runtime/plugin.suppress-warnings")
64
+ });
65
+ addImports({
66
+ name: "useI18n",
67
+ as: "useI18n",
68
+ from: resolver.resolve("./runtime/composables/useI18n"),
69
+ priority: 2
70
+ });
71
+ }
72
+ }
73
+ });
74
+
75
+ export { module$1 as default };
@@ -0,0 +1,11 @@
1
+ import { useI18n as _useI18n } from 'vue-i18n';
2
+ type UseI18nOptions = Parameters<typeof _useI18n>[0];
3
+ type UseI18nReturn = ReturnType<typeof _useI18n>;
4
+ /**
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.
7
+ */
8
+ export declare function useI18n(options?: UseI18nOptions): UseI18nReturn & {
9
+ te: (key: string, locale?: string) => boolean;
10
+ };
11
+ export {};
@@ -0,0 +1,24 @@
1
+ import { useNuxtApp } from "#app";
2
+ import { useI18n as _useI18n } from "vue-i18n";
3
+ export function useI18n(options) {
4
+ const i18n = _useI18n({ useScope: "global", ...options });
5
+ const nuxtApp = useNuxtApp();
6
+ const originalT = i18n.t.bind(i18n);
7
+ const patchedT = (key, defaultValue, ...rest) => {
8
+ const result = originalT(key, defaultValue, ...rest);
9
+ const sync = nuxtApp.$i18nowSync;
10
+ if (sync && !sync.existingKeys.has(key) && typeof defaultValue === "string") {
11
+ sync.syncKey(key, defaultValue);
12
+ }
13
+ return result;
14
+ };
15
+ const te = (key, _locale) => {
16
+ const result = originalT(key);
17
+ return typeof result === "string" && result !== key;
18
+ };
19
+ return {
20
+ ...i18n,
21
+ te,
22
+ t: patchedT
23
+ };
24
+ }
@@ -0,0 +1,9 @@
1
+ declare const _default: (nuxtApp: unknown) => Promise<{
2
+ provide: {
3
+ i18nowSync: {
4
+ syncKey: (key: string, value: string) => void;
5
+ existingKeys: Set<string>;
6
+ };
7
+ };
8
+ } | undefined>;
9
+ export default _default;
@@ -0,0 +1,93 @@
1
+ import { defineNuxtPlugin, useRuntimeConfig } from "#app";
2
+ const MAX_TRACKED = 2e3;
3
+ const MAX_RETRIES = 3;
4
+ export default defineNuxtPlugin(async (nuxtApp) => {
5
+ const { projectId, cdnUrl, environment, syncIn, locale: configLocale } = useRuntimeConfig().public.i18now;
6
+ if (!syncIn.includes(process.env.NODE_ENV ?? "development")) return;
7
+ if (!projectId) {
8
+ console.warn("[i18now] projectId is not set \u2014 key sync disabled.");
9
+ return;
10
+ }
11
+ const i18nGlobal = nuxtApp.$i18n ?? nuxtApp.vueApp.config.globalProperties.$i18n;
12
+ const existingKeys = /* @__PURE__ */ new Set();
13
+ const locale = i18nGlobal?.locale?.value ?? i18nGlobal?.locale ?? configLocale;
14
+ async function loadLocale(targetLocale) {
15
+ try {
16
+ const res = await fetch(`${cdnUrl}/${projectId}/publish/${environment}/${targetLocale}.json`);
17
+ if (res.ok) {
18
+ const data = await res.json();
19
+ existingKeys.clear();
20
+ for (const key of Object.keys(data)) existingKeys.add(key);
21
+ i18nGlobal?.setLocaleMessage(targetLocale, data);
22
+ } else if (process.env.NODE_ENV === "development") {
23
+ console.warn(
24
+ `[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: ${cdnUrl}/${projectId}/publish/${environment}/${targetLocale}.json`
25
+ );
26
+ }
27
+ } catch (err) {
28
+ if (process.env.NODE_ENV === "development") {
29
+ console.warn(`[i18now] CDN fetch failed for locale '${targetLocale}':`, err);
30
+ }
31
+ }
32
+ }
33
+ await loadLocale(locale);
34
+ const synced = /* @__PURE__ */ new Set();
35
+ const retries = /* @__PURE__ */ new Map();
36
+ const pending = /* @__PURE__ */ new Map();
37
+ let flushTimer = null;
38
+ function flush() {
39
+ flushTimer = null;
40
+ if (pending.size === 0) return;
41
+ const keys = Array.from(pending.entries()).map(([key, value]) => ({ key, value }));
42
+ pending.clear();
43
+ fetch("/api/_i18now/sync", {
44
+ method: "POST",
45
+ headers: { "Content-Type": "application/json" },
46
+ body: JSON.stringify({ keys })
47
+ }).then((res) => {
48
+ if (!res.ok) {
49
+ if (process.env.NODE_ENV === "development") {
50
+ res.json().then((data) => console.warn(`[i18now] Sync failed (${res.status}):`, data?.statusMessage ?? data)).catch(() => console.warn(`[i18now] Sync failed with status ${res.status}`));
51
+ }
52
+ throw new Error(`${res.status}`);
53
+ }
54
+ }).catch(() => {
55
+ for (const { key } of keys) {
56
+ const attempts = (retries.get(key) ?? 0) + 1;
57
+ if (attempts < MAX_RETRIES) {
58
+ retries.set(key, attempts);
59
+ synced.delete(key);
60
+ } else {
61
+ retries.delete(key);
62
+ if (process.env.NODE_ENV === "development") {
63
+ console.warn(`[i18now] Sync failed after ${MAX_RETRIES} attempts for key "${key}". Giving up.`);
64
+ }
65
+ }
66
+ }
67
+ });
68
+ }
69
+ function syncKey(key, value) {
70
+ if (synced.has(key) || synced.size >= MAX_TRACKED) return;
71
+ synced.add(key);
72
+ pending.set(key, value);
73
+ if (!flushTimer) flushTimer = setTimeout(flush, 0);
74
+ }
75
+ const originalGlobalT = nuxtApp.vueApp.config.globalProperties.$t;
76
+ if (originalGlobalT) {
77
+ nuxtApp.vueApp.config.globalProperties.$t = function(key, defaultValue, ...rest) {
78
+ const result = originalGlobalT.call(this, key, defaultValue, ...rest);
79
+ if (!existingKeys.has(key) && typeof defaultValue === "string") {
80
+ syncKey(key, defaultValue);
81
+ }
82
+ return result;
83
+ };
84
+ }
85
+ nuxtApp.hook("i18n:beforeLocaleSwitch", async ({ newLocale }) => {
86
+ await loadLocale(newLocale);
87
+ });
88
+ return {
89
+ provide: {
90
+ i18nowSync: { syncKey, existingKeys }
91
+ }
92
+ };
93
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: (nuxtApp: unknown) => Promise<void>;
2
+ export default _default;
@@ -0,0 +1,23 @@
1
+ import { defineNuxtPlugin, useRuntimeConfig } from "#app";
2
+ export default defineNuxtPlugin(async (nuxtApp) => {
3
+ const { projectId, cdnUrl, environment, locale: configLocale } = useRuntimeConfig().public.i18now;
4
+ const i18n = nuxtApp.$i18n ?? nuxtApp.vueApp.config.globalProperties.$i18n;
5
+ async function loadLocale(locale) {
6
+ try {
7
+ const url = `${cdnUrl}/${projectId}/publish/${environment}/${locale}.json`;
8
+ const res = await fetch(url);
9
+ if (!res.ok) return;
10
+ const messages = await res.json();
11
+ if (i18n) i18n.setLocaleMessage(locale, messages);
12
+ } catch (err) {
13
+ if (process.env.NODE_ENV === "development") {
14
+ console.warn(`[i18now] Failed to load locale '${locale}' from CDN:`, err);
15
+ }
16
+ }
17
+ }
18
+ const currentLocale = i18n?.locale?.value ?? i18n?.locale ?? configLocale;
19
+ await loadLocale(currentLocale);
20
+ nuxtApp.hook("i18n:beforeLocaleSwitch", async ({ newLocale }) => {
21
+ await loadLocale(newLocale);
22
+ });
23
+ });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Dev-only: suppress vue-i18n "Not found key" and fallback warnings.
3
+ * In i18now's workflow, missing keys are expected — they haven't been published
4
+ * to the CDN yet. The sync plugin handles them automatically, so the warnings
5
+ * are noise rather than actionable signal.
6
+ *
7
+ * Note: In @nuxtjs/i18n v9, nuxtApp.$i18n is the NuxtI18n object (with setLocale,
8
+ * locales, etc.) — it has no .global property. The real vue-i18n I18n instance
9
+ * lives under Symbol('vue-i18n') in the Vue app's provides.
10
+ */
11
+ declare const _default: (nuxtApp: unknown) => void;
12
+ export default _default;
@@ -0,0 +1,11 @@
1
+ import { defineNuxtPlugin } from "#app";
2
+ export default defineNuxtPlugin((nuxtApp) => {
3
+ const provides = nuxtApp.vueApp._context?.provides ?? {};
4
+ const i18nSym = Object.getOwnPropertySymbols(provides).find((s) => s.toString() === "Symbol(vue-i18n)");
5
+ const i18nInstance = i18nSym ? provides[i18nSym] : void 0;
6
+ const global = i18nInstance?.global;
7
+ if (global) {
8
+ global.missingWarn = false;
9
+ global.fallbackWarn = false;
10
+ }
11
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: (event: unknown) => unknown;
2
+ export default _default;
@@ -0,0 +1,42 @@
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 ?? "development";
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
+ if (body.keys.length > 100) {
27
+ throw createError({ statusCode: 400, statusMessage: "Maximum 100 keys per request" });
28
+ }
29
+ const res = await fetch(
30
+ `${publicConfig.host}/api/v1/projects/${publicConfig.projectId}/sync`,
31
+ {
32
+ method: "POST",
33
+ headers: { "Content-Type": "application/json" },
34
+ body: JSON.stringify({ apiKey, keys: body.keys })
35
+ }
36
+ );
37
+ const data = await res.json();
38
+ if (!res.ok) {
39
+ throw createError({ statusCode: res.status, data });
40
+ }
41
+ return data;
42
+ });
@@ -0,0 +1,3 @@
1
+ export { default } from './module.mjs'
2
+
3
+ export { type ModuleOptions } from './module.mjs'
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@uipkge/nuxt",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "import": "./dist/module.mjs"
9
+ }
10
+ },
11
+ "main": "./dist/module.mjs",
12
+ "files": ["dist"],
13
+ "scripts": {
14
+ "build": "nuxt-module-build build",
15
+ "dev": "nuxt-module-build prepare",
16
+ "dev:playground": "nuxi dev playground",
17
+ "prepack": "nuxt-module-build build",
18
+ "test": "vitest run",
19
+ "test:watch": "vitest",
20
+ "test:coverage": "vitest run --coverage"
21
+ },
22
+ "dependencies": {
23
+ "@nuxt/kit": "^3.0.0",
24
+ "@nuxtjs/i18n": "^9.0.0"
25
+ },
26
+ "peerDependencies": {
27
+ "vue-i18n": "^9.0.0 || ^10.0.0"
28
+ },
29
+ "peerDependenciesMeta": {
30
+ "vue-i18n": { "optional": true }
31
+ },
32
+ "devDependencies": {
33
+ "@nuxt/module-builder": "^1.0.0",
34
+ "happy-dom": "^17.0.0",
35
+ "nuxt": "^3.0.0",
36
+ "vitest": "^4.0.0"
37
+ }
38
+ }