@edgekits/astro-i18n 0.0.0-stage → 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.
Files changed (60) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +394 -2
  4. package/dist/cli/generate.d.ts +47 -0
  5. package/dist/cli/generate.js +133 -0
  6. package/dist/cli/hashes.d.ts +19 -0
  7. package/dist/cli/hashes.js +42 -0
  8. package/dist/cli/index.d.ts +2 -0
  9. package/dist/cli/index.js +362 -0
  10. package/dist/cli/load-config.d.ts +7 -0
  11. package/dist/cli/load-config.js +19 -0
  12. package/dist/cli/purge.d.ts +29 -0
  13. package/dist/cli/purge.js +94 -0
  14. package/dist/cli/wrangler.d.ts +10 -0
  15. package/dist/cli/wrangler.js +71 -0
  16. package/dist/index.d.ts +2 -0
  17. package/dist/index.js +4 -0
  18. package/dist/integration/index.d.ts +26 -0
  19. package/dist/integration/index.js +103 -0
  20. package/dist/integration/virtual-types.d.ts +14 -0
  21. package/dist/integration/virtual-types.js +34 -0
  22. package/dist/integration/vite-plugin.d.ts +17 -0
  23. package/dist/integration/vite-plugin.js +56 -0
  24. package/dist/key-format.d.ts +8 -0
  25. package/dist/key-format.js +15 -0
  26. package/dist/types.d.ts +66 -0
  27. package/dist/types.js +38 -0
  28. package/package.json +77 -4
  29. package/src/assemble.ts +29 -0
  30. package/src/cli/generate.ts +175 -0
  31. package/src/cli/hashes.ts +66 -0
  32. package/src/cli/index.ts +461 -0
  33. package/src/cli/load-config.ts +26 -0
  34. package/src/cli/purge.ts +122 -0
  35. package/src/cli/wrangler.ts +96 -0
  36. package/src/components/LanguageSwitcher.astro +210 -0
  37. package/src/components/MissingTranslationBanner.astro +31 -0
  38. package/src/config.entry.ts +38 -0
  39. package/src/config.ts +11 -0
  40. package/src/constants.ts +29 -0
  41. package/src/cookie-storage.ts +32 -0
  42. package/src/deep-merge.ts +38 -0
  43. package/src/fetcher.ts +304 -0
  44. package/src/format.ts +107 -0
  45. package/src/index.ts +11 -0
  46. package/src/integration/index.ts +136 -0
  47. package/src/integration/virtual-types.ts +34 -0
  48. package/src/integration/vite-plugin.ts +67 -0
  49. package/src/key-format.ts +17 -0
  50. package/src/locale-fallback.ts +65 -0
  51. package/src/markdown-path.ts +31 -0
  52. package/src/middleware/i18n.ts +223 -0
  53. package/src/middleware.ts +2 -0
  54. package/src/parse-boolean.ts +20 -0
  55. package/src/resolve-locale.ts +57 -0
  56. package/src/runtime.ts +19 -0
  57. package/src/schema.ts +28 -0
  58. package/src/translations-keys.ts +42 -0
  59. package/src/types.ts +119 -0
  60. package/src/virtual.d.ts +23 -0
