@edgekits/astro-i18n 0.0.0-stage → 0.1.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +657 -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 +78 -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 +211 -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 +121 -0
  60. package/src/virtual.d.ts +23 -0
@@ -0,0 +1,94 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Cloudflare Cache Purge API
3
+ // ---------------------------------------------------------------------------
4
+ /**
5
+ * Purge a list of URLs from Cloudflare's edge cache via the Purge API.
6
+ *
7
+ * Handles Cloudflare's rate limits automatically:
8
+ * - Max 100 URLs per request (max operations per request)
9
+ * - Max 800 URLs per second on Free plan (8 chunks of 100)
10
+ * - HTTP 429: up to MAX_RETRIES retries per chunk, waiting `Retry-After` seconds when
11
+ * the response sends it, otherwise 2 s, 4 s, 8 s… (every wait capped at
12
+ * MAX_RETRY_WAIT_MS); then the chunk counts as failed.
13
+ *
14
+ * Reference: https://developers.cloudflare.com/cache/how-to/purge-cache/
15
+ *
16
+ * `deps` replaces `fetch` and the wait in tests.
17
+ *
18
+ * @returns true if all chunks succeeded, false if any chunk failed
19
+ */
20
+ export async function purgeTranslationsCache(zoneId, apiToken, urls, deps = {}) {
21
+ if (!urls.length)
22
+ return true;
23
+ const { fetch: fetchFn = fetch, sleep: wait = sleep } = deps;
24
+ // Cloudflare limit: max 100 URLs per single purge request
25
+ const CHUNK_SIZE = 100;
26
+ // Cloudflare Free plan limit: 800 URLs/sec = 8 chunks of 100 per second
27
+ // Pro and above: 1500 URLs/sec — CHUNK_SIZE * MAX_CHUNKS_PER_SEC covers Free plan
28
+ const MAX_CHUNKS_PER_SEC = 8;
29
+ console.log(`[i18n] Purging cache for ${urls.length} URL(s)...`);
30
+ // 429 retries of the current chunk; reset when a chunk goes through.
31
+ let retries = 0;
32
+ for (let i = 0; i < urls.length; i += CHUNK_SIZE) {
33
+ const chunk = urls.slice(i, i + CHUNK_SIZE);
34
+ const currentChunkIndex = Math.floor(i / CHUNK_SIZE) + 1;
35
+ try {
36
+ const response = await fetchFn(`https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`, {
37
+ method: 'POST',
38
+ headers: {
39
+ Authorization: `Bearer ${apiToken}`,
40
+ 'Content-Type': 'application/json',
41
+ },
42
+ body: JSON.stringify({ files: chunk }),
43
+ });
44
+ // Rate limit hit — wait and retry the same chunk, a bounded number of times
45
+ if (response.status === 429) {
46
+ if (retries >= MAX_RETRIES) {
47
+ console.error(`[i18n] [Chunk ${currentChunkIndex}] Still rate-limited after ${MAX_RETRIES} retries; ` +
48
+ `giving up on ${chunk.length} URL(s):\n` +
49
+ chunk.map((url) => ` - ${url}`).join('\n'));
50
+ return false;
51
+ }
52
+ retries++;
53
+ const waitMs = retryWaitMs(response.headers.get('retry-after'), retries);
54
+ console.warn(`[i18n] [Chunk ${currentChunkIndex}] Rate limit hit, retry ${retries}/${MAX_RETRIES} in ${waitMs}ms...`);
55
+ await wait(waitMs);
56
+ i -= CHUNK_SIZE; // retry same chunk
57
+ continue;
58
+ }
59
+ if (!response.ok) {
60
+ const errorData = await response.text();
61
+ console.error(`[i18n] [Chunk ${currentChunkIndex}] Cloudflare API error:`, errorData);
62
+ return false;
63
+ }
64
+ retries = 0;
65
+ console.log(`[i18n] [Chunk ${currentChunkIndex}] Purged ${chunk.length} URL(s).`);
66
+ // After sending MAX_CHUNKS_PER_SEC chunks (= 800 URLs), pause for 1 second
67
+ // to stay within the Free plan rate limit before the next batch
68
+ if (currentChunkIndex % MAX_CHUNKS_PER_SEC === 0 && i + CHUNK_SIZE < urls.length) {
69
+ console.log(`[i18n] Reached 800 URL/sec limit. Waiting 1000ms before next batch...`);
70
+ await wait(1000);
71
+ }
72
+ }
73
+ catch (error) {
74
+ console.error(`[i18n] [Chunk ${currentChunkIndex}] Network error calling Cloudflare API:`, error);
75
+ return false;
76
+ }
77
+ }
78
+ return true;
79
+ }
80
+ export const MAX_RETRIES = 5;
81
+ export const MAX_RETRY_WAIT_MS = 30_000;
82
+ /**
83
+ * The wait before 429 retry number `attempt` (1-based): `Retry-After` in seconds when
84
+ * the header holds a non-negative number, otherwise 2 s doubled per attempt. Either way
85
+ * capped at MAX_RETRY_WAIT_MS, so a large header cannot stall the run.
86
+ */
87
+ export function retryWaitMs(retryAfter, attempt) {
88
+ const seconds = retryAfter === null || retryAfter.trim() === '' ? NaN : Number(retryAfter);
89
+ const ms = Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : 2000 * 2 ** (attempt - 1);
90
+ return Math.min(ms, MAX_RETRY_WAIT_MS);
91
+ }
92
+ function sleep(ms) {
93
+ return new Promise((resolve) => setTimeout(resolve, ms));
94
+ }
@@ -0,0 +1,10 @@
1
+ export declare const KV_BINDING = "TRANSLATIONS";
2
+ export declare function getKvNamespaceId(root: string, binding: string): string;
3
+ export declare function getWranglerVar(root: string, key: string): string | undefined;
4
+ /**
5
+ * The absolute path of the project's wrangler entry (`bin.wrangler` in its package.json),
6
+ * resolved from `root` as the project itself would resolve it. `wrangler/package.json` is
7
+ * an exported subpath; `bin/` is not, hence the detour through the manifest. Throws when
8
+ * the project has no wrangler or its manifest declares no `bin.wrangler`.
9
+ */
10
+ export declare function resolveWranglerBin(root: string): string;
@@ -0,0 +1,71 @@
1
+ import fs from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import path from 'node:path';
4
+ import { parse as parseJsonc, printParseErrorCode } from 'jsonc-parser';
5
+ // ---------------------------------------------------------------------------
6
+ // Wrangler config reader
7
+ // Parses wrangler.jsonc to extract KV namespace IDs and vars by key.
8
+ // Single source of truth — no hardcoded IDs or zone IDs in package.json.
9
+ // ---------------------------------------------------------------------------
10
+ export const KV_BINDING = 'TRANSLATIONS';
11
+ function readWranglerConfig(root) {
12
+ const wranglerConfigPath = path.join(root, 'wrangler.jsonc');
13
+ if (!fs.existsSync(wranglerConfigPath)) {
14
+ console.error(`[i18n] wrangler.jsonc not found at ${wranglerConfigPath}`);
15
+ process.exit(1);
16
+ }
17
+ const raw = fs.readFileSync(wranglerConfigPath, 'utf8');
18
+ const errors = [];
19
+ const config = parseJsonc(raw, errors, { allowTrailingComma: true });
20
+ if (!config || typeof config !== 'object') {
21
+ console.error('[i18n] Failed to parse wrangler.jsonc — result is empty.');
22
+ for (const err of errors) {
23
+ const snippet = raw.slice(Math.max(0, err.offset - 10), err.offset + 20);
24
+ console.error(` ${printParseErrorCode(err.error)} at offset ${err.offset}: ...${JSON.stringify(snippet)}...`);
25
+ }
26
+ process.exit(1);
27
+ }
28
+ return config;
29
+ }
30
+ export function getKvNamespaceId(root, binding) {
31
+ const config = readWranglerConfig(root);
32
+ const kvNamespaces = config.kv_namespaces;
33
+ const entry = kvNamespaces?.find((kv) => kv.binding === binding);
34
+ if (!entry) {
35
+ console.error(`[i18n] KV binding "${binding}" not found in wrangler.jsonc`);
36
+ process.exit(1);
37
+ }
38
+ return entry.id;
39
+ }
40
+ export function getWranglerVar(root, key) {
41
+ const config = readWranglerConfig(root);
42
+ const vars = config.vars;
43
+ return vars?.[key];
44
+ }
45
+ // ---------------------------------------------------------------------------
46
+ // Wrangler binary
47
+ // The CLI runs wrangler's JavaScript entry with the current Node binary, never through
48
+ // a shell (no `.cmd` shim on Windows, nothing for a shell to interpret).
49
+ // ---------------------------------------------------------------------------
50
+ /**
51
+ * The absolute path of the project's wrangler entry (`bin.wrangler` in its package.json),
52
+ * resolved from `root` as the project itself would resolve it. `wrangler/package.json` is
53
+ * an exported subpath; `bin/` is not, hence the detour through the manifest. Throws when
54
+ * the project has no wrangler or its manifest declares no `bin.wrangler`.
55
+ */
56
+ export function resolveWranglerBin(root) {
57
+ const requireFromRoot = createRequire(path.join(root, 'package.json'));
58
+ let manifestPath;
59
+ try {
60
+ manifestPath = requireFromRoot.resolve('wrangler/package.json');
61
+ }
62
+ catch {
63
+ throw new Error(`wrangler is not installed in ${root}. The CLI runs the project's own wrangler: install it there (npm i -D wrangler).`);
64
+ }
65
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
66
+ const bin = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.wrangler;
67
+ if (!bin) {
68
+ throw new Error(`wrangler at ${path.dirname(manifestPath)} declares no bin.wrangler in its package.json.`);
69
+ }
70
+ return path.join(path.dirname(manifestPath), bin);
71
+ }
@@ -0,0 +1,2 @@
1
+ export { edgekitsI18n, type EdgekitsI18nOptions } from './integration/index.ts';
2
+ export { defineI18nConfig, type I18nBindings, type I18nConfig, type I18nLanguage, type I18nLocales, type Locale, } from './types.ts';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ // The config-time entry: what `astro.config` and `edgekits-i18n.config.ts` import.
2
+ // Pure under Node: nothing here reaches a virtual module or `cloudflare:workers`.
3
+ export { edgekitsI18n } from "./integration/index.js";
4
+ export { defineI18nConfig, } from "./types.js";
@@ -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,80 @@
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.1",
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
+ "prepublishOnly": "npm run build",
56
+ "test": "node --import ./test/register.mjs --test test/**/*.test.ts",
57
+ "typecheck": "tsc --noEmit",
58
+ "format": "prettier --write .",
59
+ "format:check": "prettier --check ."
60
+ },
61
+ "dependencies": {
62
+ "jiti": "^2.7.0",
63
+ "jsonc-parser": "^3.3.1"
64
+ },
65
+ "peerDependencies": {
66
+ "@astrojs/cloudflare": "^14.3.0",
67
+ "astro": "^7.3.0",
68
+ "wrangler": "^4.125.0"
69
+ },
70
+ "devDependencies": {
71
+ "@cloudflare/workers-types": "^5.20261011.1",
72
+ "@types/node": "^25.9.1",
73
+ "astro": "^7.3.8",
74
+ "prettier": "3.9.4",
75
+ "prettier-plugin-astro": "0.14.1",
76
+ "prettier-plugin-tailwindcss": "0.8.0",
77
+ "tsx": "^4.23.12",
78
+ "typescript": "~5.9.3"
79
+ }
80
+ }