rosetta-i18n 1.0.0-beta.1 → 1.0.0-beta.2
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/README.md +138 -0
- package/dist/esm/astro.js +103 -0
- package/dist/esm/astro.js.map +1 -0
- package/dist/esm/cli.js +9 -0
- package/dist/esm/cli.js.map +1 -1
- package/dist/esm/locale.js +95 -0
- package/dist/esm/locale.js.map +1 -0
- package/dist/esm/next.js +112 -1
- package/dist/esm/next.js.map +1 -1
- package/dist/esm/react.js +150 -0
- package/dist/esm/react.js.map +1 -0
- package/dist/types/locale/astro.d.ts +33 -0
- package/dist/types/locale/core.d.ts +102 -0
- package/dist/types/locale/next.d.ts +56 -0
- package/dist/types/next.d.ts +1 -0
- package/dist/types/react.d.ts +59 -0
- package/package.json +25 -2
- package/skills/rosetta/SKILL.md +319 -0
package/README.md
CHANGED
|
@@ -25,6 +25,7 @@ npx rosetta check # CI gate: fails if anything is stale or broken (no API ke
|
|
|
25
25
|
## Contents
|
|
26
26
|
|
|
27
27
|
- [Quick start](#quick-start)
|
|
28
|
+
- [Set up with your AI agent](#set-up-with-your-ai-agent)
|
|
28
29
|
- [How it works](#how-it-works)
|
|
29
30
|
- [Configuration](#configuration)
|
|
30
31
|
- [Commands](#commands)
|
|
@@ -33,6 +34,7 @@ npx rosetta check # CI gate: fails if anything is stale or broken (no API ke
|
|
|
33
34
|
- [Coming from Lingo.dev](#coming-from-lingodev)
|
|
34
35
|
- [Library API](#library-api)
|
|
35
36
|
- [React & Next.js](#react--nextjs)
|
|
37
|
+
- [Locale routing & cookies](#locale-routing--cookies)
|
|
36
38
|
- [Development](#development)
|
|
37
39
|
|
|
38
40
|
## Quick start
|
|
@@ -68,6 +70,26 @@ Already have translations? The first `push` **adopts** them — it records them
|
|
|
68
70
|
the lockfile without retranslating, so it's free. Only strings that are missing,
|
|
69
71
|
changed, or broken go to the model.
|
|
70
72
|
|
|
73
|
+
## Set up with your AI agent
|
|
74
|
+
|
|
75
|
+
Copy this into your coding agent (Claude Code, Cursor, Codex, opencode, …) from your
|
|
76
|
+
project root:
|
|
77
|
+
|
|
78
|
+
```text
|
|
79
|
+
Set up Rosetta (npm: rosetta-i18n) for translations in this repo. Follow https://raw.githubusercontent.com/ian/rosetta/main/skills/rosetta/SKILL.md step by step: discover our locale files and current translation setup and report back, configure .rosetta/config.json, adopt our existing translations with `rosetta status` + `rosetta push` (list any translations it flags as broken), add tests and CI, remove the old translation tooling, document the workflow in AGENTS.md, and open one PR. Ask me before running `push --force`, before any large translation run, and before choosing how translations land if it's unclear.
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- For agents that can't fetch URLs, use the full, self-contained prompt in
|
|
83
|
+
[`docs/agent-prompt.md`](docs/agent-prompt.md).
|
|
84
|
+
- To give your agent Rosetta know-how for everyday requests ("add a German string",
|
|
85
|
+
"why is the i18n job red?"), install the skill:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
mkdir -p .claude/skills/rosetta && curl -fsSL https://raw.githubusercontent.com/ian/rosetta/main/skills/rosetta/SKILL.md -o .claude/skills/rosetta/SKILL.md
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Other agents: [`docs/agent-prompt.md#install-the-skill`](docs/agent-prompt.md#install-the-skill).
|
|
92
|
+
|
|
71
93
|
## How it works
|
|
72
94
|
|
|
73
95
|
Rosetta keeps a lockfile, `.rosetta/lock.json`, that records — for every file,
|
|
@@ -534,6 +556,119 @@ the route handler or Server Action above.
|
|
|
534
556
|
> Node-only workflow (`push`, `check`, …, which read and write files), so prefer the
|
|
535
557
|
> `next` entrypoint in edge code.
|
|
536
558
|
|
|
559
|
+
## Locale routing & cookies
|
|
560
|
+
|
|
561
|
+
Translating strings is half the job. Knowing *which* locale a request is in — and
|
|
562
|
+
switching cleanly — is the other half, and it's where most apps hand-roll fragile
|
|
563
|
+
middleware, server actions, and `router.refresh()` races. Rosetta ships the runtime
|
|
564
|
+
layer too: `rosetta-i18n/locale` (core), `/react`, `/next`, `/astro`.
|
|
565
|
+
|
|
566
|
+
The model is one-directional and needs no client-side guessing:
|
|
567
|
+
|
|
568
|
+
1. a **locale-prefixed URL** (`/ja/pricing`) is authoritative,
|
|
569
|
+
2. then a **forced header** (`x-rosetta-locale`, set by middleware),
|
|
570
|
+
3. then the **cookie**,
|
|
571
|
+
4. then the **default locale**.
|
|
572
|
+
|
|
573
|
+
**Switching writes the cookie and navigates to the localized URL**, so the URL and the
|
|
574
|
+
cookie always agree. A full-page navigation makes it a single request with no
|
|
575
|
+
refresh-after-push race.
|
|
576
|
+
|
|
577
|
+
### Config (plain data)
|
|
578
|
+
|
|
579
|
+
```ts
|
|
580
|
+
// i18n/config.ts
|
|
581
|
+
import type { LocaleConfig } from "rosetta-i18n/locale";
|
|
582
|
+
|
|
583
|
+
export const localeConfig: LocaleConfig = {
|
|
584
|
+
locales: ["en", "ja", "de", "es"],
|
|
585
|
+
defaultLocale: "en",
|
|
586
|
+
// Only these paths have localized variants; everything else stays bare.
|
|
587
|
+
localizedPaths: ["/", "/pricing", "/agents"],
|
|
588
|
+
localizedPathPrefixes: ["/blog/", "/help/", "/vs/"],
|
|
589
|
+
};
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
The core also exports the pure helpers (`resolveLocale`, `stripLocale`, `localizePath`,
|
|
593
|
+
`planSwitch`, `serializeLocaleCookie`, …) if you're wiring your own framework.
|
|
594
|
+
|
|
595
|
+
### React
|
|
596
|
+
|
|
597
|
+
```tsx
|
|
598
|
+
"use client";
|
|
599
|
+
import { LocaleProvider, LocaleSwitcher } from "rosetta-i18n/react";
|
|
600
|
+
|
|
601
|
+
export function App({ locale, children }: { locale: string; children: React.ReactNode }) {
|
|
602
|
+
return (
|
|
603
|
+
<LocaleProvider config={localeConfig} locale={locale}>
|
|
604
|
+
{/* or render={({ locales, locale, switchTo, labelFor }) => …} */}
|
|
605
|
+
<LocaleSwitcher />
|
|
606
|
+
{children}
|
|
607
|
+
</LocaleProvider>
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
Hooks: `useLocale()`, `useLocales()`, `useLocaleSwitch()`, `useLocaleContext()`.
|
|
613
|
+
`switchTo(locale)` writes the cookie and navigates (full page by default; pass
|
|
614
|
+
`navigate` to hook a router, `persist` to save server-side).
|
|
615
|
+
|
|
616
|
+
### Next.js (App Router)
|
|
617
|
+
|
|
618
|
+
```ts
|
|
619
|
+
// middleware.ts
|
|
620
|
+
import { createLocaleMiddleware } from "rosetta-i18n/next";
|
|
621
|
+
import { localeConfig } from "./i18n/config";
|
|
622
|
+
|
|
623
|
+
export const middleware = createLocaleMiddleware(localeConfig);
|
|
624
|
+
export const config = { matcher: ["/((?!_next|favicon.ico|.*\\..*).*)"] };
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
```tsx
|
|
628
|
+
// app/layout.tsx
|
|
629
|
+
import { RosettaProvider } from "rosetta-i18n/next";
|
|
630
|
+
import { localeConfig } from "@/i18n/config";
|
|
631
|
+
|
|
632
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
633
|
+
return (
|
|
634
|
+
<html>
|
|
635
|
+
<body>
|
|
636
|
+
<RosettaProvider config={localeConfig}>{children}</RosettaProvider>
|
|
637
|
+
</body>
|
|
638
|
+
</html>
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
`RosettaProvider` reads the locale the middleware forced (`getServerLocale(config)`)
|
|
644
|
+
and provides it to the client. Persist the choice for signed-in users with a server
|
|
645
|
+
action:
|
|
646
|
+
|
|
647
|
+
```ts
|
|
648
|
+
// app/locale-actions.ts
|
|
649
|
+
"use server";
|
|
650
|
+
import { createSetLocaleAction } from "rosetta-i18n/next";
|
|
651
|
+
import { localeConfig } from "@/i18n/config";
|
|
652
|
+
|
|
653
|
+
export const setLocale = createSetLocaleAction(localeConfig, {
|
|
654
|
+
persist: (locale) => updateUserProfile(userId, { locale }),
|
|
655
|
+
});
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
### Astro
|
|
659
|
+
|
|
660
|
+
```ts
|
|
661
|
+
// src/middleware.ts
|
|
662
|
+
import { createAstroLocaleMiddleware } from "rosetta-i18n/astro";
|
|
663
|
+
import { localeConfig } from "../i18n/config";
|
|
664
|
+
|
|
665
|
+
export const onRequest = createAstroLocaleMiddleware(localeConfig);
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
The middleware sets `Astro.locals.locale` (read it with `getAstroLocale(Astro, config)`)
|
|
669
|
+
and rewrites prefixed URLs. For a switcher, render the React `<LocaleSwitcher>` as an
|
|
670
|
+
island wrapped in `<LocaleProvider locale={Astro.locals.locale} config={localeConfig}>`.
|
|
671
|
+
|
|
537
672
|
## Legacy `translate-catalog`
|
|
538
673
|
|
|
539
674
|
The 0.x one-shot command still works but is deprecated and will be removed in v2:
|
|
@@ -593,6 +728,9 @@ Source lives in `src/`: the translation engine in `src/rosetta.ts`, validation i
|
|
|
593
728
|
in `src/cli.ts`. `rollup` produces `dist/esm`; `tsc` emits declarations to
|
|
594
729
|
`dist/types`.
|
|
595
730
|
|
|
731
|
+
The website lives in `site/` (Astro, deployed to Cloudflare Pages); see
|
|
732
|
+
[`site/README.md`](site/README.md).
|
|
733
|
+
|
|
596
734
|
## License
|
|
597
735
|
|
|
598
736
|
MIT © Ian Hunter
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
const LOCALE_HEADER = "x-rosetta-locale";
|
|
2
|
+
const DEFAULT_LOCALE_COOKIE = "locale";
|
|
3
|
+
const ONE_YEAR_SECONDS = 60 * 60 * 24 * 365;
|
|
4
|
+
function localeCookieName(config) {
|
|
5
|
+
return config.cookieName ?? DEFAULT_LOCALE_COOKIE;
|
|
6
|
+
}
|
|
7
|
+
function localeCookieOptions(config) {
|
|
8
|
+
return {
|
|
9
|
+
path: "/",
|
|
10
|
+
maxAge: config.cookieMaxAgeSeconds ?? ONE_YEAR_SECONDS,
|
|
11
|
+
sameSite: "lax"
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
function isLocale(config, value) {
|
|
15
|
+
return typeof value === "string" && config.locales.includes(value);
|
|
16
|
+
}
|
|
17
|
+
function localeFromPath(config, pathname) {
|
|
18
|
+
const segment = pathname.split("/")[1];
|
|
19
|
+
return segment && isLocale(config, segment) ? segment : null;
|
|
20
|
+
}
|
|
21
|
+
function stripLocale(config, pathname) {
|
|
22
|
+
const locale = localeFromPath(config, pathname);
|
|
23
|
+
if (!locale) return { locale: null, path: pathname || "/" };
|
|
24
|
+
const rest = pathname.slice(locale.length + 1);
|
|
25
|
+
return { locale, path: rest || "/" };
|
|
26
|
+
}
|
|
27
|
+
function resolveLocale(config, input) {
|
|
28
|
+
const fromPath = localeFromPath(config, input.pathname);
|
|
29
|
+
if (fromPath) {
|
|
30
|
+
return {
|
|
31
|
+
locale: fromPath,
|
|
32
|
+
path: stripLocale(config, input.pathname).path,
|
|
33
|
+
source: "url"
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (isLocale(config, input.header)) {
|
|
37
|
+
return { locale: input.header, path: input.pathname, source: "header" };
|
|
38
|
+
}
|
|
39
|
+
if (isLocale(config, input.cookie)) {
|
|
40
|
+
return { locale: input.cookie, path: input.pathname, source: "cookie" };
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
locale: config.defaultLocale,
|
|
44
|
+
path: input.pathname,
|
|
45
|
+
source: "default"
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function serializeLocaleCookie(config, locale) {
|
|
49
|
+
const options = localeCookieOptions(config);
|
|
50
|
+
return `${localeCookieName(config)}=${encodeURIComponent(locale)}; Path=${options.path}; Max-Age=${options.maxAge}; SameSite=${capitalize(options.sameSite)}`;
|
|
51
|
+
}
|
|
52
|
+
function parseCookie(header, name) {
|
|
53
|
+
if (!header) return null;
|
|
54
|
+
for (const part of header.split(";")) {
|
|
55
|
+
const [key, ...rest] = part.trim().split("=");
|
|
56
|
+
if (key === name) return decodeURIComponent(rest.join("="));
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
function capitalize(value) {
|
|
61
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function createAstroLocaleMiddleware(config) {
|
|
65
|
+
return async function onRequest(context, next) {
|
|
66
|
+
const url = new URL(context.request.url);
|
|
67
|
+
const resolved = resolveLocale(config, {
|
|
68
|
+
pathname: url.pathname,
|
|
69
|
+
header: context.request.headers.get(LOCALE_HEADER),
|
|
70
|
+
cookie: parseCookie(
|
|
71
|
+
context.request.headers.get("cookie"),
|
|
72
|
+
localeCookieName(config)
|
|
73
|
+
)
|
|
74
|
+
});
|
|
75
|
+
context.locals.locale = resolved.locale;
|
|
76
|
+
if (resolved.source === "url") {
|
|
77
|
+
const target = new URL(url);
|
|
78
|
+
target.pathname = resolved.path;
|
|
79
|
+
const response2 = await context.rewrite(target);
|
|
80
|
+
response2.headers.set(LOCALE_HEADER, resolved.locale);
|
|
81
|
+
response2.headers.append(
|
|
82
|
+
"Set-Cookie",
|
|
83
|
+
serializeLocaleCookie(config, resolved.locale)
|
|
84
|
+
);
|
|
85
|
+
return response2;
|
|
86
|
+
}
|
|
87
|
+
const response = await next();
|
|
88
|
+
if (resolved.source === "cookie" || resolved.source === "default") {
|
|
89
|
+
response.headers.append(
|
|
90
|
+
"Set-Cookie",
|
|
91
|
+
serializeLocaleCookie(config, resolved.locale)
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return response;
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function getAstroLocale(context, config) {
|
|
98
|
+
const locale = context.locals.locale;
|
|
99
|
+
return isLocale(config, locale) ? locale : config.defaultLocale;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export { createAstroLocaleMiddleware, getAstroLocale };
|
|
103
|
+
//# sourceMappingURL=astro.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"astro.js","sources":["../../src/locale/core.ts","../../src/locale/astro.ts"],"sourcesContent":["/**\n * Runtime locale layer — framework-agnostic core.\n *\n * The translation engine answers \"how do I translate these strings?\". This\n * module answers \"which locale is this request in, and how do I switch?\" — the\n * routing + cookie model that the React, Next, and Astro adapters are thin\n * wrappers around.\n *\n * The config is deliberately plain data (no functions) so the exact same object\n * can cross the server → client boundary. Resolution is deterministic and\n * one-directional:\n *\n * 1. a locale-prefixed URL (`/ja/pricing`) is authoritative,\n * 2. then a forced header (`x-rosetta-locale`, set by middleware),\n * 3. then the persisted cookie,\n * 4. then the default locale.\n *\n * Switching sets the cookie *and* navigates to the localized URL, so the URL and\n * the cookie always agree. There is no client-side guessing, no\n * refresh-after-push race, and no way for a stale request to re-pin the locale.\n */\n\nexport const LOCALE_HEADER = \"x-rosetta-locale\";\nexport const DEFAULT_LOCALE_COOKIE = \"locale\";\nexport const ONE_YEAR_SECONDS = 60 * 60 * 24 * 365;\n\nexport interface LocaleConfig {\n\t/** Every locale, including the default. Order is the switcher order. */\n\tlocales: readonly string[];\n\t/** The locale served without a URL prefix. */\n\tdefaultLocale: string;\n\t/** Cookie that persists the choice. Default `\"locale\"`. */\n\tcookieName?: string;\n\t/** Cookie max-age in seconds. Default one year. */\n\tcookieMaxAgeSeconds?: number;\n\t/** Prefix the default locale too (`/en/...`). Default `false`. */\n\tprefixDefaultLocale?: boolean;\n\t/**\n\t * Canonical (unprefixed) paths that have localized variants — exact matches.\n\t * Omit both this and `localizedPathPrefixes` to localize every path.\n\t */\n\tlocalizedPaths?: readonly string[];\n\t/** Canonical paths that have localized variants — prefix matches (e.g. `/blog/`). */\n\tlocalizedPathPrefixes?: readonly string[];\n}\n\nexport type LocaleSource = \"url\" | \"header\" | \"cookie\" | \"default\";\n\nexport interface ResolvedLocale {\n\tlocale: string;\n\t/** The canonical, unprefixed path for this request. */\n\tpath: string;\n\tsource: LocaleSource;\n}\n\nexport interface LocaleCookieOptions {\n\tpath: string;\n\tmaxAge: number;\n\tsameSite: \"lax\";\n}\n\nexport interface LocaleSwitchPlan {\n\t/** Localized URL to navigate to. */\n\thref: string;\n\t/** Cookie to persist the choice. */\n\tcookie: { name: string; value: string; options: LocaleCookieOptions };\n}\n\nexport function localeCookieName(config: LocaleConfig): string {\n\treturn config.cookieName ?? DEFAULT_LOCALE_COOKIE;\n}\n\nexport function localeCookieOptions(config: LocaleConfig): LocaleCookieOptions {\n\treturn {\n\t\tpath: \"/\",\n\t\tmaxAge: config.cookieMaxAgeSeconds ?? ONE_YEAR_SECONDS,\n\t\tsameSite: \"lax\",\n\t};\n}\n\nexport function isLocale(\n\tconfig: LocaleConfig,\n\tvalue: unknown,\n): value is string {\n\treturn typeof value === \"string\" && config.locales.includes(value);\n}\n\nexport function isLocalizedPath(\n\tconfig: LocaleConfig,\n\tpathname: string,\n): boolean {\n\tconst hasRules =\n\t\t(config.localizedPaths?.length ?? 0) > 0 ||\n\t\t(config.localizedPathPrefixes?.length ?? 0) > 0;\n\tif (!hasRules) return true;\n\tif (config.localizedPaths?.includes(pathname)) return true;\n\treturn (\n\t\tconfig.localizedPathPrefixes?.some(\n\t\t\t(prefix) => pathname === prefix || pathname.startsWith(prefix),\n\t\t) ?? false\n\t);\n}\n\n/** The leading locale segment of a path, if it is one we know. */\nexport function localeFromPath(\n\tconfig: LocaleConfig,\n\tpathname: string,\n): string | null {\n\tconst segment = pathname.split(\"/\")[1];\n\treturn segment && isLocale(config, segment) ? segment : null;\n}\n\n/** Strip a leading locale segment, returning the canonical path. */\nexport function stripLocale(\n\tconfig: LocaleConfig,\n\tpathname: string,\n): { locale: string | null; path: string } {\n\tconst locale = localeFromPath(config, pathname);\n\tif (!locale) return { locale: null, path: pathname || \"/\" };\n\tconst rest = pathname.slice(locale.length + 1);\n\treturn { locale, path: rest || \"/\" };\n}\n\n/**\n * Prefix a canonical path for a locale. The default locale stays bare unless\n * `prefixDefaultLocale` is set.\n */\nexport function localizePath(\n\tconfig: LocaleConfig,\n\tpathname: string,\n\tlocale: string,\n): string {\n\tif (locale === config.defaultLocale && !config.prefixDefaultLocale) {\n\t\treturn pathname;\n\t}\n\treturn pathname === \"/\" ? `/${locale}` : `/${locale}${pathname}`;\n}\n\n/** The URL to navigate to when switching `pathname` to `locale`. */\nexport function switchHref(\n\tconfig: LocaleConfig,\n\tpathname: string,\n\tlocale: string,\n): string {\n\tconst { path } = stripLocale(config, pathname);\n\treturn localizePath(config, path, locale);\n}\n\n/**\n * Resolve the active locale for a request. Precedence: URL prefix → forced\n * header → cookie → default. `path` is always the canonical (unprefixed) path.\n */\nexport function resolveLocale(\n\tconfig: LocaleConfig,\n\tinput: { pathname: string; header?: string | null; cookie?: string | null },\n): ResolvedLocale {\n\tconst fromPath = localeFromPath(config, input.pathname);\n\tif (fromPath) {\n\t\treturn {\n\t\t\tlocale: fromPath,\n\t\t\tpath: stripLocale(config, input.pathname).path,\n\t\t\tsource: \"url\",\n\t\t};\n\t}\n\tif (isLocale(config, input.header)) {\n\t\treturn { locale: input.header, path: input.pathname, source: \"header\" };\n\t}\n\tif (isLocale(config, input.cookie)) {\n\t\treturn { locale: input.cookie, path: input.pathname, source: \"cookie\" };\n\t}\n\treturn {\n\t\tlocale: config.defaultLocale,\n\t\tpath: input.pathname,\n\t\tsource: \"default\",\n\t};\n}\n\n/** The `Set-Cookie` value that persists `locale`. */\nexport function serializeLocaleCookie(\n\tconfig: LocaleConfig,\n\tlocale: string,\n): string {\n\tconst options = localeCookieOptions(config);\n\treturn `${localeCookieName(config)}=${encodeURIComponent(locale)}; Path=${\n\t\toptions.path\n\t}; Max-Age=${options.maxAge}; SameSite=${capitalize(options.sameSite)}`;\n}\n\n/** Read a single cookie value from a `Cookie:` header. */\nexport function parseCookie(\n\theader: string | null | undefined,\n\tname: string,\n): string | null {\n\tif (!header) return null;\n\tfor (const part of header.split(\";\")) {\n\t\tconst [key, ...rest] = part.trim().split(\"=\");\n\t\tif (key === name) return decodeURIComponent(rest.join(\"=\"));\n\t}\n\treturn null;\n}\n\n/**\n * Everything a client needs to switch locale: set `cookie`, navigate to `href`.\n * Returns `null` when the current path has no localized variant (legal pages,\n * API routes, …), so the switcher can no-op instead of linking to a 404.\n */\nexport function planSwitch(\n\tconfig: LocaleConfig,\n\tpathname: string,\n\tlocale: string,\n): LocaleSwitchPlan | null {\n\tconst { path } = stripLocale(config, pathname);\n\tif (!isLocalizedPath(config, path)) return null;\n\treturn {\n\t\thref: localizePath(config, path, locale),\n\t\tcookie: {\n\t\t\tname: localeCookieName(config),\n\t\t\tvalue: locale,\n\t\t\toptions: localeCookieOptions(config),\n\t\t},\n\t};\n}\n\nfunction capitalize(value: string): string {\n\treturn value.charAt(0).toUpperCase() + value.slice(1);\n}\n","/**\n * Astro adapter.\n *\n * Astro's middleware runs on every request, so it's the natural place to\n * resolve the locale, force it onto `Astro.locals.locale`, rewrite\n * locale-prefixed URLs to their canonical path, and persist the cookie.\n *\n * ```ts\n * // src/middleware.ts\n * import { defineMiddleware } from \"astro:middleware\";\n * import { createAstroLocaleMiddleware } from \"rosetta-i18n/astro\";\n * import { localeConfig } from \"../i18n/config\";\n *\n * export const onRequest = createAstroLocaleMiddleware(localeConfig);\n * ```\n *\n * For a client switch, render the React `<LocaleSwitcher>` as an island and\n * wrap it in `<LocaleProvider locale={Astro.locals.locale} config={localeConfig}>`.\n * Types are structural, so this module never depends on `astro`.\n */\n\nimport {\n\tisLocale,\n\tLOCALE_HEADER,\n\ttype LocaleConfig,\n\tlocaleCookieName,\n\tparseCookie,\n\tresolveLocale,\n\tserializeLocaleCookie,\n} from \"./core\";\n\nexport interface AstroLocaleContext {\n\trequest: Request;\n\tlocals: Record<string, unknown>;\n\t/** Astro's `context.rewrite()`. */\n\trewrite: (url: string | URL) => Promise<Response>;\n}\n\nexport type AstroMiddlewareNext = (rewrite?: URL) => Promise<Response>;\n\nexport function createAstroLocaleMiddleware(config: LocaleConfig) {\n\treturn async function onRequest(\n\t\tcontext: AstroLocaleContext,\n\t\tnext: AstroMiddlewareNext,\n\t): Promise<Response> {\n\t\tconst url = new URL(context.request.url);\n\t\tconst resolved = resolveLocale(config, {\n\t\t\tpathname: url.pathname,\n\t\t\theader: context.request.headers.get(LOCALE_HEADER),\n\t\t\tcookie: parseCookie(\n\t\t\t\tcontext.request.headers.get(\"cookie\"),\n\t\t\t\tlocaleCookieName(config),\n\t\t\t),\n\t\t});\n\n\t\tcontext.locals.locale = resolved.locale;\n\n\t\tif (resolved.source === \"url\") {\n\t\t\tconst target = new URL(url);\n\t\t\ttarget.pathname = resolved.path;\n\t\t\tconst response = await context.rewrite(target);\n\t\t\tresponse.headers.set(LOCALE_HEADER, resolved.locale);\n\t\t\tresponse.headers.append(\n\t\t\t\t\"Set-Cookie\",\n\t\t\t\tserializeLocaleCookie(config, resolved.locale),\n\t\t\t);\n\t\t\treturn response;\n\t\t}\n\n\t\tconst response = await next();\n\t\t// Persist only when the URL can't decide it (bare path) and the cookie\n\t\t// disagrees — never on a rewrite, where the URL is authoritative.\n\t\tif (resolved.source === \"cookie\" || resolved.source === \"default\") {\n\t\t\tresponse.headers.append(\n\t\t\t\t\"Set-Cookie\",\n\t\t\t\tserializeLocaleCookie(config, resolved.locale),\n\t\t\t);\n\t\t}\n\t\treturn response;\n\t};\n}\n\n/** Read the locale the middleware stored on `Astro.locals`. */\nexport function getAstroLocale(\n\tcontext: { locals: Record<string, unknown> },\n\tconfig: LocaleConfig,\n): string {\n\tconst locale = context.locals.locale;\n\treturn isLocale(config, locale) ? locale : config.defaultLocale;\n}\n"],"names":["response"],"mappings":"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"}
|
package/dist/esm/cli.js
CHANGED
|
@@ -2539,6 +2539,7 @@ async function cmdPush(ctx) {
|
|
|
2539
2539
|
translator: io.translator,
|
|
2540
2540
|
onLocale: (locale) => printPushLocale(ctx, locale)
|
|
2541
2541
|
});
|
|
2542
|
+
warnNoMatches(ctx, result.locales.length);
|
|
2542
2543
|
if (ctx.json) {
|
|
2543
2544
|
ctx.out(JSON.stringify(result, null, 2));
|
|
2544
2545
|
} else if (estimate && result.estimate) {
|
|
@@ -2574,6 +2575,13 @@ async function cmdPush(ctx) {
|
|
|
2574
2575
|
}
|
|
2575
2576
|
return result.ok ? EXIT.ok : EXIT.failed;
|
|
2576
2577
|
}
|
|
2578
|
+
function warnNoMatches(ctx, jobs) {
|
|
2579
|
+
if (jobs === 0 && ctx.args.positionals.length > 0 && !ctx.json) {
|
|
2580
|
+
ctx.io.stderr(
|
|
2581
|
+
`warning: no configured source files match ${ctx.args.positionals.map((p) => `"${p}"`).join(", ")} (patterns match source paths like those in .rosetta/config.json files[].pattern).`
|
|
2582
|
+
);
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2577
2585
|
function printCheck(ctx, result) {
|
|
2578
2586
|
for (const locale of result.locales) {
|
|
2579
2587
|
if (locale.ok) {
|
|
@@ -2603,6 +2611,7 @@ function cmdCheck(ctx) {
|
|
|
2603
2611
|
patterns: ctx.args.positionals,
|
|
2604
2612
|
locales: list(ctx.args.flags.locale)
|
|
2605
2613
|
});
|
|
2614
|
+
warnNoMatches(ctx, result.locales.length);
|
|
2606
2615
|
if (ctx.json) ctx.out(JSON.stringify(result, null, 2));
|
|
2607
2616
|
else printCheck(ctx, result);
|
|
2608
2617
|
return result.ok ? EXIT.ok : EXIT.stale;
|