@astroscope/i18n 0.2.1 → 0.2.2

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.
@@ -103,10 +103,11 @@ function i18nExtractPlugin({ types: t }) {
103
103
  const meta = args.length >= 2 && t.isExpression(args[1]) ? extractMeta(t, args[1], warn) : { fallback: "" };
104
104
  state.opts.onKeyExtracted({ key, meta, file, line });
105
105
  if (state.opts.stripFallbacks && args.length >= 2) {
106
+ const firstArg = args[0];
106
107
  if (args.length === 2) {
107
- path.node.arguments = [args[0]];
108
+ path.node.arguments = [firstArg];
108
109
  } else {
109
- path.node.arguments = [args[0], t.identifier("undefined"), args[2]];
110
+ path.node.arguments = [firstArg, t.identifier("undefined"), args[2]];
110
111
  }
111
112
  }
112
113
  }
@@ -29,31 +29,194 @@ function getManifest() {
29
29
  }
30
30
 
31
31
  // src/extraction/vite-plugin.ts
32
- import fs from "fs";
32
+ import fs2 from "fs";
33
33
  import path2 from "path";
34
34
  import MagicString from "magic-string";
35
+
36
+ // src/extraction/extract.ts
37
+ var TS_EXTENSIONS = [".ts", ".tsx"];
38
+ var JSX_EXTENSIONS = [".jsx", ".tsx"];
39
+ var JS_EXTENSIONS = [".js", ".jsx"];
40
+ var BABEL_EXTENSIONS = [...TS_EXTENSIONS, ...JS_EXTENSIONS];
41
+ var ALL_EXTENSIONS = [...BABEL_EXTENSIONS, ".astro"];
42
+ async function extractKeysFromFile(options) {
43
+ const { filename, code, logger, stripFallbacks = false, onKeyExtracted } = options;
44
+ const keys = [];
45
+ const [{ transformAsync }, { i18nExtractPlugin }] = await Promise.all([
46
+ import("@babel/core"),
47
+ import("./babel-plugin-GOB6FMZ2.js")
48
+ ]);
49
+ const result = await transformAsync(code, {
50
+ filename,
51
+ sourceMaps: stripFallbacks,
52
+ // only need source maps when transforming
53
+ babelrc: false,
54
+ configFile: false,
55
+ parserOpts: {
56
+ plugins: [
57
+ ...TS_EXTENSIONS.some((ext) => filename.endsWith(ext)) ? ["typescript"] : [],
58
+ ...JSX_EXTENSIONS.some((ext) => filename.endsWith(ext)) ? ["jsx"] : []
59
+ ]
60
+ },
61
+ plugins: [
62
+ [
63
+ i18nExtractPlugin,
64
+ {
65
+ stripFallbacks,
66
+ onKeyExtracted: (key) => {
67
+ keys.push(key);
68
+ onKeyExtracted?.(key);
69
+ },
70
+ logger
71
+ }
72
+ ]
73
+ ]
74
+ });
75
+ return {
76
+ keys,
77
+ code: result?.code ?? null,
78
+ map: result?.map
79
+ };
80
+ }
81
+
82
+ // src/extraction/key-store.ts
83
+ var KeyStore = class {
84
+ constructor(logger) {
85
+ this.logger = logger;
86
+ }
87
+ extractedKeys = [];
88
+ fileToKeys = /* @__PURE__ */ new Map();
89
+ filesWithI18n = /* @__PURE__ */ new Set();
90
+ /**
91
+ * Add keys for a file, replacing any existing keys for that file.
92
+ */
93
+ addFileKeys(filename, keys) {
94
+ const oldKeys = this.fileToKeys.get(filename);
95
+ const newKeyStrings = keys.map((k) => k.key);
96
+ if (oldKeys) {
97
+ const added = newKeyStrings.filter((k) => !oldKeys.includes(k));
98
+ const removed = oldKeys.filter((k) => !newKeyStrings.includes(k));
99
+ const file = filename.split("/").pop();
100
+ if (added.length > 0 || removed.length > 0) {
101
+ const parts = [];
102
+ if (added.length > 0) parts.push(`+${added.length}`);
103
+ if (removed.length > 0) parts.push(`-${removed.length}`);
104
+ this.logger.info(`hmr: ${parts.join(" ")} key(s) in ${file}`);
105
+ }
106
+ }
107
+ if (oldKeys) {
108
+ for (let i = this.extractedKeys.length - 1; i >= 0; i--) {
109
+ if (this.extractedKeys[i]?.file === filename) {
110
+ this.extractedKeys.splice(i, 1);
111
+ }
112
+ }
113
+ }
114
+ this.filesWithI18n.add(filename);
115
+ if (keys.length > 0) {
116
+ this.extractedKeys.push(...keys);
117
+ }
118
+ this.fileToKeys.set(filename, newKeyStrings);
119
+ }
120
+ /**
121
+ * Get count of unique keys.
122
+ */
123
+ get uniqueKeyCount() {
124
+ return new Set(this.extractedKeys.map((k) => k.key)).size;
125
+ }
126
+ /**
127
+ * Merge another store's data into this one.
128
+ */
129
+ merge(other) {
130
+ for (const [file, keys] of other.fileToKeys) {
131
+ this.fileToKeys.set(file, keys);
132
+ }
133
+ for (const file of other.filesWithI18n) {
134
+ this.filesWithI18n.add(file);
135
+ }
136
+ this.extractedKeys.push(...other.extractedKeys);
137
+ }
138
+ };
139
+
140
+ // src/extraction/scan.ts
141
+ import fs from "fs";
142
+ import { glob } from "glob";
143
+ var GLOB_PATTERN = `src/**/*.{${ALL_EXTENSIONS.map((e) => e.slice(1)).join(",")}}`;
144
+ async function compileAstro(code, filename) {
145
+ try {
146
+ const { transform } = await import("@astrojs/compiler");
147
+ const result = await transform(code, { filename });
148
+ return result.code;
149
+ } catch {
150
+ return null;
151
+ }
152
+ }
153
+ async function scan(projectRoot, logger) {
154
+ const store = new KeyStore(logger);
155
+ const files = await glob(GLOB_PATTERN, {
156
+ cwd: projectRoot,
157
+ absolute: true,
158
+ ignore: ["**/node_modules/**"]
159
+ });
160
+ const results = await Promise.all(
161
+ files.map(async (file) => {
162
+ let code = await fs.promises.readFile(file, "utf-8");
163
+ if (!code.includes("@astroscope/i18n/t")) {
164
+ return { file, keys: null };
165
+ }
166
+ if (file.endsWith(".astro")) {
167
+ const compiled = await compileAstro(code, file);
168
+ if (!compiled) {
169
+ return { file, keys: null };
170
+ }
171
+ code = compiled;
172
+ }
173
+ const result = await extractKeysFromFile({
174
+ filename: file,
175
+ code,
176
+ logger,
177
+ stripFallbacks: false
178
+ });
179
+ return { file, keys: result.keys.length > 0 ? result.keys : null };
180
+ })
181
+ );
182
+ for (const { file, keys } of results) {
183
+ if (keys) {
184
+ store.addFileKeys(file, keys);
185
+ }
186
+ }
187
+ return store;
188
+ }
189
+
190
+ // src/extraction/vite-plugin.ts
35
191
  var VIRTUAL_MODULE_ID = "virtual:@astroscope/i18n/manifest";
