@edgekits/astro-i18n 0.1.8 → 0.2.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 (56) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +92 -28
  3. package/dist/integration/index.d.ts +25 -4
  4. package/dist/integration/index.js +35 -9
  5. package/dist/integration/vite-plugin.d.ts +1 -1
  6. package/dist/types.d.ts +7 -55
  7. package/dist/types.js +1 -44
  8. package/package.json +7 -17
  9. package/src/components/LanguageSwitcher.astro +89 -93
  10. package/src/config.entry.ts +5 -36
  11. package/src/constants.ts +5 -3
  12. package/src/cookie-storage.ts +19 -25
  13. package/src/fetcher.ts +3 -304
  14. package/src/format.ts +3 -107
  15. package/src/instance.ts +44 -0
  16. package/src/integration/index.ts +38 -9
  17. package/src/integration/vite-plugin.ts +1 -1
  18. package/src/middleware/i18n.ts +232 -114
  19. package/src/resolve-locale.ts +7 -39
  20. package/src/runtime.ts +2 -2
  21. package/src/schema.ts +3 -11
  22. package/src/translations-keys.ts +7 -38
  23. package/src/types.ts +16 -116
  24. package/dist/cli/generate.d.ts +0 -50
  25. package/dist/cli/generate.js +0 -160
  26. package/dist/cli/hashes.d.ts +0 -19
  27. package/dist/cli/hashes.js +0 -42
  28. package/dist/cli/index.d.ts +0 -2
  29. package/dist/cli/index.js +0 -372
  30. package/dist/cli/key-prefix-check.d.ts +0 -6
  31. package/dist/cli/key-prefix-check.js +0 -27
  32. package/dist/cli/load-config.d.ts +0 -7
  33. package/dist/cli/load-config.js +0 -19
  34. package/dist/cli/purge.d.ts +0 -29
  35. package/dist/cli/purge.js +0 -94
  36. package/dist/cli/wrangler.d.ts +0 -10
  37. package/dist/cli/wrangler.js +0 -71
  38. package/dist/cli/zone.d.ts +0 -21
  39. package/dist/cli/zone.js +0 -42
  40. package/dist/key-format.d.ts +0 -8
  41. package/dist/key-format.js +0 -15
  42. package/src/accept-language.ts +0 -56
  43. package/src/assemble.ts +0 -29
  44. package/src/cli/generate.ts +0 -205
  45. package/src/cli/hashes.ts +0 -66
  46. package/src/cli/index.ts +0 -472
  47. package/src/cli/key-prefix-check.ts +0 -31
  48. package/src/cli/load-config.ts +0 -26
  49. package/src/cli/purge.ts +0 -122
  50. package/src/cli/wrangler.ts +0 -96
  51. package/src/cli/zone.ts +0 -64
  52. package/src/deep-merge.ts +0 -38
  53. package/src/key-format.ts +0 -17
  54. package/src/locale-fallback.ts +0 -58
  55. package/src/markdown-path.ts +0 -31
  56. package/src/parse-boolean.ts +0 -20
