@astroscope/i18n 2.0.0 → 2.0.1

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/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { t as i18nVitePlugin } from "./vite-plugin-C-jEpd8t.js";
2
2
  import { r as i18n } from "./context-CYqyZJyH.js";
3
- import { n as createI18nMiddleware } from "./middleware-7SvnYV--.js";
3
+ import { n as createI18nMiddleware } from "./middleware-DxKY84Iv.js";
4
4
  //#region src/integration/integration.ts
5
5
  /**
6
6
  * Astro integration for i18n with automatic per-chunk translation loading.
@@ -45,8 +45,16 @@ function createScriptHashesScript(locale) {
45
45
  * least a network round-trip later — so each island's hashes win their race
46
46
  * even while the rest of the document is still streaming. Chunks already
47
47
  * covered earlier in the document are skipped.
48
- * - translation-chunk preload links for the static closure, riding the same
49
- * immediate/deferred gate as the component chunks themselves.
48
+ * - translation-chunk preload links for the static closure, emitted as link tags
49
+ * for immediate islands.
50
+ * - translation-chunk eager imports for the full closure — dynamic imports
51
+ * included — fired by the deferred gate the moment a directive schedules
52
+ * hydration. Translation chunks are idempotent data modules, so evaluating
53
+ * them ahead of the component costs nothing, and the loader's awaited import
54
+ * then hits the module cache instead of fetching serially after the component
55
+ * graph evaluated. Every island carries its complete i18n set (no per-document
56
+ * dedup — the gate dedups at fire time), so a chunk shared between islands is
57
+ * covered by whichever gate fires first.
50
58
  *
51
59
  * The locale is recorded per request by the i18n middleware — the emitter runs
52
60
  * while the response streams, outside the middleware's AsyncLocalStorage scope.
@@ -104,16 +112,23 @@ function registerI18nEmitters() {
104
112
  const hash = hashes[chunk];
105
113
  if (hash) links.push(buildI18nChunkUrl(locale, chunk, hash));
106
114
  }
115
+ const imports = [];
116
+ for (const url of island.fullClosure) {
117
+ const chunk = urlToChunkName(url);
118
+ const hash = hashes[chunk];
119
+ if (hash) imports.push(buildI18nChunkUrl(locale, chunk, hash));
120
+ }
107
121
  const init = jsonForScript({
108
122
  locale,
109
123
  hashes: {},
110
124
  translations: {}
111
125
  });
112
126
  const html = Object.keys(merge).length > 0 ? `<script>{const i=window.__i18n__??=${init};Object.assign(i.hashes,${jsonForScript(merge)});}<\/script>` : void 0;
113
- if (!html && links.length === 0) return null;
127
+ if (!html && links.length === 0 && imports.length === 0) return null;
114
128
  return {
115
129
  html,
116
- links: links.length > 0 ? links : void 0
130
+ links: links.length > 0 ? links : void 0,
131
+ imports: imports.length > 0 ? imports : void 0
117
132
  };
118
133
  });
119
134
  }
@@ -217,4 +232,4 @@ function createI18nMiddleware(options) {
217
232
  //#endregion
218
233
  export { createI18nMiddleware as n, createI18nChunkMiddleware as t };
219
234
 
220
- //# sourceMappingURL=middleware-7SvnYV--.js.map
235
+ //# sourceMappingURL=middleware-DxKY84Iv.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"middleware-DxKY84Iv.js","names":[],"sources":["../src/server/client-state.ts","../src/server/islands-emitter.ts","../src/server/middleware.ts"],"sourcesContent":["import { i18n } from './i18n.js';\n\n/**\n * Scripts bootstrapping `window.__i18n__` on html responses, delivered through the\n * `@astroscope/node` document emitter (see islands-emitter.ts for the placement\n * policy). Both writers merge orderlessly (`??=` + assign), so emitter scripts and\n * the bootstrap can arrive in either order.\n */\n\n/** translation values may contain `</script>` — escape `<` so they cannot end the tag */\nexport function jsonForScript(value: unknown): string {\n return JSON.stringify(value).replace(/</g, '\\\\u003c');\n}\n\n/** the full dev-mode state: every translation for the locale */\nexport function createFullStateScript(locale: string): string {\n const translations = i18n.getTranslations(locale);\n const state = jsonForScript({ locale, hashes: {}, translations });\n\n return `<script>window.__i18n__=Object.assign(window.__i18n__??{hashes:{},translations:{}},${state});</script>`;\n}\n\n/** the prod bootstrap: chunk hashes for astro `<script>` entry consumers */\nexport function createScriptHashesScript(locale: string): string | null {\n const hashes = i18n.getScriptChunkHashes(locale);\n\n if (Object.keys(hashes).length === 0) {\n return null;\n }\n\n const init = jsonForScript({ locale, hashes: {}, translations: {} });\n\n return `<script>{const i=window.__i18n__??=${init};Object.assign(i.hashes,${jsonForScript(hashes)});}</script>`;\n}\n","import { type DocumentEmitter, registerDocumentEmitter, registerIslandEmitter } from '@astroscope/node/islands';\nimport type { APIContext } from 'astro';\nimport { buildI18nChunkUrl } from '../shared/url.js';\nimport { createFullStateScript, createScriptHashesScript, jsonForScript } from './client-state.js';\nimport { i18n } from './i18n.js';\n\n/**\n * Contributes the i18n slice for each island the `@astroscope/node` islands\n * middleware streams past:\n *\n * - an inline merge script with the hashes of the island's full chunk closure —\n * dynamic imports included, since a `React.lazy` chunk's injected loader reads\n * its hash at execution time. Emitted before the island tag, it is parsed\n * strictly before the island connects, and the island's chunks execute at\n * least a network round-trip later — so each island's hashes win their race\n * even while the rest of the document is still streaming. Chunks already\n * covered earlier in the document are skipped.\n * - translation-chunk preload links for the static closure, emitted as link tags\n * for immediate islands.\n * - translation-chunk eager imports for the full closure — dynamic imports\n * included — fired by the deferred gate the moment a directive schedules\n * hydration. Translation chunks are idempotent data modules, so evaluating\n * them ahead of the component costs nothing, and the loader's awaited import\n * then hits the module cache instead of fetching serially after the component\n * graph evaluated. Every island carries its complete i18n set (no per-document\n * dedup — the gate dedups at fire time), so a chunk shared between islands is\n * covered by whichever gate fires first.\n *\n * The locale is recorded per request by the i18n middleware — the emitter runs\n * while the response streams, outside the middleware's AsyncLocalStorage scope.\n */\n\nconst requestLocales = new WeakMap<Request, string>();\nconst emittedByContext = new WeakMap<APIContext, Set<string>>();\n\n// vite defines import.meta.env when bundling the middleware; typed structurally\n// so the package compiles without vite's global client types\nconst IS_DEV = !!(import.meta as { env?: { DEV?: boolean } }).env?.DEV;\n\nlet registered = false;\n\nexport function setRequestLocale(request: Request, locale: string): void {\n requestLocales.set(request, locale);\n}\n\n/** public url of a chunk → manifest chunk name, e.g. `…/_astro/Cart.abc.js` → `Cart.abc` */\nfunction urlToChunkName(url: string): string {\n return url.slice(url.lastIndexOf('/') + 1).replace(/\\.js$/, '');\n}\n\n/**\n * The document-level bootstrap for `window.__i18n__`, exported for tests.\n */\nexport function createI18nDocumentEmitter(dev: boolean = IS_DEV): DocumentEmitter {\n return (context) => {\n if (!i18n.isConfigured()) {\n return null;\n }\n\n const locale = requestLocales.get(context.request);\n\n if (locale === undefined) {\n return null;\n }\n\n if (dev) {\n return { head: createFullStateScript(locale) };\n }\n\n if (Object.keys(i18n.getManifest().chunks).length === 0) {\n return null;\n }\n\n return { end: () => createScriptHashesScript(locale) };\n };\n}\n\nexport function registerI18nEmitters(): void {\n if (registered) {\n return;\n }\n\n registered = true;\n\n registerDocumentEmitter(createI18nDocumentEmitter());\n\n registerIslandEmitter((island, context) => {\n // no request context (prerendered build pass) — the locale is unknowable\n if (!context || !i18n.isConfigured()) {\n return null;\n }\n\n const locale = requestLocales.get(context.request);\n\n if (locale === undefined) {\n return null;\n }\n\n const hashes = i18n.getHashes(locale);\n\n let emitted = emittedByContext.get(context);\n\n if (!emitted) {\n emitted = new Set();\n emittedByContext.set(context, emitted);\n }\n\n const merge: Record<string, string> = {};\n\n for (const url of island.fullClosure) {\n const chunk = urlToChunkName(url);\n const hash = hashes[chunk];\n\n if (hash && !emitted.has(chunk)) {\n emitted.add(chunk);\n merge[chunk] = hash;\n }\n }\n\n const links: string[] = [];\n\n for (const url of island.staticClosure) {\n const chunk = urlToChunkName(url);\n const hash = hashes[chunk];\n\n if (hash) {\n links.push(buildI18nChunkUrl(locale, chunk, hash));\n }\n }\n\n const imports: string[] = [];\n\n for (const url of island.fullClosure) {\n const chunk = urlToChunkName(url);\n const hash = hashes[chunk];\n\n if (hash) {\n imports.push(buildI18nChunkUrl(locale, chunk, hash));\n }\n }\n\n const init = jsonForScript({ locale, hashes: {}, translations: {} });\n const html =\n Object.keys(merge).length > 0\n ? `<script>{const i=window.__i18n__??=${init};Object.assign(i.hashes,${jsonForScript(merge)});}</script>`\n : undefined;\n\n if (!html && links.length === 0 && imports.length === 0) {\n return null;\n }\n\n return {\n html,\n links: links.length > 0 ? links : undefined,\n imports: imports.length > 0 ? imports : undefined,\n };\n });\n}\n","import { type ExcludePattern, RECOMMENDED_EXCLUDES, shouldExclude } from '@astroscope/node/excludes';\nimport { log, overrideRequestRoute } from '@astroscope/node/log';\nimport type { APIContext, MiddlewareHandler } from 'astro';\nimport { runWithContext } from './context.js';\nimport { i18n } from './i18n.js';\nimport { registerI18nEmitters, setRequestLocale } from './islands-emitter.js';\nimport type { I18nContext } from './types.js';\n\nexport type I18nMiddlewareOptions = {\n locale: (ctx: APIContext) => string;\n /**\n * Patterns to exclude from locale context setup.\n * Defaults to RECOMMENDED_EXCLUDES if not provided.\n *\n * @example\n * ```ts\n * import { RECOMMENDED_EXCLUDES } from '@astroscope/node/excludes';\n *\n * // extend defaults\n * createI18nMiddleware({\n * locale: (ctx) => ctx.locals.session?.locale ?? 'en',\n * exclude: [...RECOMMENDED_EXCLUDES, { exact: '/health' }],\n * })\n *\n * // disable excludes entirely\n * createI18nMiddleware({\n * locale: (ctx) => ctx.locals.session?.locale ?? 'en',\n * exclude: [],\n * })\n * ```\n */\n exclude?: ExcludePattern[] | ((context: APIContext) => boolean) | undefined;\n};\n\nconst I18N_ENDPOINT_PREFIX = '/_i18n/';\n\n// astro has no route behind the chunk endpoint, so requests would otherwise be\n// logged and measured under whatever routing matched — typically `/404`\nconst I18N_ENDPOINT_ROUTE = '/_i18n/[locale]/[chunk]';\n\n/**\n * Serve a translation chunk. Returns `undefined` when the path isn't a chunk\n * request, and the caller passes it on to astro.\n */\nfunction createChunkResponse(pathname: string): Response | undefined {\n if (!pathname.startsWith(I18N_ENDPOINT_PREFIX)) {\n return undefined;\n }\n\n if (!i18n.isConfigured()) {\n log.error('i18n not configured, passing through');\n\n return undefined;\n }\n\n const path = pathname.slice(I18N_ENDPOINT_PREFIX.length);\n\n // attempt to parse as efficient as possible\n // expected path format: {locale}/{chunkName}.{hash}.js\n // avoiding regex or split for performance\n const slashIdx = path.indexOf('/');\n\n if (slashIdx === -1) {\n return undefined;\n }\n\n const locale = path.slice(0, slashIdx);\n\n // validate locale against configured locales to prevent arbitrary locale injection\n const config = i18n.getConfig();\n\n if (!config.locales.includes(locale)) {\n return new Response('/* unknown locale */', {\n status: 404,\n headers: { 'Content-Type': 'application/javascript' },\n });\n }\n\n const rest = path.slice(slashIdx + 1);\n\n if (!rest.endsWith('.js')) {\n return undefined;\n }\n\n const withoutJs = rest.slice(0, -3);\n const lastDotIdx = withoutJs.lastIndexOf('.');\n\n if (lastDotIdx === -1) {\n return undefined;\n }\n\n const chunkName = withoutJs.slice(0, lastDotIdx);\n const body = i18n.getChunkBody(locale, chunkName);\n\n if (!body) {\n return new Response(`/* chunk not found: ${chunkName} */`, {\n status: 404,\n headers: { 'Content-Type': 'application/javascript' },\n });\n }\n\n return new Response(body as BodyInit, {\n status: 200,\n headers: {\n 'Content-Type': 'text/javascript; charset=utf-8',\n 'Content-Length': String(body.byteLength),\n 'Cache-Control': 'public, max-age=31536000, immutable',\n },\n });\n}\n\n/**\n * Create the i18n chunk middleware that serves translation chunks at `/_i18n/` endpoints.\n *\n * Injected by the integration as `pre` middleware (before any user middleware) —\n * not part of the public API.\n *\n * @internal\n */\nexport function createI18nChunkMiddleware(): MiddlewareHandler {\n return (ctx, next) => {\n const response = createChunkResponse(ctx.url.pathname);\n\n if (!response) {\n return next();\n }\n\n // this middleware, not astro's routing, is what serves the request\n overrideRequestRoute(I18N_ENDPOINT_ROUTE);\n\n return response;\n };\n}\n\n/**\n * Create the i18n locale middleware.\n *\n * Sets up the request context with locale and translations for `t()`, injects\n * the client i18n state into html responses, and records the locale for the\n * islands emitter. Place this after session middleware if your locale detection\n * depends on session/cookies.\n *\n * @example\n * ```typescript\n * import { createI18nMiddleware, detectLocale, i18n } from '@astroscope/i18n';\n *\n * export const i18nMiddleware = createI18nMiddleware({\n * locale: (ctx) =>\n * ctx.cookies.get('locale')?.value ??\n * detectLocale(ctx.request) ??\n * i18n.getConfig().defaultLocale,\n * });\n * ```\n */\nexport function createI18nMiddleware(options: I18nMiddlewareOptions): MiddlewareHandler {\n registerI18nEmitters();\n\n return async (ctx, next) => {\n if (shouldExclude(ctx, options.exclude ?? RECOMMENDED_EXCLUDES)) {\n return next();\n }\n\n if (!i18n.isConfigured()) {\n log.error('i18n not configured, passing through');\n\n return next();\n }\n\n const locale = options.locale(ctx);\n\n // the islands emitter runs while the response streams, outside this ALS scope\n setRequestLocale(ctx.request, locale);\n\n const context: I18nContext = {\n locale,\n translations: i18n.getCompiledTranslations(locale),\n rawTranslations: i18n.getTranslations(locale),\n fallback: i18n.getConfig().fallback,\n };\n\n return runWithContext(context, () => next());\n };\n}\n"],"mappings":";;;;;;;;;;;;;AAUA,SAAgB,cAAc,OAAwB;CACpD,OAAO,KAAK,UAAU,KAAK,CAAC,CAAC,QAAQ,MAAM,SAAS;AACtD;;AAGA,SAAgB,sBAAsB,QAAwB;CAI5D,OAAO,sFAFO,cAAc;EAAE;EAAQ,QAAQ,CAAC;EAAG,cAD7B,KAAK,gBAAgB,MACmB;CAAE,CAEkC,EAAE;AACrG;;AAGA,SAAgB,yBAAyB,QAA+B;CACtE,MAAM,SAAS,KAAK,qBAAqB,MAAM;CAE/C,IAAI,OAAO,KAAK,MAAM,CAAC,CAAC,WAAW,GACjC,OAAO;CAKT,OAAO,sCAFM,cAAc;EAAE;EAAQ,QAAQ,CAAC;EAAG,cAAc,CAAC;CAAE,CAElB,EAAE,0BAA0B,cAAc,MAAM,EAAE;AACpG;;;;;;;;;;;;;;;;;;;;;;;;;;;;ACDA,MAAM,iCAAiB,IAAI,QAAyB;AACpD,MAAM,mCAAmB,IAAI,QAAiC;AAI9D,MAAM,SAAS,CAAC,CAAE,YAA4C,KAAK;AAEnE,IAAI,aAAa;AAEjB,SAAgB,iBAAiB,SAAkB,QAAsB;CACvE,eAAe,IAAI,SAAS,MAAM;AACpC;;AAGA,SAAS,eAAe,KAAqB;CAC3C,OAAO,IAAI,MAAM,IAAI,YAAY,GAAG,IAAI,CAAC,CAAC,CAAC,QAAQ,SAAS,EAAE;AAChE;;;;AAKA,SAAgB,0BAA0B,MAAe,QAAyB;CAChF,QAAQ,YAAY;EAClB,IAAI,CAAC,KAAK,aAAa,GACrB,OAAO;EAGT,MAAM,SAAS,eAAe,IAAI,QAAQ,OAAO;EAEjD,IAAI,WAAW,KAAA,GACb,OAAO;EAGT,IAAI,KACF,OAAO,EAAE,MAAM,sBAAsB,MAAM,EAAE;EAG/C,IAAI,OAAO,KAAK,KAAK,YAAY,CAAC,CAAC,MAAM,CAAC,CAAC,WAAW,GACpD,OAAO;EAGT,OAAO,EAAE,WAAW,yBAAyB,MAAM,EAAE;CACvD;AACF;AAEA,SAAgB,uBAA6B;CAC3C,IAAI,YACF;CAGF,aAAa;CAEb,wBAAwB,0BAA0B,CAAC;CAEnD,uBAAuB,QAAQ,YAAY;EAEzC,IAAI,CAAC,WAAW,CAAC,KAAK,aAAa,GACjC,OAAO;EAGT,MAAM,SAAS,eAAe,IAAI,QAAQ,OAAO;EAEjD,IAAI,WAAW,KAAA,GACb,OAAO;EAGT,MAAM,SAAS,KAAK,UAAU,MAAM;EAEpC,IAAI,UAAU,iBAAiB,IAAI,OAAO;EAE1C,IAAI,CAAC,SAAS;GACZ,0BAAU,IAAI,IAAI;GAClB,iBAAiB,IAAI,SAAS,OAAO;EACvC;EAEA,MAAM,QAAgC,CAAC;EAEvC,KAAK,MAAM,OAAO,OAAO,aAAa;GACpC,MAAM,QAAQ,eAAe,GAAG;GAChC,MAAM,OAAO,OAAO;GAEpB,IAAI,QAAQ,CAAC,QAAQ,IAAI,KAAK,GAAG;IAC/B,QAAQ,IAAI,KAAK;IACjB,MAAM,SAAS;GACjB;EACF;EAEA,MAAM,QAAkB,CAAC;EAEzB,KAAK,MAAM,OAAO,OAAO,eAAe;GACtC,MAAM,QAAQ,eAAe,GAAG;GAChC,MAAM,OAAO,OAAO;GAEpB,IAAI,MACF,MAAM,KAAK,kBAAkB,QAAQ,OAAO,IAAI,CAAC;EAErD;EAEA,MAAM,UAAoB,CAAC;EAE3B,KAAK,MAAM,OAAO,OAAO,aAAa;GACpC,MAAM,QAAQ,eAAe,GAAG;GAChC,MAAM,OAAO,OAAO;GAEpB,IAAI,MACF,QAAQ,KAAK,kBAAkB,QAAQ,OAAO,IAAI,CAAC;EAEvD;EAEA,MAAM,OAAO,cAAc;GAAE;GAAQ,QAAQ,CAAC;GAAG,cAAc,CAAC;EAAE,CAAC;EACnE,MAAM,OACJ,OAAO,KAAK,KAAK,CAAC,CAAC,SAAS,IACxB,sCAAsC,KAAK,0BAA0B,cAAc,KAAK,EAAE,iBAC1F,KAAA;EAEN,IAAI,CAAC,QAAQ,MAAM,WAAW,KAAK,QAAQ,WAAW,GACpD,OAAO;EAGT,OAAO;GACL;GACA,OAAO,MAAM,SAAS,IAAI,QAAQ,KAAA;GAClC,SAAS,QAAQ,SAAS,IAAI,UAAU,KAAA;EAC1C;CACF,CAAC;AACH;;;AC3HA,MAAM,uBAAuB;AAI7B,MAAM,sBAAsB;;;;;AAM5B,SAAS,oBAAoB,UAAwC;CACnE,IAAI,CAAC,SAAS,WAAW,oBAAoB,GAC3C;CAGF,IAAI,CAAC,KAAK,aAAa,GAAG;EACxB,IAAI,MAAM,sCAAsC;EAEhD;CACF;CAEA,MAAM,OAAO,SAAS,MAAM,CAA2B;CAKvD,MAAM,WAAW,KAAK,QAAQ,GAAG;CAEjC,IAAI,aAAa,IACf;CAGF,MAAM,SAAS,KAAK,MAAM,GAAG,QAAQ;CAKrC,IAAI,CAFW,KAAK,UAEV,CAAC,CAAC,QAAQ,SAAS,MAAM,GACjC,OAAO,IAAI,SAAS,wBAAwB;EAC1C,QAAQ;EACR,SAAS,EAAE,gBAAgB,yBAAyB;CACtD,CAAC;CAGH,MAAM,OAAO,KAAK,MAAM,WAAW,CAAC;CAEpC,IAAI,CAAC,KAAK,SAAS,KAAK,GACtB;CAGF,MAAM,YAAY,KAAK,MAAM,GAAG,EAAE;CAClC,MAAM,aAAa,UAAU,YAAY,GAAG;CAE5C,IAAI,eAAe,IACjB;CAGF,MAAM,YAAY,UAAU,MAAM,GAAG,UAAU;CAC/C,MAAM,OAAO,KAAK,aAAa,QAAQ,SAAS;CAEhD,IAAI,CAAC,MACH,OAAO,IAAI,SAAS,uBAAuB,UAAU,MAAM;EACzD,QAAQ;EACR,SAAS,EAAE,gBAAgB,yBAAyB;CACtD,CAAC;CAGH,OAAO,IAAI,SAAS,MAAkB;EACpC,QAAQ;EACR,SAAS;GACP,gBAAgB;GAChB,kBAAkB,OAAO,KAAK,UAAU;GACxC,iBAAiB;EACnB;CACF,CAAC;AACH;;;;;;;;;AAUA,SAAgB,4BAA+C;CAC7D,QAAQ,KAAK,SAAS;EACpB,MAAM,WAAW,oBAAoB,IAAI,IAAI,QAAQ;EAErD,IAAI,CAAC,UACH,OAAO,KAAK;EAId,qBAAqB,mBAAmB;EAExC,OAAO;CACT;AACF;;;;;;;;;;;;;;;;;;;;;AAsBA,SAAgB,qBAAqB,SAAmD;CACtF,qBAAqB;CAErB,OAAO,OAAO,KAAK,SAAS;EAC1B,IAAI,cAAc,KAAK,QAAQ,WAAW,oBAAoB,GAC5D,OAAO,KAAK;EAGd,IAAI,CAAC,KAAK,aAAa,GAAG;GACxB,IAAI,MAAM,sCAAsC;GAEhD,OAAO,KAAK;EACd;EAEA,MAAM,SAAS,QAAQ,OAAO,GAAG;EAGjC,iBAAiB,IAAI,SAAS,MAAM;EAEpC,MAAM,UAAuB;GAC3B;GACA,cAAc,KAAK,wBAAwB,MAAM;GACjD,iBAAiB,KAAK,gBAAgB,MAAM;GAC5C,UAAU,KAAK,UAAU,CAAC,CAAC;EAC7B;EAEA,OAAO,eAAe,eAAe,KAAK,CAAC;CAC7C;AACF"}
@@ -1,4 +1,4 @@
1
- import { t as createI18nChunkMiddleware } from "../middleware-7SvnYV--.js";
1
+ import { t as createI18nChunkMiddleware } from "../middleware-DxKY84Iv.js";
2
2
  //#region src/server/chunk-middleware-entrypoint.ts