36
192
  var isVirtualModuleId = (id) => id === VIRTUAL_MODULE_ID || id.startsWith(`${VIRTUAL_MODULE_ID}?`);
37
193
  var RESOLVED_VIRTUAL_MODULE_ID = `\0${VIRTUAL_MODULE_ID}`;
38
194
  var isResolvedVirtualModuleId = (id) => id === RESOLVED_VIRTUAL_MODULE_ID || id.startsWith(`\0${VIRTUAL_MODULE_ID}?`);
39
195
  var MANIFEST_FILE_NAME = "i18n-manifest.json";
40
- var INCLUDE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".astro"];
41
196
  function i18nVitePlugin(options) {
42
197
  const { logger } = options;
43
- const extractedKeys = [];
44
- const fileToKeys = /* @__PURE__ */ new Map();
45
- const filesWithI18n = /* @__PURE__ */ new Set();
198
+ const store = new KeyStore(logger);
46
199
  let isBuild = false;
47
200
  let isSSR = false;
201
+ let projectRoot = "";
48
202
  return {
49
203
  name: "@astroscope/i18n/extract",
50
204
  enforce: "pre",
51
205
  configResolved(config) {
52
206
  const state = getGlobalState();
53
207
  state.projectRoot = config.root;
54
- state.extractedKeys = extractedKeys;
208
+ state.extractedKeys = store.extractedKeys;
55
209
  isBuild = config.command === "build";
56
210
  isSSR = !!config.build.ssr;
211
+ projectRoot = config.root;
212
+ },
213
+ async configureServer(server) {
214
+ if (server.config.isProduction || isBuild) {
215
+ return;
216
+ }
217
+ const result = await scan(projectRoot, logger);
218
+ store.merge(result);
219
+ logger.info(`dev mode: scanned ${result.fileToKeys.size} files, found ${result.uniqueKeyCount} keys`);
57
220
  },
58
221
  resolveId(id) {
59
222
  if (isVirtualModuleId(id)) {
@@ -83,7 +246,7 @@ export function getManifest() { return _getManifest(); }
83
246
  }
84
247
  },
85
248
  async transform(code, filename) {
86
- if (!INCLUDE_EXTENSIONS.some((ext) => filename.endsWith(ext))) {
249
+ if (!ALL_EXTENSIONS.some((ext) => filename.endsWith(ext))) {
87
250
  return null;
88
251
  }
89
252
  if (filename.includes("node_modules")) {
@@ -92,43 +255,14 @@ export function getManifest() { return _getManifest(); }
92
255
  if (!code.includes("@astroscope/i18n/t")) {
93
256
  return null;
94
257
  }
95
- filesWithI18n.add(filename);
96
- const fileKeys = [];
97
- const [{ transformAsync }, { i18nExtractPlugin }] = await Promise.all([
98
- import("@babel/core"),
99
- import("./babel-plugin-K2K4ZPM5.js")
100
- ]);
101
- const result = await transformAsync(code, {
258
+ const result = await extractKeysFromFile({
102
259
  filename,
103
- sourceMaps: true,
104
- babelrc: false,
105
- configFile: false,
106
- parserOpts: {
107
- plugins: [
108
- ...filename.endsWith(".ts") || filename.endsWith(".tsx") ? ["typescript"] : [],
109
- ...filename.endsWith(".jsx") || filename.endsWith(".tsx") ? ["jsx"] : []
110
- ]
111
- },
112
- plugins: [
113
- [
114
- i18nExtractPlugin,
115
- {
116
- stripFallbacks: isBuild,
117
- // only strip in production
118
- onKeyExtracted: (key) => fileKeys.push(key),
119
- logger
120
- }
121
- ]
122
- ]
260
+ code,
261
+ logger,
262
+ stripFallbacks: isBuild
123
263
  });
124
- if (fileKeys.length > 0) {
125
- extractedKeys.push(...fileKeys);
126
- fileToKeys.set(
127
- filename,
128
- fileKeys.map((k) => k.key)
129
- );
130
- }
131
- if (isBuild && result?.code) {
264
+ store.addFileKeys(filename, result.keys);
265
+ if (isBuild && result.code) {
132
266
  return { code: result.code, map: result.map };
133
267
  }
134
268
  return null;
@@ -145,9 +279,9 @@ export function getManifest() { return _getManifest(); }
145
279
  const keys = /* @__PURE__ */ new Set();
146
280
  let hasI18nModule = false;
147
281
  for (const moduleId of chunk.moduleIds) {
148
- const moduleKeys = fileToKeys.get(moduleId);
282
+ const moduleKeys = store.fileToKeys.get(moduleId);
149
283
  moduleKeys?.forEach((k) => keys.add(k));
150
- if (filesWithI18n.has(moduleId)) {
284
+ if (store.filesWithI18n.has(moduleId)) {
151
285
  hasI18nModule = true;
152
286
  }
153
287
  }
@@ -202,14 +336,14 @@ export function getManifest() { return _getManifest(); }
202
336
  chunk.map = s.generateMap({ hires: true });
203
337
  }
204
338
  }
205
- logger.info(`manifest: ${Object.keys(state.chunkManifest).length} chunks, ${extractedKeys.length} keys`);
339
+ logger.info(`manifest: ${Object.keys(state.chunkManifest).length} chunks, ${store.uniqueKeyCount} keys`);
206
340
  },
207
341
  writeBundle(outputOptions) {
208
342
  if (!isSSR && outputOptions.dir) {
209
343
  const chunksDir = path2.resolve(outputOptions.dir, "..", "server", "chunks");
210
344
  const manifestPath = path2.join(chunksDir, MANIFEST_FILE_NAME);
211
- if (fs.existsSync(chunksDir)) {
212
- fs.writeFileSync(manifestPath, JSON.stringify(getManifest()));
345
+ if (fs2.existsSync(chunksDir)) {
346
+ fs2.writeFileSync(manifestPath, JSON.stringify(getManifest()));
213
347
  } else {
214
348
  logger.error(`server chunks directory not found, cannot write manifest to ${manifestPath}`);
215
349
  }
@@ -53,6 +53,8 @@ var I18nSingleton = class {
53
53
  hashCache = /* @__PURE__ */ new Map();
54
54
  // locale -> inline script for I18nScript component
55
55
  scriptCache = /* @__PURE__ */ new Map();
56
+ // "locale:chunkName" -> encoded chunk response body
57
+ chunkCache = /* @__PURE__ */ new Map();
56
58
  // manifest getter (set by init module, provides live data in dev mode)
57
59
  manifestGetter = null;
58
60
  async configure(config) {
@@ -153,6 +155,38 @@ var I18nSingleton = class {
153
155
  getHashes(locale) {
154
156
  return this.hashCache.get(locale) ?? {};
155
157
  }
158
+ /**
159
+ * Get the encoded chunk response body for a locale/chunk combination.
160
+ * Cached to avoid repeated JSON.stringify and encoding on each request.
161
+ * Returns undefined if chunk not found in manifest.
162
+ */
163
+ getChunkBody(locale, chunkName) {
164
+ const keys = this.getManifest().chunks[chunkName];
165
+ if (!keys) {
166
+ return void 0;
167
+ }
168
+ const cacheKey = `${locale}:${chunkName}`;
169
+ const cached = this.chunkCache.get(cacheKey);
170
+ if (cached) {
171
+ return cached;
172
+ }
173
+ const translations = this.getTranslations(locale);
174
+ const chunkTranslations = {};
175
+ for (const key of keys) {
176
+ if (translations[key]) {
177
+ chunkTranslations[key] = translations[key];
178
+ }
179
+ }
180
+ const js = `/* @astroscope/i18n chunk: ${chunkName} */
181
+ (function() {
182
+ var i = window.__i18n__;
183
+ if (i) Object.assign(i.translations, ${JSON.stringify(chunkTranslations)});
184
+ })();
185
+ `;
186
+ const body = new TextEncoder().encode(js);
187
+ this.chunkCache.set(cacheKey, body);
188
+ return body;
189
+ }
156
190
  /**
157
191
  * Get the inline script for I18nScript component.
158
192
  * Cached per locale, invalidated when translations change.
@@ -203,6 +237,11 @@ var I18nSingleton = class {
203
237
  this.compiledCache.delete(locale);
204
238
  this.hashCache.delete(locale);
205
239
  this.scriptCache.delete(locale);
240
+ for (const key of this.chunkCache.keys()) {
241
+ if (key.startsWith(`${locale}:`)) {
242
+ this.chunkCache.delete(key);
243
+ }
244
+ }
206
245
  }
207
246
  }
208
247
  resolveLocales(locales) {
@@ -11,9 +11,7 @@ var visibleDirective = (load, options, el) => {
11
11
  await hydrate();
12
12
  };
13
13
  const rawOptions = typeof options.value === "object" ? options.value : void 0;
14
- const ioOptions = {
15
- rootMargin: rawOptions?.rootMargin
16
- };
14
+ const ioOptions = rawOptions?.rootMargin ? { rootMargin: rawOptions.rootMargin } : {};
17
15
  const io = new IntersectionObserver((entries) => {
18
16
  for (const entry of entries) {
19
17
  if (!entry.isIntersecting) continue;
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  getManifest,
3
3
  i18nVitePlugin
4
- } from "../chunk-EO4FUQXR.js";
4
+ } from "../chunk-FW6TPWMT.js";
5
5
  import "../chunk-4A5BKHFN.js";
6
6
  export {
7
7
  getManifest,
@@ -31,6 +31,7 @@ declare class I18nSingleton {
31
31
  private compiledCache;
32
32
  private hashCache;
33
33
  private scriptCache;
34
+ private chunkCache;
34
35
  private manifestGetter;
35
36
  configure(config: I18nConfig): Promise<void>;
36
37
  isConfigured(): boolean;
@@ -45,6 +46,12 @@ declare class I18nSingleton {
45
46
  */
46
47
  getManifest(): ExtractionManifest;
47
48
  getHashes(locale: string): Record<string, string>;
49
+ /**
50
+ * Get the encoded chunk response body for a locale/chunk combination.
51
+ * Cached to avoid repeated JSON.stringify and encoding on each request.
52
+ * Returns undefined if chunk not found in manifest.
53
+ */
54
+ getChunkBody(locale: string, chunkName: string): Uint8Array | undefined;
48
55
  /**
49
56
  * Get the inline script for I18nScript component.
50
57
  * Cached per locale, invalidated when translations change.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { AstroIntegration, MiddlewareHandler, APIContext } from 'astro';
2
- export { F as FallbackBehavior, I as I18nConfig, i as i18n } from './i18n-mTHTcD8K.js';
2
+ export { F as FallbackBehavior, I as I18nConfig, i as i18n } from './i18n-BBAAVYz_.js';
3
3
  import { ExcludePattern } from '@astroscope/excludes';
4
4
  export { R as RawTranslations, T as TranslationMeta } from './types-DNRFj1XA.js';
5
5
  export { E as ExtractedKey, a as ExtractionManifest } from './types-DTUho6gi.js';
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  i18nVitePlugin
3
- } from "./chunk-EO4FUQXR.js";
3
+ } from "./chunk-FW6TPWMT.js";
4
4
  import {
5
5
  i18n,
6
6
  runWithContext
7
- } from "./chunk-RHWEV4L2.js";
7
+ } from "./chunk-ZKXMIGSF.js";
8
8
  import "./chunk-KNDYKTHE.js";
9
9
  import "./chunk-4A5BKHFN.js";
10
10
 
@@ -69,6 +69,13 @@ function createI18nChunkMiddleware() {
69
69
  return next();
70
70
  }
71
71
  const locale = path.slice(0, slashIdx);
72
+ const config = i18n.getConfig();
73
+ if (!config.locales.includes(locale)) {
74
+ return new Response("/* unknown locale */", {
75
+ status: 404,
76
+ headers: { "Content-Type": "application/javascript" }
77
+ });
78
+ }
72
79
  const rest = path.slice(slashIdx + 1);
73
80
  if (!rest.endsWith(".js")) {
74
81
  return next();
@@ -79,27 +86,13 @@ function createI18nChunkMiddleware() {
79
86
  return next();
80
87
  }
81
88
  const chunkName = withoutJs.slice(0, lastDotIdx);
82
- const keys = i18n.getManifest().chunks[chunkName];
83
- if (!keys) {
89
+ const body = i18n.getChunkBody(locale, chunkName);
90
+ if (!body) {
84
91
  return new Response(`/* chunk not found: ${chunkName} */`, {
85
92
  status: 404,
86
93
  headers: { "Content-Type": "application/javascript" }
87
94
  });
88
95
  }
89
- const translations = i18n.getTranslations(locale);
90
- const chunkTranslations = {};
91
- for (const key of keys) {
92
- if (translations[key]) {
93
- chunkTranslations[key] = translations[key];
94
- }
95
- }
96
- const js = `/* @astroscope/i18n chunk: ${chunkName} */
97
- (function() {
98
- var i = window.__i18n__;
99
- if (i) Object.assign(i.translations, ${JSON.stringify(chunkTranslations)});
100
- })();
101
- `;
102
- const body = new TextEncoder().encode(js);
103
96
  return new Response(body, {
104
97
  status: 200,
105
98
  headers: {
@@ -134,11 +127,12 @@ function detectLocale(request) {
134
127
  if (!acceptLanguage) return void 0;
135
128
  const preferred = acceptLanguage.split(",").map((part) => {
136
129
  const [lang, q = "q=1"] = part.trim().split(";");
130
+ const baseLang = lang?.split("-")[0]?.toLowerCase();
137
131
  return {
138
- lang: lang.split("-")[0].toLowerCase(),
132
+ lang: baseLang,
139
133
  q: parseFloat(q.replace("q=", ""))
140
134
  };
141
- }).sort((a, b) => b.q - a.q);
135
+ }).filter((p) => !!p.lang).sort((a, b) => b.q - a.q);
142
136
  for (const { lang } of preferred) {
143
137
  if (locales.includes(lang)) {
144
138
  return lang;
@@ -1,5 +1,5 @@
1
- import { a as I18nContext } from './i18n-mTHTcD8K.js';
2
- export { i as i18n } from './i18n-mTHTcD8K.js';
1
+ import { a as I18nContext } from './i18n-BBAAVYz_.js';
2
+ export { i as i18n } from './i18n-BBAAVYz_.js';
3
3
  export { I as I18nClientState } from './types-DNRFj1XA.js';
4
4
  import './types-DTUho6gi.js';
5
5
 
package/dist/internal.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  getContext,
3
3
  i18n
4
- } from "./chunk-RHWEV4L2.js";
4
+ } from "./chunk-ZKXMIGSF.js";
5
5
  import "./chunk-KNDYKTHE.js";
6
6
  export {
7
7
  getContext,
package/dist/server/t.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  getContext,
3
3
  i18n
4
- } from "../chunk-RHWEV4L2.js";
4
+ } from "../chunk-ZKXMIGSF.js";
5
5
  import {
6
6
  normalizeMeta
7
7
  } from "../chunk-EMM6LRO2.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astroscope/i18n",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "i18n for Astro + React islands — automatic tree-shaking, parallel loading, any translation source",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -86,9 +86,11 @@
86
86
  "lint:fix": "eslint 'src/**/*.ts' --fix"
87
87
  },
88
88
  "dependencies": {
89
+ "@astrojs/compiler": "^2.12.2",
89
90
  "@babel/core": "^7.28.6",
90
91
  "@babel/preset-typescript": "^7.28.5",
91
92
  "@messageformat/core": "^3.4.0",
93
+ "glob": "^11.0.0",
92
94
  "magic-string": "^0.30.21"
93
95
  },
94
96
  "devDependencies": {