@@ -0,0 +1,26 @@
1
+ import type { AstroConfig, AstroIntegration } from 'astro';
2
+ import { type I18nConfig } from '../types.ts';
3
+ export type EdgekitsI18nOptions = {
4
+ middleware?: 'auto' | 'manual';
5
+ };
6
+ export declare const PACKAGE_NAME = "@edgekits/astro-i18n";
7
+ /**
8
+ * The Astro integration. Build time only: it runs in Node while Astro loads its
9
+ * config, so it reaches the engine's runtime modules through nothing but the Vite
10
+ * plugin's virtual modules.
11
+ */
12
+ export declare function edgekitsI18n(config: I18nConfig, options?: EdgekitsI18nOptions): AstroIntegration;
13
+ /**
14
+ * Augments `I18nLocales` with the configured locales, so `Locale` narrows from
15
+ * `string` to their union in the project, through both entries. The `export {}` keeps
16
+ * the file a module: without it `declare module` would replace the package's types
17
+ * instead of merging.
18
+ */
19
+ export declare function localesDeclaration(locales: readonly string[]): string;
20
+ /**
21
+ * The Astro `i18n` block set when the project has none: locales and default from the
22
+ * config; every URL locale-prefixed (the middleware redirects `/` itself, so Astro
23
+ * does not). Complete, `fallbackType` included: Astro does not re-validate what
24
+ * `updateConfig` merges, so its schema defaults never fill a partial block.
25
+ */
26
+ export declare function astroI18n(config: I18nConfig): NonNullable<AstroConfig['i18n']>;
@@ -0,0 +1,103 @@
1
+ import { DEFAULT_GENERATED_DIR, defineI18nConfig } from "../types.js";
2
+ import { i18nVitePlugin } from "./vite-plugin.js";
3
+ import { virtualModulesDeclaration } from "./virtual-types.js";
4
+ const MIDDLEWARE_MODES = ['auto', 'manual'];
5
+ export const PACKAGE_NAME = '@edgekits/astro-i18n';
6
+ /**
7
+ * The Astro integration. Build time only: it runs in Node while Astro loads its
8
+ * config, so it reaches the engine's runtime modules through nothing but the Vite
9
+ * plugin's virtual modules.
10
+ */
11
+ export function edgekitsI18n(config, options = {}) {
12
+ const i18nConfig = defineI18nConfig(config);
13
+ const middleware = options.middleware ?? 'auto';
14
+ if (!MIDDLEWARE_MODES.includes(middleware)) {
15
+ throw new Error(`[@edgekits/astro-i18n] \`middleware\` "${String(middleware)}" is not supported; use 'auto' or 'manual'.`);
16
+ }
17
+ return {
18
+ name: '@edgekits/astro-i18n',
19
+ hooks: {
20
+ 'astro:config:setup': ({ config: astroConfig, updateConfig, addMiddleware }) => {
21
+ const generatedDir = new URL(`${trimSlashes(i18nConfig.generatedDir ?? DEFAULT_GENERATED_DIR)}/`, astroConfig.root);
22
+ // Astro computes `preferredLocale` (the middleware's Accept-Language step) only
23
+ // with an `i18n` block: set it when the project has none, check it when it has one.
24
+ if (astroConfig.i18n)
25
+ assertAstroI18n(astroConfig.i18n, i18nConfig);
26
+ updateConfig({
27
+ vite: {
28
+ plugins: [i18nVitePlugin(i18nConfig, generatedDir)],
29
+ // The runtime ships as source and imports the virtual modules above: Vite
30
+ // must process it rather than hand it to Node as an external dependency.
31
+ ssr: { noExternal: [PACKAGE_NAME] },
32
+ },
33
+ ...(astroConfig.i18n ? {} : { i18n: astroI18n(i18nConfig) }),
34
+ });
35
+ if (middleware === 'auto') {
36
+ addMiddleware({ entrypoint: '@edgekits/astro-i18n/middleware', order: 'pre' });
37
+ }
38
+ },
39
+ 'astro:config:done': ({ injectTypes }) => {
40
+ injectTypes({ filename: 'locales.d.ts', content: localesDeclaration(Object.keys(i18nConfig.languages)) });
41
+ // The shipped runtime imports the virtual modules; a consumer's program needs their
42
+ // declarations too. A separate file: these must not sit in a module (see virtual-types.ts).
43
+ injectTypes({ filename: 'virtual-modules.d.ts', content: virtualModulesDeclaration(PACKAGE_NAME) });
44
+ },
45
+ },
46
+ };
47
+ }
48
+ // Each entry that exports `I18nLocales` from its own declaration: the root entry's comes
49
+ // from dist/types.d.ts, the runtime entry's from the shipped source (src/types.ts).
50
+ const LOCALE_MODULES = ['@edgekits/astro-i18n', '@edgekits/astro-i18n/runtime'];
51
+ /**
52
+ * Augments `I18nLocales` with the configured locales, so `Locale` narrows from
53
+ * `string` to their union in the project, through both entries. The `export {}` keeps
54
+ * the file a module: without it `declare module` would replace the package's types
55
+ * instead of merging.
56
+ */
57
+ export function localesDeclaration(locales) {
58
+ const members = locales.map((locale) => ` ${JSON.stringify(locale)}: true`);
59
+ return [
60
+ 'export {}',
61
+ ...LOCALE_MODULES.flatMap((specifier) => [
62
+ '',
63
+ `declare module '${specifier}' {`,
64
+ ' interface I18nLocales {',
65
+ ...members,
66
+ ' }',
67
+ '}',
68
+ ]),
69
+ '',
70
+ ].join('\n');
71
+ }
72
+ /**
73
+ * The Astro `i18n` block set when the project has none: locales and default from the
74
+ * config; every URL locale-prefixed (the middleware redirects `/` itself, so Astro
75
+ * does not). Complete, `fallbackType` included: Astro does not re-validate what
76
+ * `updateConfig` merges, so its schema defaults never fill a partial block.
77
+ */
78
+ export function astroI18n(config) {
79
+ return {
80
+ locales: Object.keys(config.languages),
81
+ defaultLocale: config.defaultLocale,
82
+ routing: { prefixDefaultLocale: true, redirectToDefaultLocale: false, fallbackType: 'redirect' },
83
+ };
84
+ }
85
+ /**
86
+ * A project's own Astro `i18n` block must cover every configured locale (as a string
87
+ * entry, a `path` or one of its `codes`) and share the default locale.
88
+ */
89
+ function assertAstroI18n(block, config) {
90
+ const declared = new Set(block.locales.flatMap((entry) => (typeof entry === 'string' ? [entry] : [entry.path, ...entry.codes])));
91
+ const missing = Object.keys(config.languages).filter((locale) => !declared.has(locale));
92
+ if (missing.length > 0) {
93
+ throw new Error(`[@edgekits/astro-i18n] Astro \`i18n.locales\` is missing ${missing.join(', ')} from \`languages\`. ` +
94
+ 'Add them, or remove the `i18n` block from astro.config and the integration sets it for you.');
95
+ }
96
+ if (block.defaultLocale !== config.defaultLocale) {
97
+ throw new Error(`[@edgekits/astro-i18n] Astro \`i18n.defaultLocale\` "${block.defaultLocale}" differs from \`defaultLocale\` "${config.defaultLocale}". ` +
98
+ 'Make them equal, or remove the `i18n` block from astro.config and the integration sets it for you.');
99
+ }
100
+ }
101
+ function trimSlashes(path) {
102
+ return path.replace(/^\/+|\/+$/g, '');
103
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The declarations of the three `virtual:@edgekits/astro-i18n/*` modules, in one place
3
+ * for both programs that need them:
4
+ * - the package's own typecheck, through `src/virtual.d.ts` (`typesModule` './types');
5
+ * test/virtual-types.test.ts fails when that file stops matching this text;
6
+ * - the consumer's, through the `virtual-modules.d.ts` the integration injects
7
+ * (`typesModule` the package name), because a consumer's program never loads
8
+ * `src/virtual.d.ts` from node_modules but does import the shipped `src/` files.
9
+ *
10
+ * The text has no top-level import or export on purpose: in a module file,
11
+ * `declare module 'virtual:…'` would augment a module that does not exist instead of
12
+ * declaring one. (The injected `locales.d.ts` is a module, so it cannot carry these.)
13
+ */
14
+ export declare function virtualModulesDeclaration(typesModule: string): string;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The declarations of the three `virtual:@edgekits/astro-i18n/*` modules, in one place
3
+ * for both programs that need them:
4
+ * - the package's own typecheck, through `src/virtual.d.ts` (`typesModule` './types');
5
+ * test/virtual-types.test.ts fails when that file stops matching this text;
6
+ * - the consumer's, through the `virtual-modules.d.ts` the integration injects
7
+ * (`typesModule` the package name), because a consumer's program never loads
8
+ * `src/virtual.d.ts` from node_modules but does import the shipped `src/` files.
9
+ *
10
+ * The text has no top-level import or export on purpose: in a module file,
11
+ * `declare module 'virtual:…'` would augment a module that does not exist instead of
12
+ * declaring one. (The injected `locales.d.ts` is a module, so it cannot carry these.)
13
+ */
14
+ export function virtualModulesDeclaration(typesModule) {
15
+ return `declare module 'virtual:@edgekits/astro-i18n/config' {
16
+ const config: import('${typesModule}').I18nConfig
17
+ export default config
18
+ }
19
+
20
+ // The consumer's \`<generatedDir>/runtime-constants.ts\`, or a stub with empty lists.
21
+ declare module 'virtual:@edgekits/astro-i18n/runtime-constants' {
22
+ export const NAMESPACE_KEYS: readonly string[]
23
+ export const LOCALES_WITH_TRANSLATIONS: readonly string[]
24
+ export const FULLY_TRANSLATED_LOCALES: readonly string[]
25
+ }
26
+
27
+ // The consumer's \`<generatedDir>/fallbacks.generated.ts\`, or an empty stub. Its exports
28
+ // (\`FALLBACK_<NAMESPACE>\`) depend on the project's namespaces, so none are declared here:
29
+ // the fetcher reads the module namespace by name.
30
+ declare module 'virtual:@edgekits/astro-i18n/fallbacks' {
31
+ export {}
32
+ }
33
+ `;
34
+ }
@@ -0,0 +1,17 @@
1
+ import type { AstroConfig } from 'astro';
2
+ import type { I18nConfig } from '../types.ts';
3
+ type VitePlugin = NonNullable<NonNullable<AstroConfig['vite']>['plugins']>[number];
4
+ export declare const CONFIG_ID = "virtual:@edgekits/astro-i18n/config";
5
+ export declare const FALLBACKS_ID = "virtual:@edgekits/astro-i18n/fallbacks";
6
+ export declare const RUNTIME_CONSTANTS_ID = "virtual:@edgekits/astro-i18n/runtime-constants";
7
+ /**
8
+ * Serves the three virtual modules the engine imports:
9
+ * - the config, serialised as JSON;
10
+ * - the project's generated `fallbacks.generated.ts` and `runtime-constants.ts` under
11
+ * `generatedDir`, or a stub module when the file does not exist (yet).
12
+ *
13
+ * The file check runs on every resolve, so files the CLI generates while the dev
14
+ * server runs are picked up on the next resolve.
15
+ */
16
+ export declare function i18nVitePlugin(config: I18nConfig, generatedDir: URL): VitePlugin;
17
+ export {};
@@ -0,0 +1,56 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ export const CONFIG_ID = 'virtual:@edgekits/astro-i18n/config';
4
+ export const FALLBACKS_ID = 'virtual:@edgekits/astro-i18n/fallbacks';
5
+ export const RUNTIME_CONSTANTS_ID = 'virtual:@edgekits/astro-i18n/runtime-constants';
6
+ // Rollup convention: a `\0` prefix marks a resolved id as virtual, so no other plugin
7
+ // tries to read it from disk.
8
+ const resolved = (id) => `\0${id}`;
9
+ const STUBS = {
10
+ [FALLBACKS_ID]: 'export {}\n',
11
+ [RUNTIME_CONSTANTS_ID]: [
12
+ 'export const NAMESPACE_KEYS = []',
13
+ 'export const LOCALES_WITH_TRANSLATIONS = []',
14
+ 'export const FULLY_TRANSLATED_LOCALES = []',
15
+ '',
16
+ ].join('\n'),
17
+ };
18
+ /**
19
+ * Serves the three virtual modules the engine imports:
20
+ * - the config, serialised as JSON;
21
+ * - the project's generated `fallbacks.generated.ts` and `runtime-constants.ts` under
22
+ * `generatedDir`, or a stub module when the file does not exist (yet).
23
+ *
24
+ * The file check runs on every resolve, so files the CLI generates while the dev
25
+ * server runs are picked up on the next resolve.
26
+ */
27
+ export function i18nVitePlugin(config, generatedDir) {
28
+ const generatedFiles = {
29
+ [FALLBACKS_ID]: toPath(new URL('fallbacks.generated.ts', generatedDir)),
30
+ [RUNTIME_CONSTANTS_ID]: toPath(new URL('runtime-constants.ts', generatedDir)),
31
+ };
32
+ return {
33
+ name: '@edgekits/astro-i18n',
34
+ resolveId(id) {
35
+ if (id === CONFIG_ID)
36
+ return resolved(id);
37
+ const file = generatedFiles[id];
38
+ if (file === undefined)
39
+ return undefined;
40
+ return existsSync(file) ? file : resolved(id);
41
+ },
42
+ load(id) {
43
+ if (id === resolved(CONFIG_ID))
44
+ return `export default ${JSON.stringify(config)}\n`;
45
+ for (const [virtualId, stub] of Object.entries(STUBS)) {
46
+ if (id === resolved(virtualId))
47
+ return stub;
48
+ }
49
+ return undefined;
50
+ },
51
+ };
52
+ }
53
+ // Vite ids use forward slashes on every platform.
54
+ function toPath(url) {
55
+ return fileURLToPath(url).replace(/\\/g, '/');
56
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The translation KV key and edge-cache URL formats, as pure functions of the key
3
+ * prefix. Shared by the runtime (`translations-keys.ts`, prefix from the virtual config)
4
+ * and the CLI (prefix from the loaded config), so the key a value is stored under and
5
+ * the URL a purge targets can never drift apart. Pinned by `test/i18n-keys.test.ts`.
6
+ */
7
+ export declare function formatTranslationKvKey(keyPrefix: string, locale: string, ns: string): string;
8
+ export declare function formatTranslationCacheUrl(keyPrefix: string, locale: string, ns: string): string;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The translation KV key and edge-cache URL formats, as pure functions of the key
3
+ * prefix. Shared by the runtime (`translations-keys.ts`, prefix from the virtual config)
4
+ * and the CLI (prefix from the loaded config), so the key a value is stored under and
5
+ * the URL a purge targets can never drift apart. Pinned by `test/i18n-keys.test.ts`.
6
+ */
7
+ // Format: <keyPrefix>:<namespace>:<locale>
8
+ export function formatTranslationKvKey(keyPrefix, locale, ns) {
9
+ return `${keyPrefix}:${ns}:${locale}`;
10
+ }
11
+ // Format: https://<keyPrefix>/<encodeURIComponent(i18n:<locale>:<namespace>)>
12
+ export function formatTranslationCacheUrl(keyPrefix, locale, ns) {
13
+ const cacheId = `i18n:${locale}:${ns}`;
14
+ return `https://${keyPrefix}/${encodeURIComponent(cacheId)}`;
15
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Public types of the package and the config validator. Pure: no virtual modules,
3
+ * no `cloudflare:workers`, so the consumer's `astro.config` and the CLI can import it
4
+ * under plain Node.
5
+ */
6
+ export type I18nLanguage = {
7
+ countryCode: string;
8
+ name: string;
9
+ };
10
+ export type I18nConfig = {
11
+ languages: Record<string, I18nLanguage>;
12
+ defaultLocale: string;
13
+ cookie: {
14
+ name: string;
15
+ maxAge: number;
16
+ };
17
+ keyPrefix: string;
18
+ missingTranslationBanner: boolean;
19
+ routing: 'prefix';
20
+ generatedDir?: string;
21
+ localesDir?: string;
22
+ countryToLocale?: Record<string, string>;
23
+ markdownLocale?: string;
24
+ };
25
+ export declare const DEFAULT_GENERATED_DIR = "src/i18n";
26
+ export declare const DEFAULT_LOCALES_DIR = "locales";
27
+ /** The locale keys of a config's `languages`: a literal union for a literal config. */
28
+ export type LocaleOf<C extends I18nConfig> = Extract<keyof C['languages'], string>;
29
+ /**
30
+ * Validates and returns the config unchanged. Throws at config time on an empty
31
+ * `languages`, a `defaultLocale` or `markdownLocale` that is not one of its keys, an
32
+ * unknown `routing`, or a `countryToLocale` entry whose key is not an uppercase country
33
+ * code or whose value is not one of `languages` (also a type error for a literal config).
34
+ */
35
+ export declare function defineI18nConfig<const C extends I18nConfig>(config: C & {
36
+ countryToLocale?: Record<string, LocaleOf<C>>;
37
+ markdownLocale?: LocaleOf<C>;
38
+ }): C;
39
+ /**
40
+ * The project's locales, as keys. Empty here; the integration augments it from the
41
+ * config's `languages` (`injectTypes` at `astro:config:done`).
42
+ */
43
+ export interface I18nLocales {
44
+ }
45
+ type LocaleKey = Extract<keyof I18nLocales, string>;
46
+ export type Locale = [LocaleKey] extends [never] ? string : LocaleKey;
47
+ /** The Worker bindings the engine reads from `cloudflare:workers`. */
48
+ export interface I18nBindings {
49
+ TRANSLATIONS: KVNamespace;
50
+ I18N_CACHE?: string;
51
+ DEBUG_I18N?: string;
52
+ }
53
+ declare global {
54
+ namespace I18n {
55
+ interface Schema {
56
+ }
57
+ }
58
+ namespace App {
59
+ interface Locals {
60
+ uiLocale: Locale;
61
+ translationLocale: Locale;
62
+ isMissingContent?: boolean;
63
+ }
64
+ }
65
+ }
66
+ export {};
package/dist/types.js ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Public types of the package and the config validator. Pure: no virtual modules,
3
+ * no `cloudflare:workers`, so the consumer's `astro.config` and the CLI can import it
4
+ * under plain Node.
5
+ */
6
+ export const DEFAULT_GENERATED_DIR = 'src/i18n';
7
+ export const DEFAULT_LOCALES_DIR = 'locales';
8
+ const COUNTRY_CODE = /^[A-Z]{2}$/;
9
+ /**
10
+ * Validates and returns the config unchanged. Throws at config time on an empty
11
+ * `languages`, a `defaultLocale` or `markdownLocale` that is not one of its keys, an
12
+ * unknown `routing`, or a `countryToLocale` entry whose key is not an uppercase country
13
+ * code or whose value is not one of `languages` (also a type error for a literal config).
14
+ */
15
+ export function defineI18nConfig(config) {
16
+ const locales = Object.keys(config.languages ?? {});
17
+ if (locales.length === 0) {
18
+ throw new Error('[@edgekits/astro-i18n] `languages` must define at least one locale.');
19
+ }
20
+ if (!locales.includes(config.defaultLocale)) {
21
+ throw new Error(`[@edgekits/astro-i18n] \`defaultLocale\` "${config.defaultLocale}" is not one of \`languages\`: ${locales.join(', ')}.`);
22
+ }
23
+ if (config.markdownLocale !== undefined && !locales.includes(config.markdownLocale)) {
24
+ throw new Error(`[@edgekits/astro-i18n] \`markdownLocale\` "${config.markdownLocale}" is not one of \`languages\`: ${locales.join(', ')}.`);
25
+ }
26
+ if (config.routing !== 'prefix') {
27
+ throw new Error(`[@edgekits/astro-i18n] \`routing\` "${String(config.routing)}" is not supported; the only routing is 'prefix'.`);
28
+ }
29
+ for (const [country, locale] of Object.entries(config.countryToLocale ?? {})) {
30
+ if (!COUNTRY_CODE.test(country)) {
31
+ throw new Error(`[@edgekits/astro-i18n] \`countryToLocale\` key "${country}" is not an uppercase ISO 3166-1 alpha-2 country code.`);
32
+ }
33
+ if (!locales.includes(locale)) {
34
+ throw new Error(`[@edgekits/astro-i18n] \`countryToLocale\` maps "${country}" to "${locale}", which is not one of \`languages\`: ${locales.join(', ')}.`);
35
+ }
36
+ }
37
+ return config;
38
+ }
package/package.json CHANGED
@@ -1,6 +1,79 @@
1
1
  {
2
2
  "name": "@edgekits/astro-i18n",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Edge-native i18n for Astro on Cloudflare Workers: translations in Workers KV, a per-namespace edge cache invalidated by the Purge API, and typed keys.",
5
+ "keywords": [
6
+ "astro",
7
+ "astro-integration",
8
+ "i18n",
9
+ "cloudflare",
10
+ "cloudflare-workers",
11
+ "kv",
12
+ "edge"
13
+ ],
14
+ "type": "module",
15
+ "license": "MIT",
16
+ "author": "Gary Stupak",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/EdgeKits/astro-i18n"
20
+ },
21
+ "homepage": "https://github.com/EdgeKits/astro-i18n#readme",
22
+ "bugs": {
23
+ "url": "https://github.com/EdgeKits/astro-i18n/issues"
24
+ },
25
+ "engines": {
26
+ "node": ">=22"
27
+ },
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "default": "./dist/index.js"
32
+ },
33
+ "./config": "./src/config.entry.ts",
34
+ "./format": "./src/format.ts",
35
+ "./runtime": "./src/runtime.ts",
36
+ "./middleware": "./src/middleware.ts",
37
+ "./components/*": "./src/components/*.astro",
38
+ "./package.json": "./package.json"
39
+ },
40
+ "bin": {
41
+ "edgekits-i18n": "./dist/cli/index.js"
42
+ },
43
+ "files": [
44
+ "dist",
45
+ "src",
46
+ "README.md",
47
+ "CHANGELOG.md",
48
+ "LICENSE"
49
+ ],
50
+ "publishConfig": {
51
+ "access": "public"
52
+ },
53
+ "scripts": {
54
+ "build": "tsc -p tsconfig.build.json",
55
+ "test": "node --import ./test/register.mjs --test test/**/*.test.ts",
56
+ "typecheck": "tsc --noEmit",
57
+ "format": "prettier --write .",
58
+ "format:check": "prettier --check ."
59
+ },
60
+ "dependencies": {
61
+ "jiti": "^2.7.0",
62
+ "jsonc-parser": "^3.3.1"
63
+ },
64
+ "peerDependencies": {
65
+ "@astrojs/cloudflare": "^14.3.0",
66
+ "astro": "^7.3.0",
67
+ "wrangler": "^4.125.0"
68
+ },
69
+ "devDependencies": {
70
+ "@cloudflare/workers-types": "^5.20261011.1",
71
+ "@types/node": "^25.9.1",
72
+ "astro": "^7.3.8",
73
+ "prettier": "3.9.4",
74
+ "prettier-plugin-astro": "0.14.1",
75
+ "prettier-plugin-tailwindcss": "0.8.0",
76
+ "tsx": "^4.23.12",
77
+ "typescript": "~5.9.3"
78
+ }
79
+ }
@@ -0,0 +1,29 @@
1
+ import { deepMerge } from './deep-merge'
2
+
3
+ /**
4
+ * Assemble a single translation namespace from a raw value and its compiled
5
+ * fallback dictionary.
6
+ *
7
+ * This is the ONLY place namespace assembly happens: `fetchTranslations` routes
8
+ * its cache-HIT branch, its cache-miss/KV branch and `fetchAllFromKV` through it,
9
+ * so a cached namespace and a freshly read one can never diverge in shape.
10
+ *
11
+ * Semantics (unchanged from the original miss path):
12
+ * - the fallback provides the full known key set,
13
+ * - the raw value (cached entry or KV read) overrides it wherever it has a value,
14
+ * - `null` / `undefined` are tolerated for either argument.
15
+ *
16
+ * Consequence for stale cache entries: a namespace cached before a schema addition
17
+ * degrades to default-locale text for the keys it predates instead of handing pages
18
+ * `undefined` (see ADR-0014 and the 2026-07-18 `/en/blog/` incident).
19
+ *
20
+ * Deliberately pure — no `cloudflare:workers` imports, so it is testable under
21
+ * plain Node (`npm test`).
22
+ */
23
+ export function assembleNamespace(rawValue: unknown, fallback: unknown): object {
24
+ const raw = (rawValue as object | null | undefined) ?? {}
25
+
26
+ if (!fallback) return raw
27
+
28
+ return deepMerge(fallback as object, raw)
29
+ }