@astroscope/i18n 0.2.1 → 0.2.3

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,193 @@ 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 isTypeScript = TS_EXTENSIONS.some((ext) => filename.endsWith(ext)) || filename.endsWith(".astro");
50
+ const isJSX = JSX_EXTENSIONS.some((ext) => filename.endsWith(ext));
51
+ const result = await transformAsync(code, {
52
+ filename,
53
+ sourceMaps: stripFallbacks,
54
+ // only need source maps when transforming
55
+ babelrc: false,
56
+ configFile: false,
57
+ parserOpts: {
58
+ plugins: [...isTypeScript ? ["typescript"] : [], ...isJSX ? ["jsx"] : []]
59
+ },
60
+ plugins: [
61
+ [
62
+ i18nExtractPlugin,
63
+ {
64
+ stripFallbacks,
65
+ onKeyExtracted: (key) => {
66
+ keys.push(key);
67
+ onKeyExtracted?.(key);
68
+ },
69
+ logger
70
+ }
71
+ ]
72
+ ]
73
+ });
74
+ return {
75
+ keys,
76
+ code: result?.code ?? null,
77
+ map: result?.map
78
+ };
79
+ }
80
+
81
+ // src/extraction/key-store.ts
82
+ var KeyStore = class {
83
+ constructor(logger) {
84
+ this.logger = logger;
85
+ }
86
+ extractedKeys = [];
87
+ fileToKeys = /* @__PURE__ */ new Map();
88
+ filesWithI18n = /* @__PURE__ */ new Set();
89
+ /**
90
+ * Add keys for a file, replacing any existing keys for that file.
91
+ */
92
+ addFileKeys(filename, keys) {
93
+ const oldKeys = this.fileToKeys.get(filename);
94
+ const newKeyStrings = keys.map((k) => k.key);
95
+ if (oldKeys) {
96
+ const added = newKeyStrings.filter((k) => !oldKeys.includes(k));
97
+ const removed = oldKeys.filter((k) => !newKeyStrings.includes(k));
98
+ const file = filename.split("/").pop();
99
+ if (added.length > 0 || removed.length > 0) {
100
+ const parts = [];
101
+ if (added.length > 0) parts.push(`+${added.length}`);
102
+ if (removed.length > 0) parts.push(`-${removed.length}`);
103
+ this.logger.info(`hmr: ${parts.join(" ")} key(s) in ${file}`);
104
+ }
105
+ }
106
+ if (oldKeys) {
107
+ for (let i = this.extractedKeys.length - 1; i >= 0; i--) {
108
+ if (this.extractedKeys[i]?.file === filename) {
109
+ this.extractedKeys.splice(i, 1);
110
+ }
111
+ }
112
+ }
113
+ this.filesWithI18n.add(filename);
114
+ if (keys.length > 0) {
115
+ this.extractedKeys.push(...keys);
116
+ }
117
+ this.fileToKeys.set(filename, newKeyStrings);
118
+ }
119
+ /**
120
+ * Get count of unique keys.
121
+ */
122
+ get uniqueKeyCount() {
123
+ return new Set(this.extractedKeys.map((k) => k.key)).size;
124
+ }
125
+ /**
126
+ * Merge another store's data into this one.
127
+ */
128
+ merge(other) {
129
+ for (const [file, keys] of other.fileToKeys) {
130
+ this.fileToKeys.set(file, keys);
131
+ }
132
+ for (const file of other.filesWithI18n) {
133
+ this.filesWithI18n.add(file);
134
+ }
135
+ this.extractedKeys.push(...other.extractedKeys);
136
+ }
137
+ };
138
+
139
+ // src/extraction/scan.ts
140
+ import fs from "fs";
141
+ import { glob } from "glob";
142
+ var GLOB_PATTERN = `src/**/*.{${ALL_EXTENSIONS.map((e) => e.slice(1)).join(",")}}`;
143
+ async function compileAstro(code, filename) {
144
+ try {
145
+ const { transform } = await import("@astrojs/compiler");
146
+ const result = await transform(code, { filename });
147
+ return result.code;
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
152
+ async function scan(projectRoot, logger) {
153
+ const store = new KeyStore(logger);
154
+ const files = await glob(GLOB_PATTERN, {
155
+ cwd: projectRoot,
156
+ absolute: true,
157
+ ignore: ["**/node_modules/**"]
158
+ });
159
+ const results = await Promise.all(
160
+ files.map(async (file) => {
161
+ let code = await fs.promises.readFile(file, "utf-8");
162
+ if (!code.includes("@astroscope/i18n/t")) {
163
+ return { file, keys: null };
164
+ }
165
+ if (file.endsWith(".astro")) {
166
+ const compiled = await compileAstro(code, file);
167
+ if (!compiled) {
168
+ return { file, keys: null };
169
+ }
170
+ code = compiled;
171
+ }
172
+ const result = await extractKeysFromFile({
173
+ filename: file,
174
+ code,
175
+ logger,
176
+ stripFallbacks: false
177
+ });
178
+ return { file, keys: result.keys.length > 0 ? result.keys : null };
179
+ })
180
+ );
181
+ for (const { file, keys } of results) {
182
+ if (keys) {
183
+ store.addFileKeys(file, keys);
184
+ }
185
+ }
186
+ return store;
187
+ }
188
+
189
+ // src/extraction/vite-plugin.ts
35
190
  var VIRTUAL_MODULE_ID = "virtual:@astroscope/i18n/manifest";
36
191
  var isVirtualModuleId = (id) => id === VIRTUAL_MODULE_ID || id.startsWith(`${VIRTUAL_MODULE_ID}?`);
37
192
  var RESOLVED_VIRTUAL_MODULE_ID = `\0${VIRTUAL_MODULE_ID}`;
38
193
  var isResolvedVirtualModuleId = (id) => id === RESOLVED_VIRTUAL_MODULE_ID || id.startsWith(`\0${VIRTUAL_MODULE_ID}?`);
39
194
  var MANIFEST_FILE_NAME = "i18n-manifest.json";
40
- var INCLUDE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".astro"];
41
195
  function i18nVitePlugin(options) {
42
196
  const { logger } = options;
43
- const extractedKeys = [];
44
- const fileToKeys = /* @__PURE__ */ new Map();
45
- const filesWithI18n = /* @__PURE__ */ new Set();
197
+ const store = new KeyStore(logger);
46
198
  let isBuild = false;
47
199
  let isSSR = false;
200
+ let projectRoot = "";
48
201
  return {
49
202
  name: "@astroscope/i18n/extract",
50
203
  enforce: "pre",
51
204
  configResolved(config) {
52
205
  const state = getGlobalState();
53
206
  state.projectRoot = config.root;
54
- state.extractedKeys = extractedKeys;
207
+ state.extractedKeys = store.extractedKeys;
55
208
  isBuild = config.command === "build";
56
209
  isSSR = !!config.build.ssr;
210
+ projectRoot = config.root;
211
+ },
212
+ async configureServer(server) {
213
+ if (server.config.isProduction || isBuild) {
214
+ return;
215
+ }
216
+ const result = await scan(projectRoot, logger);
217
+ store.merge(result);
218
+ logger.info(`dev mode: scanned ${result.fileToKeys.size} files, found ${result.uniqueKeyCount} keys`);
57
219
  },
58
220
  resolveId(id) {
59
221
  if (isVirtualModuleId(id)) {
@@ -83,7 +245,7 @@ export function getManifest() { return _getManifest(); }
83
245
  }
84
246
  },
85
247
  async transform(code, filename) {
86
- if (!INCLUDE_EXTENSIONS.some((ext) => filename.endsWith(ext))) {
248
+ if (!ALL_EXTENSIONS.some((ext) => filename.endsWith(ext))) {
87
249
  return null;
88
250
  }
89
251
  if (filename.includes("node_modules")) {
@@ -92,43 +254,14 @@ export function getManifest() { return _getManifest(); }
92
254
  if (!code.includes("@astroscope/i18n/t")) {
93
255
  return null;
94
256
  }
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, {
257
+ const result = await extractKeysFromFile({
102
258
  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
- ]
259
+ code,
260
+ logger,
261
+ stripFallbacks: isBuild
123
262
  });
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) {
263
+ store.addFileKeys(filename, result.keys);
264
+ if (isBuild && result.code) {
132
265
  return { code: result.code, map: result.map };
133
266
  }
134
267
  return null;
@@ -145,9 +278,9 @@ export function getManifest() { return _getManifest(); }
145
278
  const keys = /* @__PURE__ */ new Set();
146
279
  let hasI18nModule = false;
147
280
  for (const moduleId of chunk.moduleIds) {
148
- const moduleKeys = fileToKeys.get(moduleId);
281
+ const moduleKeys = store.fileToKeys.get(moduleId);
149
282
  moduleKeys?.forEach((k) => keys.add(k));
150
- if (filesWithI18n.has(moduleId)) {
283
+ if (store.filesWithI18n.has(moduleId)) {
151
284
  hasI18nModule = true;
152
285
  }
153
286
  }
@@ -202,14 +335,14 @@ export function getManifest() { return _getManifest(); }
202
335
  chunk.map = s.generateMap({ hires: true });
203
336
  }
204
337
  }
205
- logger.info(`manifest: ${Object.keys(state.chunkManifest).length} chunks, ${extractedKeys.length} keys`);
338
+ logger.info(`manifest: ${Object.keys(state.chunkManifest).length} chunks, ${store.uniqueKeyCount} keys`);
206
339
  },
207
340
  writeBundle(outputOptions) {
208
341
  if (!isSSR && outputOptions.dir) {
209
342
  const chunksDir = path2.resolve(outputOptions.dir, "..", "server", "chunks");
210
343
  const manifestPath = path2.join(chunksDir, MANIFEST_FILE_NAME);
211
- if (fs.existsSync(chunksDir)) {
212
- fs.writeFileSync(manifestPath, JSON.stringify(getManifest()));
344
+ if (fs2.existsSync(chunksDir)) {
345
+ fs2.writeFileSync(manifestPath, JSON.stringify(getManifest()));
213
346
  } else {
214
347
  logger.error(`server chunks directory not found, cannot write manifest to ${manifestPath}`);
215
348
  }
@@ -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-4IUQAJ6R.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-4IUQAJ6R.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.3",
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": {