package/src/types.ts CHANGED
@@ -1,123 +1,23 @@
1
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.
2
+ * The core's public types and config helpers, re-exported for this package's entries
3
+ * (root, `./config`, `./runtime`), plus what only Astro needs: the `App.Locals` the
4
+ * middleware fills. `I18nLocales` is the core's interface: augmenting it through either
5
+ * entry (the integration's injected `locales.d.ts`) merges into that one declaration.
5
6
  */
6
-
7
- // `countryCode` is what the switcher's `flag` component receives; without it the item
8
- // shows the name alone.
9
- export type I18nLanguage = { countryCode?: string; name: string }
10
-
11
- export type I18nConfig = {
12
- // The only source of truth for languages: the keys are the locales.
13
- languages: Record<string, I18nLanguage>
14
- defaultLocale: string
15
- // The lang cookie the middleware keeps in sync with the URL locale.
16
- cookie: { name: string; maxAge: number }
17
- // Prefix of every translation KV key and the host of every edge-cache URL.
18
- keyPrefix: string
19
- missingTranslationBanner: boolean
20
- routing: 'prefix'
21
- // Where the CLI writes `fallbacks.generated.ts` and `runtime-constants.ts`,
22
- // relative to the project root.
23
- generatedDir?: string
24
- // Where the CLI reads `<locale>/<namespace>.json`, relative to the project root.
25
- localesDir?: string
26
- // Geo-IP step of the middleware's locale fallback: ISO 3166-1 alpha-2 country code
27
- // (uppercase, as `request.cf.country` sends it) → one of `languages`. Without it the
28
- // step is skipped; the package ships no table of its own.
29
- countryToLocale?: Record<string, string>
30
- // The locale of the project's `.md` endpoints: a `.md` path is redirected once to
31
- // `/<markdownLocale>/<rest>` (a leading locale segment replaced, otherwise kept). Without
32
- // it, `.md` paths pass through the middleware untouched.
33
- markdownLocale?: string
34
- }
35
-
36
- export const DEFAULT_GENERATED_DIR = 'src/i18n'
37
- export const DEFAULT_LOCALES_DIR = 'locales'
38
-
39
- /** The locale keys of a config's `languages`: a literal union for a literal config. */
40
- export type LocaleOf<C extends I18nConfig> = Extract<keyof C['languages'], string>
41
-
42
- const COUNTRY_CODE = /^[A-Z]{2}$/
43
-
44
- /**
45
- * Validates and returns the config unchanged. Throws at config time on an empty
46
- * `languages`, a `defaultLocale` or `markdownLocale` that is not one of its keys, an
47
- * unknown `routing`, or a `countryToLocale` entry whose key is not an uppercase country
48
- * code or whose value is not one of `languages` (also a type error for a literal config).
49
- */
50
- export function defineI18nConfig<const C extends I18nConfig>(
51
- config: C & { countryToLocale?: Record<string, LocaleOf<C>>; markdownLocale?: LocaleOf<C> },
52
- ): C {
53
- const locales = Object.keys(config.languages ?? {})
54
-
55
- if (locales.length === 0) {
56
- throw new Error('[@edgekits/astro-i18n] `languages` must define at least one locale.')
57
- }
58
- // Lowercase everywhere (config, `locales/` folders, URLs); `toIetfTag` relies on it.
59
- for (const locale of locales) {
60
- if (locale !== locale.toLowerCase()) {
61
- throw new Error(
62
- `[@edgekits/astro-i18n] \`languages\` key "${locale}" is not lowercase (use "${locale.toLowerCase()}").`,
63
- )
64
- }
65
- }
66
- if (!locales.includes(config.defaultLocale)) {
67
- throw new Error(
68
- `[@edgekits/astro-i18n] \`defaultLocale\` "${config.defaultLocale}" is not one of \`languages\`: ${locales.join(', ')}.`,
69
- )
70
- }
71
- if (config.markdownLocale !== undefined && !locales.includes(config.markdownLocale)) {
72
- throw new Error(
73
- `[@edgekits/astro-i18n] \`markdownLocale\` "${config.markdownLocale}" is not one of \`languages\`: ${locales.join(', ')}.`,
74
- )
75
- }
76
- if (config.routing !== 'prefix') {
77
- throw new Error(
78
- `[@edgekits/astro-i18n] \`routing\` "${String(config.routing)}" is not supported; the only routing is 'prefix'.`,
79
- )
80
- }
81
- for (const [country, locale] of Object.entries(config.countryToLocale ?? {})) {
82
- if (!COUNTRY_CODE.test(country)) {
83
- throw new Error(
84
- `[@edgekits/astro-i18n] \`countryToLocale\` key "${country}" is not an uppercase ISO 3166-1 alpha-2 country code.`,
85
- )
86
- }
87
- if (!locales.includes(locale)) {
88
- throw new Error(
89
- `[@edgekits/astro-i18n] \`countryToLocale\` maps "${country}" to "${locale}", which is not one of \`languages\`: ${locales.join(', ')}.`,
90
- )
91
- }
92
- }
93
-
94
- return config
95
- }
96
-
97
- /**
98
- * The project's locales, as keys. Empty here; the integration augments it from the
99
- * config's `languages` (`injectTypes` at `astro:config:done`).
100
- */
101
- export interface I18nLocales {}
102
-
103
- type LocaleKey = Extract<keyof I18nLocales, string>
104
-
105
- // Falls back to `string` while `I18nLocales` is empty, like `Namespace` over `I18n.Schema`.
106
- export type Locale = [LocaleKey] extends [never] ? string : LocaleKey
107
-
108
- /** The Worker bindings the engine reads from `cloudflare:workers`. */
109
- export interface I18nBindings {
110
- TRANSLATIONS: KVNamespace
111
- I18N_CACHE?: string
112
- DEBUG_I18N?: string
113
- }
7
+ import type { Locale } from '@edgekits/i18n-core'
8
+
9
+ export { DEFAULT_GENERATED_DIR, defineI18nConfig, localesOf } from '@edgekits/i18n-core'
10
+ export type {
11
+ I18nBindings,
12
+ I18nConfig,
13
+ I18nLanguage,
14
+ I18nLocales,
15
+ Locale,
16
+ LocaleOf,
17
+ LocalesOf,
18
+ } from '@edgekits/i18n-core'
114
19
 
115
20
  declare global {
116
- // Augmented by the consumer's generated `i18n.generated.d.ts`.
117
- namespace I18n {
118
- interface Schema {}
119
- }
120
-
121
21
  // Set by the i18n middleware on every request.
122
22
  namespace App {
123
23
  interface Locals {
@@ -1,50 +0,0 @@
1
- /**
2
- * The CLI's code generation, as pure functions of the collected locale tree: no
3
- * filesystem, so the emitted text is testable. Formats are those of the site's
4
- * `scripts/bundle-translations.ts`, header aside.
5
- */
6
- export type TranslationTree = Record<string, unknown>;
7
- export type CollectedData = Record<string, Record<string, TranslationTree>>;
8
- export type KvBulkEntry = {
9
- key: string;
10
- value: string;
11
- };
12
- export declare const NAMESPACE_NAME: RegExp;
13
- /**
14
- * Throws, naming the offender, when a locale folder is not one of the config's
15
- * `languages` or a namespace file name does not match NAMESPACE_NAME. Run on the
16
- * collected tree before anything is generated or pushed.
17
- */
18
- export declare function assertValidNames(collected: CollectedData, languages: Readonly<Record<string, unknown>>): void;
19
- /**
20
- * A single-quoted TS string literal, escaped by JSON.stringify. For every name the
21
- * validation accepts it is byte-identical to the plain `'name'` the CLI always wrote, so
22
- * existing generated files do not change; anything else comes out escaped, not injected.
23
- */
24
- export declare function quoteString(value: string): string;
25
- /** Locales that have at least one namespace, sorted. */
26
- export declare function localesWithTranslations(collected: CollectedData): string[];
27
- /**
28
- * The locale the schema and the namespace list are derived from: the default locale
29
- * when it has translations, otherwise the first locale that does.
30
- */
31
- export declare function schemaSourceLocale(collected: CollectedData, defaultLocale: string): string | undefined;
32
- /** `wrangler kv bulk put` input: one entry per locale and namespace. */
33
- export declare function buildBulkData(collected: CollectedData, keyPrefix: string): KvBulkEntry[];
34
- /**
35
- * `src/i18n.generated.d.ts`: the global `I18n.Schema`, from the schema source locale.
36
- * Throws, naming the key, when an array in it mixes kinds of elements.
37
- */
38
- export declare function buildTypesContent(collected: CollectedData, defaultLocale: string): string;
39
- /**
40
- * `runtime-constants.ts`:
41
- * - NAMESPACE_KEYS -> from the schema source locale (default locale preferred)
42
- * - LOCALES_WITH_TRANSLATIONS -> from all collected locales
43
- * - FULLY_TRANSLATED_LOCALES -> locales that have ALL namespace keys
44
- */
45
- export declare function buildRuntimeConstantsContent(collected: CollectedData, defaultLocale: string): string;
46
- /**
47
- * `fallbacks.generated.ts`: one `FALLBACK_<NAMESPACE>` per default-locale namespace.
48
- * Null when the default locale has no translations (nothing to fall back to).
49
- */
50
- export declare function buildFallbacksContent(collected: CollectedData, defaultLocale: string): string | null;
@@ -1,160 +0,0 @@
1
- import { formatTranslationKvKey } from "../key-format.js";
2
- const HEADER = `// Auto-generated by @edgekits/astro-i18n (edgekits-i18n CLI)
3
- // Do not edit manually.
4
- `;
5
- // A namespace becomes part of a KV key, a cache URL and the `FALLBACK_<NAMESPACE>`
6
- // identifier, so its file name is restricted to what is valid in all three.
7
- export const NAMESPACE_NAME = /^[a-z0-9][a-z0-9_]*$/;
8
- /**
9
- * Throws, naming the offender, when a locale folder is not one of the config's
10
- * `languages` or a namespace file name does not match NAMESPACE_NAME. Run on the
11
- * collected tree before anything is generated or pushed.
12
- */
13
- export function assertValidNames(collected, languages) {
14
- const configured = Object.keys(languages);
15
- for (const [locale, namespaces] of Object.entries(collected)) {
16
- if (!configured.includes(locale)) {
17
- throw new Error(`Locale folder "${locale}" is not one of \`languages\` in edgekits-i18n.config.ts: ${configured.join(', ')}.`);
18
- }
19
- for (const ns of Object.keys(namespaces)) {
20
- if (!NAMESPACE_NAME.test(ns)) {
21
- throw new Error(`Namespace "${ns}" (${locale}/${ns}.json) must match ${NAMESPACE_NAME}: lowercase letters, digits and underscores.`);
22
- }
23
- }
24
- }
25
- }
26
- /**
27
- * A single-quoted TS string literal, escaped by JSON.stringify. For every name the
28
- * validation accepts it is byte-identical to the plain `'name'` the CLI always wrote, so
29
- * existing generated files do not change; anything else comes out escaped, not injected.
30
- */
31
- export function quoteString(value) {
32
- const escaped = JSON.stringify(value).slice(1, -1).replace(/\\"/g, '"').replace(/'/g, "\\'");
33
- return `'${escaped}'`;
34
- }
35
- /** Locales that have at least one namespace, sorted. */
36
- export function localesWithTranslations(collected) {
37
- return Object.keys(collected).sort();
38
- }
39
- /**
40
- * The locale the schema and the namespace list are derived from: the default locale
41
- * when it has translations, otherwise the first locale that does.
42
- */
43
- export function schemaSourceLocale(collected, defaultLocale) {
44
- if (collected[defaultLocale])
45
- return defaultLocale;
46
- return localesWithTranslations(collected)[0];
47
- }
48
- /** `wrangler kv bulk put` input: one entry per locale and namespace. */
49
- export function buildBulkData(collected, keyPrefix) {
50
- const bulkData = [];
51
- for (const [locale, namespaces] of Object.entries(collected)) {
52
- for (const [ns, json] of Object.entries(namespaces)) {
53
- bulkData.push({ key: formatTranslationKvKey(keyPrefix, locale, ns), value: JSON.stringify(json) });
54
- }
55
- }
56
- return bulkData;
57
- }
58
- function isTree(value) {
59
- return typeof value === 'object' && value !== null && !Array.isArray(value);
60
- }
61
- // The JSON kind of an array element; an array is typed only when all its elements share one.
62
- function jsonKind(value) {
63
- if (value === null)
64
- return 'null';
65
- if (Array.isArray(value))
66
- return 'array';
67
- return typeof value;
68
- }
69
- /**
70
- * The TS type of one JSON value. Objects become object types; arrays become `string[]`
71
- * (strings, other scalars, or empty) or `Array<…>` typed from their first element
72
- * (objects, nested arrays); every other leaf stays `string`. `keyPath` and `file` name the
73
- * value in the error a mixed array raises.
74
- */
75
- function generateSchemaTs(value, indent, keyPath, file) {
76
- if (Array.isArray(value)) {
77
- if (value.length === 0)
78
- return 'string[]';
79
- const kinds = [...new Set(value.map(jsonKind))];
80
- if (kinds.length > 1) {
81
- throw new Error(`Key "${keyPath}" (${file}) is an array that mixes ${kinds.join(', ')} elements: ` +
82
- 'I18n.Schema cannot type it. Use one kind of element per array.');
83
- }
84
- const first = value[0];
85
- if (!isTree(first) && !Array.isArray(first))
86
- return 'string[]';
87
- return `Array<${generateSchemaTs(first, indent, `${keyPath}[0]`, file)}>`;
88
- }
89
- if (!isTree(value))
90
- return 'string';
91
- const entries = Object.entries(value);
92
- if (entries.length === 0)
93
- return '{}';
94
- const pad = ' '.repeat(indent);
95
- const padInner = ' '.repeat(indent + 2);
96
- const lines = entries.map(([key, child]) => `${padInner}${JSON.stringify(key)}: ${generateSchemaTs(child, indent + 2, `${keyPath}.${key}`, file)}`);
97
- return `{\n${lines.join(';\n')};\n${pad}}`;
98
- }
99
- /**
100
- * `src/i18n.generated.d.ts`: the global `I18n.Schema`, from the schema source locale.
101
- * Throws, naming the key, when an array in it mixes kinds of elements.
102
- */
103
- export function buildTypesContent(collected, defaultLocale) {
104
- const source = schemaSourceLocale(collected, defaultLocale);
105
- const schemaObject = (source && collected[source]) || {};
106
- const namespaceLines = Object.entries(schemaObject).map(([ns, value]) => {
107
- const valueTs = generateSchemaTs(value, 10, ns, `${source}/${ns}.json`);
108
- return ` ${JSON.stringify(ns)}: ${valueTs}`;
109
- });
110
- const interfaceBody = namespaceLines.length > 0
111
- ? ` export interface Schema {\n${namespaceLines.join(';\n')};\n }`
112
- : ` export interface Schema {}`;
113
- return `${HEADER}
114
- declare global {
115
- namespace I18n {
116
- ${interfaceBody}
117
- }
118
- }
119
-
120
- export {}
121
- `;
122
- }
123
- /**
124
- * `runtime-constants.ts`:
125
- * - NAMESPACE_KEYS -> from the schema source locale (default locale preferred)
126
- * - LOCALES_WITH_TRANSLATIONS -> from all collected locales
127
- * - FULLY_TRANSLATED_LOCALES -> locales that have ALL namespace keys
128
- */
129
- export function buildRuntimeConstantsContent(collected, defaultLocale) {
130
- const source = schemaSourceLocale(collected, defaultLocale);
131
- const namespaceKeys = Object.keys((source && collected[source]) || {}).sort();
132
- const locales = localesWithTranslations(collected);
133
- const fullyTranslatedLocales = locales.filter((locale) => {
134
- const localeNamespaces = Object.keys(collected[locale] || {});
135
- return namespaceKeys.every((ns) => localeNamespaces.includes(ns));
136
- });
137
- return `${HEADER}
138
- export const NAMESPACE_KEYS = [${namespaceKeys.map(quoteString).join(', ')}] as [string, ...string[]];
139
-
140
- export const LOCALES_WITH_TRANSLATIONS = [${locales.map(quoteString).join(', ')}] as const;
141
-
142
- export const FULLY_TRANSLATED_LOCALES = [${fullyTranslatedLocales.map(quoteString).join(', ')}] as const;
143
- `;
144
- }
145
- /**
146
- * `fallbacks.generated.ts`: one `FALLBACK_<NAMESPACE>` per default-locale namespace.
147
- * Null when the default locale has no translations (nothing to fall back to).
148
- */
149
- export function buildFallbacksContent(collected, defaultLocale) {
150
- const fallbackNamespaces = collected[defaultLocale];
151
- if (!fallbackNamespaces)
152
- return null;
153
- const lines = [HEADER];
154
- lines.push(`export const FALLBACK_LOCALE = ${JSON.stringify(defaultLocale)} as const;\n`);
155
- for (const [ns, json] of Object.entries(fallbackNamespaces)) {
156
- const constName = `FALLBACK_${ns.toUpperCase()}`;
157
- lines.push(`export const ${constName} = ${JSON.stringify(json, null, 2)} as const;\n`);
158
- }
159
- return lines.join('\n');
160
- }
@@ -1,19 +0,0 @@
1
- import type { CollectedData } from './generate.ts';
2
- /**
3
- * Per-namespace content hashes and the change diff that drives the cache purge.
4
- * Pure apart from hashing. Same algorithm as the site's `scripts/bundle-translations.ts`,
5
- * so an existing `.i18n-hashes.json` stays a valid baseline.
6
- */
7
- export type HashMap = Record<string, string>;
8
- export declare function stableStringify(value: unknown): string;
9
- export declare function computeHash(value: unknown): string;
10
- /**
11
- * Hash every collected namespace and list the "<locale>:<namespace>" pairs whose hash
12
- * differs from the previous run (all of them when there is no previous run).
13
- */
14
- export declare function diffHashes(collected: CollectedData, previousHashes: HashMap): {
15
- currentHashes: HashMap;
16
- changedKeys: string[];
17
- };
18
- /** Purge API URLs for the changed "<locale>:<namespace>" pairs. */
19
- export declare function purgeUrlsFor(changedKeys: readonly string[], keyPrefix: string): string[];
@@ -1,42 +0,0 @@
1
- import crypto from 'node:crypto';
2
- import { formatTranslationCacheUrl } from "../key-format.js";
3
- export function stableStringify(value) {
4
- if (value === null || typeof value !== 'object') {
5
- return JSON.stringify(value);
6
- }
7
- if (Array.isArray(value)) {
8
- return '[' + value.map(stableStringify).join(',') + ']';
9
- }
10
- const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b));
11
- return '{' + entries.map(([k, v]) => JSON.stringify(k) + ':' + stableStringify(v)).join(',') + '}';
12
- }
13
- export function computeHash(value) {
14
- const json = stableStringify(value);
15
- return crypto.createHash('sha256').update(json).digest('hex').slice(0, 12);
16
- }
17
- /**
18
- * Hash every collected namespace and list the "<locale>:<namespace>" pairs whose hash
19
- * differs from the previous run (all of them when there is no previous run).
20
- */
21
- export function diffHashes(collected, previousHashes) {
22
- const currentHashes = {};
23
- const changedKeys = [];
24
- for (const [locale, namespaces] of Object.entries(collected)) {
25
- for (const [ns, json] of Object.entries(namespaces)) {
26
- const hashKey = `${locale}:${ns}`;
27
- const hash = computeHash(json);
28
- currentHashes[hashKey] = hash;
29
- if (previousHashes[hashKey] !== hash) {
30
- changedKeys.push(hashKey);
31
- }
32
- }
33
- }
34
- return { currentHashes, changedKeys };
35
- }
36
- /** Purge API URLs for the changed "<locale>:<namespace>" pairs. */
37
- export function purgeUrlsFor(changedKeys, keyPrefix) {
38
- return changedKeys.map((hashKey) => {
39
- const [locale, ns] = hashKey.split(':');
40
- return formatTranslationCacheUrl(keyPrefix, locale, ns);
41
- });
42
- }
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env node
2
- export {};