3
3
  const onRequest = createI18nChunkMiddleware();
4
4
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astroscope/i18n",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "i18n for Astro + React islands - MessageFormat 2, automatic tree-shaking, parallel loading, any translation source",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -69,7 +69,7 @@
69
69
  "messageformat": "^4.0.0"
70
70
  },
71
71
  "devDependencies": {
72
- "@astroscope/node": "^2.0.0",
72
+ "@astroscope/node": "^2.1.0",
73
73
  "@babel/types": "^8.0.4",
74
74
  "@types/babel__core": "^7.20.5",
75
75
  "astro": "^7.2.6",
@@ -78,7 +78,7 @@
78
78
  "vite": "^8.2.2"
79
79
  },
80
80
  "peerDependencies": {
81
- "@astroscope/node": "^2.0.0",
81
+ "@astroscope/node": "^2.1.0",
82
82
  "astro": "^7.0.0",
83
83
  "vite": "^8.0.0"
84
84
  },
@@ -1 +0,0 @@
1
- {"version":3,"file":"middleware-7SvnYV--.js","names":[],"sources":["../src/server/client-state.ts","../src/server/islands-emitter.ts","../src/server/middleware.ts"],"sourcesContent":["import { i18n } from './i18n.js';\n\n/**\n * Scripts bootstrapping `window.__i18n__` on html responses, delivered through the\n * `@astroscope/node` document emitter (see islands-emitter.ts for the placement\n * policy). Both writers merge orderlessly (`??=` + assign), so emitter scripts and\n * the bootstrap can arrive in either order.\n */\n\n/** translation values may contain `</script>` — escape `<` so they cannot end the tag */\nexport function jsonForScript(value: unknown): string {\n return JSON.stringify(value).replace(/</g, '\\\\u003c');\n}\n\n/** the full dev-mode state: every translation for the locale */\nexport function createFullStateScript(locale: string): string {\n const translations = i18n.getTranslations(locale);\n const state = jsonForScript({ locale, hashes: {}, translations });\n\n return `<script>window.__i18n__=Object.assign(window.__i18n__??{hashes:{},translations:{}},${state});</script>`;\n}\n\n/** the prod bootstrap: chunk hashes for astro `<script>` entry consumers */\nexport function createScriptHashesScript(locale: string): string | null {\n const hashes = i18n.getScriptChunkHashes(locale);\n\n if (Object.keys(hashes).length === 0) {\n return null;\n }\n\n const init = jsonForScript({ locale, hashes: {}, translations: {} });\n\n return `<script>{const i=window.__i18n__??=${init};Object.assign(i.hashes,${jsonForScript(hashes)});}</script>`;\n}\n","import { type DocumentEmitter, registerDocumentEmitter, registerIslandEmitter } from '@astroscope/node/islands';\nimport type { APIContext } from 'astro';\nimport { buildI18nChunkUrl } from '../shared/url.js';\nimport { createFullStateScript, createScriptHashesScript, jsonForScript } from './client-state.js';\nimport { i18n } from './i18n.js';\n\n/**\n * Contributes the i18n slice for each island the `@astroscope/node` islands\n * middleware streams past:\n *\n * - an inline merge script with the hashes of the island's full chunk closure —\n * dynamic imports included, since a `React.lazy` chunk's injected loader reads\n * its hash at execution time. Emitted before the island tag, it is parsed\n * strictly before the island connects, and the island's chunks execute at\n * least a network round-trip later — so each island's hashes win their race\n * even while the rest of the document is still streaming. Chunks already\n * covered earlier in the document are skipped.\n * - translation-chunk preload links for the static closure, riding the same\n * immediate/deferred gate as the component chunks themselves.\n *\n * The locale is recorded per request by the i18n middleware — the emitter runs\n * while the response streams, outside the middleware's AsyncLocalStorage scope.\n */\n\nconst requestLocales = new WeakMap<Request, string>();\nconst emittedByContext = new WeakMap<APIContext, Set<string>>();\n\n// vite defines import.meta.env when bundling the middleware; typed structurally\n// so the package compiles without vite's global client types\nconst IS_DEV = !!(import.meta as { env?: { DEV?: boolean } }).env?.DEV;\n\nlet registered = false;\n\nexport function setRequestLocale(request: Request, locale: string): void {\n requestLocales.set(request, locale);\n}\n\n/** public url of a chunk → manifest chunk name, e.g. `…/_astro/Cart.abc.js` → `Cart.abc` */\nfunction urlToChunkName(url: string): string {\n return url.slice(url.lastIndexOf('/') + 1).replace(/\\.js$/, '');\n}\n\n/**\n * The document-level bootstrap for `window.__i18n__`, exported for tests.\n */\nexport function createI18nDocumentEmitter(dev: boolean = IS_DEV): DocumentEmitter {\n return (context) => {\n if (!i18n.isConfigured()) {\n return null;\n }\n\n const locale = requestLocales.get(context.request);\n\n if (locale === undefined) {\n return null;\n }\n\n if (dev) {\n return { head: createFullStateScript(locale) };\n }\n\n if (Object.keys(i18n.getManifest().chunks).length === 0) {\n return null;\n }\n\n return { end: () => createScriptHashesScript(locale) };\n };\n}\n\nexport function registerI18nEmitters(): void {\n if (registered) {\n return;\n }\n\n registered = true;\n\n registerDocumentEmitter(createI18nDocumentEmitter());\n\n registerIslandEmitter((island, context) => {\n // no request context (prerendered build pass) — the locale is unknowable\n if (!context || !i18n.isConfigured()) {\n return null;\n }\n\n const locale = requestLocales.get(context.request);\n\n if (locale === undefined) {\n return null;\n }\n\n const hashes = i18n.getHashes(locale);\n\n let emitted = emittedByContext.get(context);\n\n if (!emitted) {\n emitted = new Set();\n emittedByContext.set(context, emitted);\n }\n\n const merge: Record<string, string> = {};\n\n for (const url of island.fullClosure) {\n const chunk = urlToChunkName(url);\n const hash = hashes[chunk];\n\n if (hash && !emitted.has(chunk)) {\n emitted.add(chunk);\n merge[chunk] = hash;\n }\n }\n\n const links: string[] = [];\n\n for (const url of island.staticClosure) {\n const chunk = urlToChunkName(url);\n const hash = hashes[chunk];\n\n if (hash) {\n links.push(buildI18nChunkUrl(locale, chunk, hash));\n }\n }\n\n const init = jsonForScript({ locale, hashes: {}, translations: {} });\n const html =\n Object.keys(merge).length > 0\n ? `<script>{const i=window.__i18n__??=${init};Object.assign(i.hashes,${jsonForScript(merge)});}</script>`\n : undefined;\n\n if (!html && links.length === 0) {\n return null;\n }\n\n return { html, links: links.length > 0 ? links : undefined };\n });\n}\n","import { type ExcludePattern, RECOMMENDED_EXCLUDES, shouldExclude } from '@astroscope/node/excludes';\nimport { log, overrideRequestRoute } from '@astroscope/node/log';\nimport type { APIContext, MiddlewareHandler } from 'astro';\nimport { runWithContext } from './context.js';\nimport { i18n } from './i18n.js';\nimport { registerI18nEmitters, setRequestLocale } from './islands-emitter.js';\nimport type { I18nContext } from './types.js';\n\nexport type I18nMiddlewareOptions = {\n locale: (ctx: APIContext) => string;\n /**\n * Patterns to exclude from locale context setup.\n * Defaults to RECOMMENDED_EXCLUDES if not provided.\n *\n * @example\n * ```ts\n * import { RECOMMENDED_EXCLUDES } from '@astroscope/node/excludes';\n *\n * // extend defaults\n * createI18nMiddleware({\n * locale: (ctx) => ctx.locals.session?.locale ?? 'en',\n * exclude: [...RECOMMENDED_EXCLUDES, { exact: '/health' }],\n * })\n *\n * // disable excludes entirely\n * createI18nMiddleware({\n * locale: (ctx) => ctx.locals.session?.locale ?? 'en',\n * exclude: [],\n * })\n * ```\n */\n exclude?: ExcludePattern[] | ((context: APIContext) => boolean) | undefined;\n};\n\nconst I18N_ENDPOINT_PREFIX = '/_i18n/';\n\n// astro has no route behind the chunk endpoint, so requests would otherwise be\n// logged and measured under whatever routing matched — typically `/404`\nconst I18N_ENDPOINT_ROUTE = '/_i18n/[locale]/[chunk]';\n\n/**\n * Serve a translation chunk. Returns `undefined` when the path isn't a chunk\n * request, and the caller passes it on to astro.\n */\nfunction createChunkResponse(pathname: string): Response | undefined {\n if (!pathname.startsWith(I18N_ENDPOINT_PREFIX)) {\n return undefined;\n }\n\n if (!i18n.isConfigured()) {\n log.error('i18n not configured, passing through');\n\n return undefined;\n }\n\n const path = pathname.slice(I18N_ENDPOINT_PREFIX.length);\n\n // attempt to parse as efficient as possible\n // expected path format: {locale}/{chunkName}.{hash}.js\n // avoiding regex or split for performance\n const slashIdx = path.indexOf('/');\n\n if (slashIdx === -1) {\n return undefined;\n }\n\n const locale = path.slice(0, slashIdx);\n\n // validate locale against configured locales to prevent arbitrary locale injection\n const config = i18n.getConfig();\n\n if (!config.locales.includes(locale)) {\n return new Response('/* unknown locale */', {\n status: 404,\n headers: { 'Content-Type': 'application/javascript' },\n });\n }\n\n const rest = path.slice(slashIdx + 1);\n\n if (!rest.endsWith('.js')) {\n return undefined;\n }\n\n const withoutJs = rest.slice(0, -3);\n const lastDotIdx = withoutJs.lastIndexOf('.');\n\n if (lastDotIdx === -1) {\n return undefined;\n }\n\n const chunkName = withoutJs.slice(0, lastDotIdx);\n const body = i18n.getChunkBody(locale, chunkName);\n\n if (!body) {\n return new Response(`/* chunk not found: ${chunkName} */`, {\n status: 404,\n headers: { 'Content-Type': 'application/javascript' },\n });\n }\n\n return new Response(body as BodyInit, {\n status: 200,\n headers: {\n 'Content-Type': 'text/javascript; charset=utf-8',\n 'Content-Length': String(body.byteLength),\n 'Cache-Control': 'public, max-age=31536000, immutable',\n },\n });\n}\n\n/**\n * Create the i18n chunk middleware that serves translation chunks at `/_i18n/` endpoints.\n *\n * Injected by the integration as `pre` middleware (before any user middleware) —\n * not part of the public API.\n *\n * @internal\n */\nexport function createI18nChunkMiddleware(): MiddlewareHandler {\n return (ctx, next) => {\n const response = createChunkResponse(ctx.url.pathname);\n\n if (!response) {\n return next();\n }\n\n // this middleware, not astro's routing, is what serves the request\n overrideRequestRoute(I18N_ENDPOINT_ROUTE);\n\n return response;\n };\n}\n\n/**\n * Create the i18n locale middleware.\n *\n * Sets up the request context with locale and translations for `t()`, injects\n * the client i18n state into html responses, and records the locale for the\n * islands emitter. Place this after session middleware if your locale detection\n * depends on session/cookies.\n *\n * @example\n * ```typescript\n * import { createI18nMiddleware, detectLocale, i18n } from '@astroscope/i18n';\n *\n * export const i18nMiddleware = createI18nMiddleware({\n * locale: (ctx) =>\n * ctx.cookies.get('locale')?.value ??\n * detectLocale(ctx.request) ??\n * i18n.getConfig().defaultLocale,\n * });\n * ```\n */\nexport function createI18nMiddleware(options: I18nMiddlewareOptions): MiddlewareHandler {\n registerI18nEmitters();\n\n return async (ctx, next) => {\n if (shouldExclude(ctx, options.exclude ?? RECOMMENDED_EXCLUDES)) {\n return next();\n }\n\n if (!i18n.isConfigured()) {\n log.error('i18n not configured, passing through');\n\n return next();\n }\n\n const locale = options.locale(ctx);\n\n // the islands emitter runs while the response streams, outside this ALS scope\n setRequestLocale(ctx.request, locale);\n\n const context: I18nContext = {\n locale,\n translations: i18n.getCompiledTranslations(locale),\n rawTranslations: i18n.getTranslations(locale),\n fallback: i18n.getConfig().fallback,\n };\n\n return runWithContext(context, () => next());\n };\n}\n"],"mappings":"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"}