@terpjs/react-core 0.11.0 → 0.13.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.
- package/README.md +6 -5
- package/package.json +2 -2
- package/src/AppShell.test.tsx +43 -0
- package/src/AppShell.tsx +18 -14
- package/src/ConfirmDialog.tsx +4 -4
- package/src/EmptyState.tsx +8 -5
- package/src/ErrorState.tsx +8 -4
- package/src/Field.test.tsx +20 -0
- package/src/Field.tsx +2 -2
- package/src/LoginView.tsx +3 -2
- package/src/UserMenu.test.tsx +2 -2
- package/src/bootstrap.tsx +5 -2
- package/src/format.test.tsx +3 -3
- package/src/index.ts +24 -3
- package/src/locale.test.tsx +195 -3
- package/src/locale.tsx +187 -11
- package/src/nav.ts +2 -2
- package/src/router.tsx +2 -2
- package/src/sso.ts +2 -2
- package/src/styles.test.ts +32 -0
- package/src/styles.ts +58 -4
- package/src/uiText.test.tsx +15 -1
- package/src/uiText.tsx +44 -2
package/README.md
CHANGED
|
@@ -31,9 +31,10 @@ JSDoc, so your editor shows the same guidance inline. **Never deep-import** from
|
|
|
31
31
|
focus ring, a checkbox's `accent-color`. One token cannot do both: in a dark theme the
|
|
32
32
|
surface use needs a value dark enough to hold a white label and the ink use needs one light
|
|
33
33
|
enough to read on a dark canvas, and there is no value satisfying both.
|
|
34
|
-
- **
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
- **Static user-facing text is cataloged** — use an `{id, message}` `UiText`
|
|
35
|
+
descriptor for text props and `<Trans id message />` for JSX bodies. Plain strings are
|
|
36
|
+
reserved for dynamic business data, identifiers and product names. The boundary lint
|
|
37
|
+
refuses bare static copy and checks every id against every target in `frontend/i18n.json`.
|
|
37
38
|
- **Dependency-free UI** — react-core ships no icon/toast/i18n libraries. Glyphs are
|
|
38
39
|
inline SVG; transient feedback goes through `ToastProvider` / `useToast`.
|
|
39
40
|
- **Security defaults** — `dangerouslySetInnerHTML` and the DOM HTML-injection sinks
|
|
@@ -54,7 +55,7 @@ JSDoc, so your editor shows the same guidance inline. **Never deep-import** from
|
|
|
54
55
|
| `useSso`, `parseSsoCallback`, `fetchSsoAuthorizationUrl`, `completeSsoCallback` | The SSO login seam (ADR 0058): `useSso().begin(provider)` opens an OIDC flow; `TerpProvider` completes the `/auth/callback/{provider}` redirect landing into a normal session on boot. `renderTerpApp({ ssoProviders })` wires the buttons in one line. |
|
|
55
56
|
| `RequireAuth` | Renders children only with a session; pairs with the router so the app mounts only when signed in. |
|
|
56
57
|
| `ThemeProvider`, `ThemeToggle`, `useTheme` | Theming over the shipped palettes — `light`, `dark`, `midnight`, `twilight`, `contrast` — plus `system` to follow the OS preference. Applies `data-theme` on `<html>` (the token stylesheet carries every palette) and persists the choice. `defaultTheme` is how an app ships on a named theme — declare it in `layout-contract.json` so a tool can read and rewrite it, or pass the bootstrap option; both is refused. `renderTerpApp` mounts it for every app; the shell header uses an icon-only, token-themed `variant="inline"` menu. |
|
|
57
|
-
| `LocaleProvider`, `LanguageSwitcher`, `useLocale`, `LOCALE_EN`, `LOCALE_NL` | The language seam over `UiTextProvider`:
|
|
58
|
+
| `LocaleProvider`, `defineAppLocales`, `LanguageSwitcher`, `useLocale`, `LOCALE_EN`, `LOCALE_NL` | The language seam over `UiTextProvider`: `defineAppLocales(i18n, frameworkCatalogs)` validates and merges checked-in app messages with framework chrome, the active locale persists, and the shell offers a picker. Pass `sourceLocale` with `locales`; missing/empty target messages, undocumented source copies, invalid locale selection and a non-English locale without a complete framework-string catalog throw rather than silently falling back. |
|
|
58
59
|
| `UserMenu`, `userInitials` | The signed-in user's menu, pinned by `buildAppRouter` to the bottom of the sidebar: an initials avatar trigger opening the identity block, **Settings** (the built-in profile page) and sign-out. Collapses to the avatar in the icon rail. |
|
|
59
60
|
| `ProfileView` | The built-in profile / settings page (`/profile`): the server-validated identity, theme + language preferences, and sign-out. |
|
|
60
61
|
|
|
@@ -266,7 +267,7 @@ single screen by claiming its path from an app module.
|
|
|
266
267
|
|
|
267
268
|
| Export | Use |
|
|
268
269
|
|---|---|
|
|
269
|
-
| `UiTextProvider`, `useUiText`, `useStrings`, `resolveUiText`, `DEFAULT_STRINGS` | The `UiText` seam:
|
|
270
|
+
| `UiTextProvider`, `Trans`, `useUiText`, `useStrings`, `resolveUiText`, `DEFAULT_STRINGS` | The `UiText` seam: descriptors for props, `Trans` for body copy, and framework strings through one resolver. `LocaleProvider` is the batteries-included catalog layer and refuses missing target-locale entries. |
|
|
270
271
|
|
|
271
272
|
## Testing components
|
|
272
273
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@terpjs/react-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Terp React stack core — typed @terpjs/contract client provider, auth session, capability gates, TanStack Router adapter, app shell, page archetypes, DataView and token-styled UI primitives. First frontend stack; see README.md for the component catalog.",
|
|
6
6
|
"exports": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"@tanstack/react-router": "^1.170.16",
|
|
17
|
-
"@terpjs/contract": "^0.
|
|
17
|
+
"@terpjs/contract": "^0.13.0"
|
|
18
18
|
},
|
|
19
19
|
"peerDependencies": {
|
|
20
20
|
"react": "^19.0.0",
|
package/src/AppShell.test.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
4
4
|
import type { NavItem } from "@terpjs/contract";
|
|
5
5
|
|
|
6
6
|
import { AppShell, SIDEBAR_STORAGE_KEY } from "./AppShell";
|
|
7
|
+
import { LOCALE_NL, LocaleProvider } from "./locale";
|
|
7
8
|
|
|
8
9
|
afterEach(() => {
|
|
9
10
|
cleanup();
|
|
@@ -360,6 +361,48 @@ describe("AppShell navigation groups", () => {
|
|
|
360
361
|
{ label: "Loose", to: "/loose" },
|
|
361
362
|
];
|
|
362
363
|
|
|
364
|
+
it("resolves localized item and group descriptors before rendering navigation", () => {
|
|
365
|
+
render(
|
|
366
|
+
<LocaleProvider
|
|
367
|
+
locales={{
|
|
368
|
+
en: {},
|
|
369
|
+
nl: {
|
|
370
|
+
...LOCALE_NL,
|
|
371
|
+
messages: {
|
|
372
|
+
"nav.work": "Werkruimte",
|
|
373
|
+
"nav.notes": "Notities",
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
}}
|
|
377
|
+
defaultLocale="nl"
|
|
378
|
+
sourceLocale="en"
|
|
379
|
+
>
|
|
380
|
+
<AppShell
|
|
381
|
+
title="Terp"
|
|
382
|
+
nav={[
|
|
383
|
+
{
|
|
384
|
+
label: { id: "nav.notes", message: "Notes" },
|
|
385
|
+
to: "/notes",
|
|
386
|
+
group: "work",
|
|
387
|
+
},
|
|
388
|
+
]}
|
|
389
|
+
navGroups={[
|
|
390
|
+
{
|
|
391
|
+
id: "work",
|
|
392
|
+
label: { id: "nav.work", message: "Workspace" },
|
|
393
|
+
},
|
|
394
|
+
]}
|
|
395
|
+
renderLink={(item, children) => <a href={item.to}>{children}</a>}
|
|
396
|
+
>
|
|
397
|
+
<p>page content</p>
|
|
398
|
+
</AppShell>
|
|
399
|
+
</LocaleProvider>,
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
expect(screen.getByRole("list", { name: "Werkruimte" })).toBeInTheDocument();
|
|
403
|
+
expect(screen.getByRole("link", { name: "Notities" })).toHaveAttribute("href", "/notes");
|
|
404
|
+
});
|
|
405
|
+
|
|
363
406
|
it("labels each group's list with its own visible label", () => {
|
|
364
407
|
render(
|
|
365
408
|
<AppShell
|
package/src/AppShell.tsx
CHANGED
|
@@ -477,11 +477,12 @@ export function AppShell({
|
|
|
477
477
|
onClick={isMobile ? closeDrawer : undefined}
|
|
478
478
|
>
|
|
479
479
|
{groupNav(nav, navGroups).map((section, index) => {
|
|
480
|
+
const groupLabel = section.label === null ? null : resolve(section.label);
|
|
480
481
|
// Only a labelled section needs an id, and only a DECLARED section can be labelled — the
|
|
481
482
|
// default one has no declaration to carry a label. Keyed on the index rather than on
|
|
482
483
|
// `section.id`: a group id is an app-supplied string, and whitespace in one would
|
|
483
484
|
// silently break the IDREF rather than fail anywhere.
|
|
484
|
-
const labelId =
|
|
485
|
+
const labelId = groupLabel === null ? undefined : `${navGroupId}-${index}`;
|
|
485
486
|
return (
|
|
486
487
|
// No heading element, and this is the decision rather than an oversight. `Heading`
|
|
487
488
|
// refuses level 1 to reserve it for the routed view's title (see typography.tsx), and
|
|
@@ -504,22 +505,25 @@ export function AppShell({
|
|
|
504
505
|
<div key={index} data-terp="appshell-nav-group">
|
|
505
506
|
{labelId !== undefined && (
|
|
506
507
|
<span id={labelId} data-terp="appshell-nav-group-label">
|
|
507
|
-
{
|
|
508
|
+
{groupLabel}
|
|
508
509
|
</span>
|
|
509
510
|
)}
|
|
510
511
|
<ul data-terp="appshell-nav-list" aria-labelledby={labelId}>
|
|
511
|
-
{section.items.map((item) =>
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
512
|
+
{section.items.map((item) => {
|
|
513
|
+
const label = resolve(item.label);
|
|
514
|
+
return (
|
|
515
|
+
<li key={item.to} title={railCollapsed ? label : undefined}>
|
|
516
|
+
{renderLink(
|
|
517
|
+
item,
|
|
518
|
+
<>
|
|
519
|
+
<NavIcon name={item.icon} label={label} />
|
|
520
|
+
<span data-terp="appshell-nav-label">{label}</span>
|
|
521
|
+
</>,
|
|
522
|
+
{ collapsed: railCollapsed, active: item.to === currentTo },
|
|
523
|
+
)}
|
|
524
|
+
</li>
|
|
525
|
+
);
|
|
526
|
+
})}
|
|
523
527
|
</ul>
|
|
524
528
|
</div>
|
|
525
529
|
);
|
package/src/ConfirmDialog.tsx
CHANGED
|
@@ -3,8 +3,8 @@ import type { ReactNode } from "react";
|
|
|
3
3
|
|
|
4
4
|
import { Button } from "./ui/Button";
|
|
5
5
|
import { injectTerpStyles } from "./styles";
|
|
6
|
-
import { useStrings, useUiText } from "./uiText";
|
|
7
|
-
import type { UiText } from "./uiText";
|
|
6
|
+
import { resolveUiTextNode, useStrings, useUiText } from "./uiText";
|
|
7
|
+
import type { UiText, UiTextNode } from "./uiText";
|
|
8
8
|
|
|
9
9
|
injectTerpStyles();
|
|
10
10
|
|
|
@@ -18,7 +18,7 @@ export interface ConfirmDialogProps {
|
|
|
18
18
|
/** Short question — what is about to happen. */
|
|
19
19
|
title: UiText;
|
|
20
20
|
/** Optional consequence explanation. */
|
|
21
|
-
description?:
|
|
21
|
+
description?: UiTextNode;
|
|
22
22
|
/** Confirm-button label; defaults to the `confirm` string. */
|
|
23
23
|
confirmLabel?: UiText;
|
|
24
24
|
/** Cancel-button label; defaults to the `cancel` string. */
|
|
@@ -118,7 +118,7 @@ export function ConfirmDialog({
|
|
|
118
118
|
</h2>
|
|
119
119
|
{description !== undefined && (
|
|
120
120
|
<div id={descriptionId} data-terp="dialog-description">
|
|
121
|
-
{description}
|
|
121
|
+
{resolveUiTextNode(description, resolve)}
|
|
122
122
|
</div>
|
|
123
123
|
)}
|
|
124
124
|
<div data-terp="dialog-actions">
|
package/src/EmptyState.tsx
CHANGED
|
@@ -2,8 +2,8 @@ import type { ReactNode } from "react";
|
|
|
2
2
|
|
|
3
3
|
import { Icon } from "./icons";
|
|
4
4
|
import { injectTerpStyles } from "./styles";
|
|
5
|
-
import { useUiText } from "./uiText";
|
|
6
|
-
import type { UiText } from "./uiText";
|
|
5
|
+
import { resolveUiTextNode, useUiText } from "./uiText";
|
|
6
|
+
import type { UiText, UiTextNode } from "./uiText";
|
|
7
7
|
|
|
8
8
|
injectTerpStyles();
|
|
9
9
|
|
|
@@ -16,7 +16,7 @@ export interface EmptyStateProps {
|
|
|
16
16
|
/** Short title — what is missing. */
|
|
17
17
|
title: UiText;
|
|
18
18
|
/** Optional explanation — why it's missing, or what to do next. */
|
|
19
|
-
description?:
|
|
19
|
+
description?: UiTextNode;
|
|
20
20
|
/** Optional call to action (typically a `Button`). */
|
|
21
21
|
action?: ReactNode;
|
|
22
22
|
/**
|
|
@@ -58,9 +58,12 @@ export function EmptyState({
|
|
|
58
58
|
<div data-terp="empty-state" data-size={compact ? "compact" : undefined}>
|
|
59
59
|
{leading}
|
|
60
60
|
<p data-terp="empty-state-title">{resolve(title)}</p>
|
|
61
|
-
{description !== undefined &&
|
|
61
|
+
{description !== undefined && (
|
|
62
|
+
<div data-terp="empty-state-description">
|
|
63
|
+
{resolveUiTextNode(description, resolve)}
|
|
64
|
+
</div>
|
|
65
|
+
)}
|
|
62
66
|
{action}
|
|
63
67
|
</div>
|
|
64
68
|
);
|
|
65
69
|
}
|
|
66
|
-
|
package/src/ErrorState.tsx
CHANGED
|
@@ -3,8 +3,8 @@ import type { ReactNode } from "react";
|
|
|
3
3
|
import { useErrorMessage } from "./errorMessages";
|
|
4
4
|
import { Icon } from "./icons";
|
|
5
5
|
import { injectTerpStyles } from "./styles";
|
|
6
|
-
import { useStrings, useUiText } from "./uiText";
|
|
7
|
-
import type { UiText } from "./uiText";
|
|
6
|
+
import { resolveUiTextNode, useStrings, useUiText } from "./uiText";
|
|
7
|
+
import type { UiText, UiTextNode } from "./uiText";
|
|
8
8
|
|
|
9
9
|
injectTerpStyles();
|
|
10
10
|
|
|
@@ -43,7 +43,7 @@ export interface ErrorStateProps {
|
|
|
43
43
|
* copy for the error's stable `code` (see `useErrorMessage`), falling back to
|
|
44
44
|
* {@link describeError}, so the platform error envelope surfaces consistently.
|
|
45
45
|
*/
|
|
46
|
-
description?:
|
|
46
|
+
description?: UiTextNode;
|
|
47
47
|
/** The caught failure — used to derive `description` when none is given. */
|
|
48
48
|
error?: unknown;
|
|
49
49
|
/** Optional call to action (typically a retry `Button`). */
|
|
@@ -73,7 +73,11 @@ export function ErrorState({ icon, title, description, error, action }: ErrorSta
|
|
|
73
73
|
<div role="alert" data-terp="error-state">
|
|
74
74
|
{leading}
|
|
75
75
|
<p data-terp="error-state-title">{resolve(title ?? strings.errorTitle)}</p>
|
|
76
|
-
{message !== null && message !== undefined &&
|
|
76
|
+
{message !== null && message !== undefined && (
|
|
77
|
+
<div data-terp="error-state-description">
|
|
78
|
+
{resolveUiTextNode(message, resolve)}
|
|
79
|
+
</div>
|
|
80
|
+
)}
|
|
77
81
|
{action}
|
|
78
82
|
</div>
|
|
79
83
|
);
|
package/src/Field.test.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { cleanup, render, screen } from "@testing-library/react";
|
|
|
3
3
|
import { afterEach, describe, expect, it } from "vitest";
|
|
4
4
|
|
|
5
5
|
import { Field } from "./Field";
|
|
6
|
+
import { UiTextProvider } from "./uiText";
|
|
6
7
|
import { Input } from "./ui/Input";
|
|
7
8
|
import { Select } from "./ui/Select";
|
|
8
9
|
import { Textarea } from "./ui/Textarea";
|
|
@@ -10,6 +11,25 @@ import { Textarea } from "./ui/Textarea";
|
|
|
10
11
|
afterEach(cleanup);
|
|
11
12
|
|
|
12
13
|
describe("Field", () => {
|
|
14
|
+
it("resolves a descriptor used as helper text", () => {
|
|
15
|
+
render(
|
|
16
|
+
<UiTextProvider
|
|
17
|
+
resolveText={(text) =>
|
|
18
|
+
typeof text === "string" ? text : `translated:${text.id}`
|
|
19
|
+
}
|
|
20
|
+
>
|
|
21
|
+
<Field
|
|
22
|
+
label="Email"
|
|
23
|
+
hint={{ id: "account.email.hint", message: "We never share it" }}
|
|
24
|
+
>
|
|
25
|
+
<Input />
|
|
26
|
+
</Field>
|
|
27
|
+
</UiTextProvider>,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
expect(screen.getByText("translated:account.email.hint")).toBeInTheDocument();
|
|
31
|
+
});
|
|
32
|
+
|
|
13
33
|
it("labels its control (accessible association) and shows hint + error", () => {
|
|
14
34
|
render(
|
|
15
35
|
<Field label="Email" hint="we never share it" error="required">
|
package/src/Field.tsx
CHANGED
|
@@ -15,7 +15,7 @@ export interface FieldProps {
|
|
|
15
15
|
/** A field-level error (e.g. mapped from a 422), shown under the control. */
|
|
16
16
|
error?: string | null;
|
|
17
17
|
/** Optional helper text under the control. */
|
|
18
|
-
hint?:
|
|
18
|
+
hint?: UiText;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/**
|
|
@@ -90,7 +90,7 @@ export function Field({ label, children, error, hint }: FieldProps) {
|
|
|
90
90
|
</label>
|
|
91
91
|
{hint !== undefined && (
|
|
92
92
|
<span id={hintId} data-terp="field-hint">
|
|
93
|
-
{hint}
|
|
93
|
+
{resolve(hint)}
|
|
94
94
|
</span>
|
|
95
95
|
)}
|
|
96
96
|
{hasError && (
|
package/src/LoginView.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import type { FormEvent } from "react";
|
|
|
3
3
|
|
|
4
4
|
import { TerpMark } from "./icons";
|
|
5
5
|
import { useAuth, useSso } from "./TerpProvider";
|
|
6
|
-
import { useStrings } from "./uiText";
|
|
6
|
+
import { useStrings, useUiText } from "./uiText";
|
|
7
7
|
import { Button } from "./ui/Button";
|
|
8
8
|
import { Field } from "./Field";
|
|
9
9
|
import { Input } from "./ui/Input";
|
|
@@ -53,6 +53,7 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
|
|
|
53
53
|
const auth = useAuth();
|
|
54
54
|
const sso = useSso();
|
|
55
55
|
const strings = useStrings();
|
|
56
|
+
const resolve = useUiText();
|
|
56
57
|
const [email, setEmail] = useState("");
|
|
57
58
|
const [password, setPassword] = useState("");
|
|
58
59
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -158,7 +159,7 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
|
|
|
158
159
|
disabled={busy}
|
|
159
160
|
onClick={() => void onSso(provider)}
|
|
160
161
|
>
|
|
161
|
-
{`${strings.continueWith} ${provider.label ?? provider.name}`}
|
|
162
|
+
{`${strings.continueWith} ${resolve(provider.label ?? provider.name)}`}
|
|
162
163
|
</Button>
|
|
163
164
|
))}
|
|
164
165
|
</div>
|
package/src/UserMenu.test.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
5
5
|
|
|
6
6
|
import { TerpProvider, useAuth } from "./TerpProvider";
|
|
7
7
|
import { UserMenu, userInitials } from "./UserMenu";
|
|
8
|
-
import { LOCALE_EN, LocaleProvider } from "./locale";
|
|
8
|
+
import { LOCALE_EN, LOCALE_NL, LocaleProvider } from "./locale";
|
|
9
9
|
import { ThemeProvider } from "./theme";
|
|
10
10
|
|
|
11
11
|
function jsonResponse(body: unknown): Response {
|
|
@@ -73,7 +73,7 @@ describe("UserMenu", () => {
|
|
|
73
73
|
stubAuthFetch();
|
|
74
74
|
render(
|
|
75
75
|
<ThemeProvider>
|
|
76
|
-
<LocaleProvider locales={{ en: LOCALE_EN, nl:
|
|
76
|
+
<LocaleProvider locales={{ en: LOCALE_EN, nl: LOCALE_NL }}>
|
|
77
77
|
<TerpProvider baseUrl="https://api.test">
|
|
78
78
|
<LogInOnMount />
|
|
79
79
|
<UserMenu />
|
package/src/bootstrap.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import { RouterProvider } from "@tanstack/react-router";
|
|
|
2
2
|
import { StrictMode } from "react";
|
|
3
3
|
import type { ComponentType, ReactNode } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
|
-
import type { ModuleManifest, NavGroup } from "@terpjs/contract";
|
|
5
|
+
import type { ModuleManifest, NavGroup, UiText } from "@terpjs/contract";
|
|
6
6
|
|
|
7
7
|
import { LoginView } from "./LoginView";
|
|
8
8
|
import type { DevCredentials } from "./LoginView";
|
|
@@ -95,7 +95,7 @@ export function collectModules(modules: Record<string, unknown>): {
|
|
|
95
95
|
|
|
96
96
|
export interface RenderTerpAppOptions {
|
|
97
97
|
/** App title shown in the shell's sidebar brand (and the default footer). */
|
|
98
|
-
title:
|
|
98
|
+
title: UiText;
|
|
99
99
|
/** Discovered modules from an import.meta.glob over "./modules/<name>/module.tsx" (eager). */
|
|
100
100
|
modules: Record<string, unknown>;
|
|
101
101
|
/** Brand mark in the sidebar (any rendered node); default: the placeholder TerpMark. */
|
|
@@ -187,6 +187,8 @@ export interface RenderTerpAppOptions {
|
|
|
187
187
|
locales?: Record<string, LocaleCatalog>;
|
|
188
188
|
/** Starting locale when the user has not chosen one; default: the first `locales` key. */
|
|
189
189
|
defaultLocale?: string;
|
|
190
|
+
/** Source locale for app-authored UiText descriptors; default: the first locale key. */
|
|
191
|
+
sourceLocale?: string;
|
|
190
192
|
/**
|
|
191
193
|
* Starting theme when the user has not chosen one; default `"system"` (OS preference).
|
|
192
194
|
*
|
|
@@ -362,6 +364,7 @@ export function renderTerpApp(options: RenderTerpAppOptions): void {
|
|
|
362
364
|
<LocaleProvider
|
|
363
365
|
locales={options.locales ?? { en: {} }}
|
|
364
366
|
defaultLocale={options.defaultLocale}
|
|
367
|
+
sourceLocale={options.sourceLocale}
|
|
365
368
|
>
|
|
366
369
|
<TerpProvider baseUrl={options.baseUrl ?? ""} ssoCallbackPath={options.ssoCallbackPath}>
|
|
367
370
|
<ToastProvider>
|
package/src/format.test.tsx
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
useFormatDate,
|
|
14
14
|
useFormatNumber,
|
|
15
15
|
} from "./format";
|
|
16
|
-
import { LocaleProvider } from "./locale";
|
|
16
|
+
import { LOCALE_EN, LOCALE_NL, LocaleProvider } from "./locale";
|
|
17
17
|
|
|
18
18
|
afterEach(() => {
|
|
19
19
|
cleanup();
|
|
@@ -27,8 +27,8 @@ afterEach(() => {
|
|
|
27
27
|
// version asserted the literal digit 7 and would have failed in Kiritimati and nowhere else.
|
|
28
28
|
const WHEN = "2026-07-07T12:00:00Z";
|
|
29
29
|
|
|
30
|
-
const NL =
|
|
31
|
-
const EN =
|
|
30
|
+
const NL = LOCALE_NL;
|
|
31
|
+
const EN = LOCALE_EN;
|
|
32
32
|
|
|
33
33
|
describe("the locale-explicit formatters", () => {
|
|
34
34
|
it("actually varies with the locale it is given", () => {
|
package/src/index.ts
CHANGED
|
@@ -87,8 +87,23 @@ export type {
|
|
|
87
87
|
} from "./SplitPage";
|
|
88
88
|
export { HubPage, HubCard } from "./HubPage";
|
|
89
89
|
export type { HubPageProps, HubCardProps, RenderHubCardLink } from "./HubPage";
|
|
90
|
-
export {
|
|
91
|
-
|
|
90
|
+
export {
|
|
91
|
+
UiTextProvider,
|
|
92
|
+
Trans,
|
|
93
|
+
useStrings,
|
|
94
|
+
useUiText,
|
|
95
|
+
resolveUiText,
|
|
96
|
+
resolveUiTextNode,
|
|
97
|
+
DEFAULT_STRINGS,
|
|
98
|
+
} from "./uiText";
|
|
99
|
+
export type {
|
|
100
|
+
UiText,
|
|
101
|
+
UiTextNode,
|
|
102
|
+
ResolveUiText,
|
|
103
|
+
TerpStrings,
|
|
104
|
+
TransProps,
|
|
105
|
+
UiTextProviderProps,
|
|
106
|
+
} from "./uiText";
|
|
92
107
|
export { EmptyState } from "./EmptyState";
|
|
93
108
|
export type { EmptyStateProps } from "./EmptyState";
|
|
94
109
|
export { ErrorState, describeError } from "./ErrorState";
|
|
@@ -216,13 +231,19 @@ export { ThemeProvider, ThemeToggle, useTheme, THEME_STORAGE_KEY } from "./theme
|
|
|
216
231
|
export type { Theme, ThemeProviderProps, ThemeToggleProps } from "./theme";
|
|
217
232
|
export {
|
|
218
233
|
LocaleProvider,
|
|
234
|
+
defineAppLocales,
|
|
219
235
|
LanguageSwitcher,
|
|
220
236
|
useLocale,
|
|
221
237
|
LOCALE_EN,
|
|
222
238
|
LOCALE_NL,
|
|
223
239
|
LOCALE_STORAGE_KEY,
|
|
224
240
|
} from "./locale";
|
|
225
|
-
export type {
|
|
241
|
+
export type {
|
|
242
|
+
AppI18nDeclaration,
|
|
243
|
+
LocaleCatalog,
|
|
244
|
+
LocaleProviderProps,
|
|
245
|
+
LanguageSwitcherProps,
|
|
246
|
+
} from "./locale";
|
|
226
247
|
export { UserMenu, userInitials } from "./UserMenu";
|
|
227
248
|
export type { UserMenuProps } from "./UserMenu";
|
|
228
249
|
|
package/src/locale.test.tsx
CHANGED
|
@@ -2,8 +2,15 @@
|
|
|
2
2
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
3
3
|
import { afterEach, describe, expect, it } from "vitest";
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
|
|
5
|
+
import {
|
|
6
|
+
LOCALE_EN,
|
|
7
|
+
LOCALE_NL,
|
|
8
|
+
LOCALE_STORAGE_KEY,
|
|
9
|
+
LanguageSwitcher,
|
|
10
|
+
LocaleProvider,
|
|
11
|
+
defineAppLocales,
|
|
12
|
+
} from "./locale";
|
|
13
|
+
import { DEFAULT_STRINGS, Trans, useStrings } from "./uiText";
|
|
7
14
|
|
|
8
15
|
afterEach(() => {
|
|
9
16
|
cleanup();
|
|
@@ -14,7 +21,7 @@ function SignOutLabel() {
|
|
|
14
21
|
return <p>{useStrings().signOut}</p>;
|
|
15
22
|
}
|
|
16
23
|
|
|
17
|
-
const NL =
|
|
24
|
+
const NL = LOCALE_NL;
|
|
18
25
|
|
|
19
26
|
describe("LocaleProvider + LanguageSwitcher", () => {
|
|
20
27
|
it("feeds the active catalog's overrides through the UiText seam", () => {
|
|
@@ -84,6 +91,191 @@ describe("LocaleProvider + LanguageSwitcher", () => {
|
|
|
84
91
|
// No visible label text in the inline variant.
|
|
85
92
|
expect(screen.queryByText("Language")).not.toBeInTheDocument();
|
|
86
93
|
});
|
|
94
|
+
|
|
95
|
+
it("resolves app descriptors through the active locale catalog", () => {
|
|
96
|
+
render(
|
|
97
|
+
<LocaleProvider
|
|
98
|
+
locales={{ en: LOCALE_EN, nl: { ...NL, messages: { greeting: "Hallo" } } }}
|
|
99
|
+
defaultLocale="nl"
|
|
100
|
+
sourceLocale="en"
|
|
101
|
+
>
|
|
102
|
+
<Trans id="greeting" message="Hello" />
|
|
103
|
+
</LocaleProvider>,
|
|
104
|
+
);
|
|
105
|
+
expect(screen.getByText("Hallo")).toBeInTheDocument();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("refuses a missing target translation instead of silently using source copy", () => {
|
|
109
|
+
expect(() =>
|
|
110
|
+
render(
|
|
111
|
+
<LocaleProvider locales={{ en: LOCALE_EN, nl: NL }} defaultLocale="nl" sourceLocale="en">
|
|
112
|
+
<Trans id="greeting" message="Hello" />
|
|
113
|
+
</LocaleProvider>,
|
|
114
|
+
),
|
|
115
|
+
).toThrow(/Missing translation "greeting" for locale "nl"/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("refuses a copied source translation unless allowIdentical documents it", () => {
|
|
119
|
+
expect(() =>
|
|
120
|
+
render(
|
|
121
|
+
<LocaleProvider
|
|
122
|
+
locales={{ en: {}, nl: { ...LOCALE_NL, messages: { greeting: "Hello" } } }}
|
|
123
|
+
defaultLocale="nl"
|
|
124
|
+
sourceLocale="en"
|
|
125
|
+
>
|
|
126
|
+
<Trans id="greeting" message="Hello" />
|
|
127
|
+
</LocaleProvider>,
|
|
128
|
+
),
|
|
129
|
+
).toThrow(/copies its source text/);
|
|
130
|
+
|
|
131
|
+
render(
|
|
132
|
+
<LocaleProvider
|
|
133
|
+
locales={{
|
|
134
|
+
en: {},
|
|
135
|
+
nl: {
|
|
136
|
+
...LOCALE_NL,
|
|
137
|
+
messages: { greeting: "Hello" },
|
|
138
|
+
allowIdentical: ["greeting"],
|
|
139
|
+
},
|
|
140
|
+
}}
|
|
141
|
+
defaultLocale="nl"
|
|
142
|
+
sourceLocale="en"
|
|
143
|
+
>
|
|
144
|
+
<Trans id="greeting" message="Hello" />
|
|
145
|
+
</LocaleProvider>,
|
|
146
|
+
);
|
|
147
|
+
expect(screen.getByText("Hello")).toBeInTheDocument();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("refuses malformed locale configuration and descriptors", () => {
|
|
151
|
+
expect(() =>
|
|
152
|
+
render(
|
|
153
|
+
<LocaleProvider locales={{ en: {} }} sourceLocale="nl">
|
|
154
|
+
<span />
|
|
155
|
+
</LocaleProvider>,
|
|
156
|
+
),
|
|
157
|
+
).toThrow(/Source locale "nl" is not present/);
|
|
158
|
+
expect(() =>
|
|
159
|
+
render(
|
|
160
|
+
<LocaleProvider locales={{ en: {} }} defaultLocale="nl">
|
|
161
|
+
<span />
|
|
162
|
+
</LocaleProvider>,
|
|
163
|
+
),
|
|
164
|
+
).toThrow(/Default locale "nl" is not present/);
|
|
165
|
+
expect(() =>
|
|
166
|
+
render(
|
|
167
|
+
<LocaleProvider locales={{ en: {} }}>
|
|
168
|
+
<Trans id="" message="Hello" />
|
|
169
|
+
</LocaleProvider>,
|
|
170
|
+
),
|
|
171
|
+
).toThrow(/non-empty id and message/);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("merges checked-in app messages with framework catalogs", () => {
|
|
175
|
+
expect(
|
|
176
|
+
defineAppLocales(
|
|
177
|
+
{ sourceLocale: "nl", locales: { nl: {}, en: { messages: { greeting: "Hello" } } } },
|
|
178
|
+
{ en: LOCALE_EN, nl: LOCALE_NL },
|
|
179
|
+
).en.messages,
|
|
180
|
+
).toEqual({ greeting: "Hello" });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("validates the checked-in declaration before merging it", () => {
|
|
184
|
+
expect(() =>
|
|
185
|
+
defineAppLocales({ sourceLocale: "nl", locales: { en: {} } }),
|
|
186
|
+
).toThrow(/Source locale "nl" is not present/);
|
|
187
|
+
expect(() =>
|
|
188
|
+
defineAppLocales({
|
|
189
|
+
sourceLocale: "nl",
|
|
190
|
+
locales: { nl: {}, en: { messages: { greeting: "" } } },
|
|
191
|
+
}),
|
|
192
|
+
).toThrow(/empty or invalid message entry/);
|
|
193
|
+
expect(() =>
|
|
194
|
+
defineAppLocales(
|
|
195
|
+
{ sourceLocale: "en", locales: { en: {} } },
|
|
196
|
+
{ en: { strings: [] as never } },
|
|
197
|
+
),
|
|
198
|
+
).toThrow(/framework strings must be an object/);
|
|
199
|
+
expect(() =>
|
|
200
|
+
defineAppLocales(
|
|
201
|
+
{ sourceLocale: "en", locales: { en: {} } },
|
|
202
|
+
"invalid" as never,
|
|
203
|
+
),
|
|
204
|
+
).toThrow(/Framework locale catalogs must be an object/);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("refuses incomplete framework catalogs through LocaleProvider itself", () => {
|
|
208
|
+
expect(() =>
|
|
209
|
+
render(
|
|
210
|
+
<LocaleProvider locales={{ en: LOCALE_EN, de: { messages: { greeting: "Hallo" } } }}>
|
|
211
|
+
<span />
|
|
212
|
+
</LocaleProvider>,
|
|
213
|
+
),
|
|
214
|
+
).toThrow(/missing .* framework string translation/);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("refuses malformed labels and supplied framework strings", () => {
|
|
218
|
+
expect(() =>
|
|
219
|
+
render(
|
|
220
|
+
<LocaleProvider locales={{ en: { label: "" } }}>
|
|
221
|
+
<span />
|
|
222
|
+
</LocaleProvider>,
|
|
223
|
+
),
|
|
224
|
+
).toThrow(/label must be a non-empty string/);
|
|
225
|
+
expect(() =>
|
|
226
|
+
render(
|
|
227
|
+
<LocaleProvider
|
|
228
|
+
locales={{ en: { strings: { ...LOCALE_NL.strings, signOut: "" } } }}
|
|
229
|
+
>
|
|
230
|
+
<span />
|
|
231
|
+
</LocaleProvider>,
|
|
232
|
+
),
|
|
233
|
+
).toThrow(/empty or invalid framework string "signOut"/);
|
|
234
|
+
expect(() =>
|
|
235
|
+
render(
|
|
236
|
+
<LocaleProvider locales={{ en: { strings: "invalid" as never } }}>
|
|
237
|
+
<span />
|
|
238
|
+
</LocaleProvider>,
|
|
239
|
+
),
|
|
240
|
+
).toThrow(/framework strings must be an object/);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("refuses a target locale whose app copy is translated but framework chrome is not", () => {
|
|
244
|
+
expect(() =>
|
|
245
|
+
defineAppLocales(
|
|
246
|
+
{
|
|
247
|
+
sourceLocale: "en",
|
|
248
|
+
locales: { en: {}, de: { messages: { greeting: "Hallo" } } },
|
|
249
|
+
},
|
|
250
|
+
{ en: LOCALE_EN },
|
|
251
|
+
),
|
|
252
|
+
).toThrow(/missing .* framework string translation/);
|
|
253
|
+
|
|
254
|
+
const germanStrings = Object.fromEntries(
|
|
255
|
+
Object.keys(DEFAULT_STRINGS).map((key) => [key, `de:${key}`]),
|
|
256
|
+
);
|
|
257
|
+
expect(
|
|
258
|
+
defineAppLocales(
|
|
259
|
+
{
|
|
260
|
+
sourceLocale: "en",
|
|
261
|
+
locales: { en: {}, de: { messages: { greeting: "Hallo" } } },
|
|
262
|
+
},
|
|
263
|
+
{ en: LOCALE_EN, de: { strings: germanStrings } },
|
|
264
|
+
).de.messages,
|
|
265
|
+
).toEqual({ greeting: "Hallo" });
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("always renders the descriptor fallback in the source locale", () => {
|
|
269
|
+
render(
|
|
270
|
+
<LocaleProvider
|
|
271
|
+
locales={{ en: { messages: { greeting: "stale catalog value" } } }}
|
|
272
|
+
sourceLocale="en"
|
|
273
|
+
>
|
|
274
|
+
<Trans id="greeting" message="Hello" />
|
|
275
|
+
</LocaleProvider>,
|
|
276
|
+
);
|
|
277
|
+
expect(screen.getByText("Hello")).toBeInTheDocument();
|
|
278
|
+
});
|
|
87
279
|
});
|
|
88
280
|
|
|
89
281
|
describe("LOCALE_NL", () => {
|
package/src/locale.tsx
CHANGED
|
@@ -1,21 +1,153 @@
|
|
|
1
1
|
import { createContext, useCallback, useContext, useMemo, useState } from "react";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
|
+
import type { UiText } from "@terpjs/contract";
|
|
3
4
|
|
|
4
5
|
import { Icon } from "./icons";
|
|
5
6
|
import { Menu, MenuItem } from "./ui/Menu";
|
|
6
|
-
import { UiTextProvider, useStrings } from "./uiText";
|
|
7
|
+
import { DEFAULT_STRINGS, UiTextProvider, useStrings } from "./uiText";
|
|
7
8
|
import type { TerpStrings } from "./uiText";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
|
-
* One locale's catalog:
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* One locale's catalog: framework strings, app messages, and an optional native display
|
|
12
|
+
* name for language pickers. English locales may omit `strings` because react-core's
|
|
13
|
+
* bundled defaults are English; every declared non-English locale must supply the complete
|
|
14
|
+
* `TerpStrings` set so framework chrome cannot silently fall back to English.
|
|
13
15
|
*/
|
|
14
16
|
export interface LocaleCatalog {
|
|
15
17
|
/** Native display name shown by {@link LanguageSwitcher} (default: the locale code). */
|
|
16
18
|
label?: string;
|
|
17
19
|
/** Framework-string overrides for this locale. */
|
|
18
20
|
strings?: Partial<TerpStrings>;
|
|
21
|
+
/** App-authored messages, keyed by the stable id carried by a `UiText` descriptor. */
|
|
22
|
+
messages?: Record<string, string>;
|
|
23
|
+
/** Message ids intentionally identical to their source copy (catalog-gate documentation). */
|
|
24
|
+
allowIdentical?: readonly string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Checked-in, JSON-compatible app declaration consumed by {@link defineAppLocales}. */
|
|
28
|
+
export interface AppI18nDeclaration {
|
|
29
|
+
sourceLocale: string;
|
|
30
|
+
locales: Record<string, LocaleCatalog>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
34
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function assertLocaleCatalogs(
|
|
38
|
+
locales: unknown,
|
|
39
|
+
sourceLocale?: string,
|
|
40
|
+
): asserts locales is Record<string, LocaleCatalog> {
|
|
41
|
+
if (!isRecord(locales) || Object.keys(locales).length === 0) {
|
|
42
|
+
throw new Error("Locale catalogs must declare at least one locale.");
|
|
43
|
+
}
|
|
44
|
+
if (
|
|
45
|
+
sourceLocale !== undefined &&
|
|
46
|
+
(sourceLocale.trim() === "" || !Object.hasOwn(locales, sourceLocale))
|
|
47
|
+
) {
|
|
48
|
+
throw new Error(`Source locale "${sourceLocale}" is not present in the locale catalogs.`);
|
|
49
|
+
}
|
|
50
|
+
for (const [code, value] of Object.entries(locales)) {
|
|
51
|
+
if (code.trim() === "" || !isRecord(value)) {
|
|
52
|
+
throw new Error("Locale entries must be non-empty codes mapped to catalog objects.");
|
|
53
|
+
}
|
|
54
|
+
if (
|
|
55
|
+
value.label !== undefined &&
|
|
56
|
+
(typeof value.label !== "string" || value.label.trim() === "")
|
|
57
|
+
) {
|
|
58
|
+
throw new Error(`Locale "${code}" label must be a non-empty string.`);
|
|
59
|
+
}
|
|
60
|
+
if (value.strings !== undefined && !isRecord(value.strings)) {
|
|
61
|
+
throw new Error(`Locale "${code}" framework strings must be an object.`);
|
|
62
|
+
}
|
|
63
|
+
for (const [key, translated] of Object.entries(value.strings ?? {})) {
|
|
64
|
+
if (!Object.hasOwn(DEFAULT_STRINGS, key)) {
|
|
65
|
+
throw new Error(`Locale "${code}" has unknown framework string "${key}".`);
|
|
66
|
+
}
|
|
67
|
+
if (typeof translated !== "string" || translated.trim() === "") {
|
|
68
|
+
throw new Error(`Locale "${code}" has an empty or invalid framework string "${key}".`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const messages = value.messages;
|
|
72
|
+
if (messages !== undefined && !isRecord(messages)) {
|
|
73
|
+
throw new Error(`Locale "${code}" messages must be an object.`);
|
|
74
|
+
}
|
|
75
|
+
for (const [id, translated] of Object.entries(messages ?? {})) {
|
|
76
|
+
if (id.trim() === "" || typeof translated !== "string" || translated.trim() === "") {
|
|
77
|
+
throw new Error(`Locale "${code}" has an empty or invalid message entry.`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const allowed = value.allowIdentical;
|
|
81
|
+
if (
|
|
82
|
+
allowed !== undefined &&
|
|
83
|
+
(!Array.isArray(allowed) ||
|
|
84
|
+
allowed.some((id) => typeof id !== "string" || id.trim() === ""))
|
|
85
|
+
) {
|
|
86
|
+
throw new Error(`Locale "${code}" allowIdentical must be an array of non-empty ids.`);
|
|
87
|
+
}
|
|
88
|
+
if (allowed !== undefined && new Set(allowed).size !== allowed.length) {
|
|
89
|
+
throw new Error(`Locale "${code}" allowIdentical contains duplicate ids.`);
|
|
90
|
+
}
|
|
91
|
+
const stale = allowed?.find((id) => typeof messages?.[id] !== "string");
|
|
92
|
+
if (stale !== undefined) {
|
|
93
|
+
throw new Error(`Locale "${code}" allowIdentical names missing message "${stale}".`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function assertFrameworkStringsComplete(locales: Record<string, LocaleCatalog>): void {
|
|
99
|
+
for (const [code, catalog] of Object.entries(locales)) {
|
|
100
|
+
if (code.split("-")[0].toLowerCase() === "en") continue;
|
|
101
|
+
const missing = Object.keys(DEFAULT_STRINGS).filter(
|
|
102
|
+
(key) =>
|
|
103
|
+
typeof catalog.strings?.[key as keyof TerpStrings] !== "string" ||
|
|
104
|
+
catalog.strings[key as keyof TerpStrings]?.trim() === "",
|
|
105
|
+
);
|
|
106
|
+
if (missing.length > 0) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`Locale "${code}" is missing ${missing.length} framework string translation(s) ` +
|
|
109
|
+
`(for example: ${missing.slice(0, 3).join(", ")}). ` +
|
|
110
|
+
"Pass a complete framework catalog; app messages alone do not translate the shell.",
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Merge app message catalogs with react-core's framework-string catalogs. This keeps one
|
|
118
|
+
* checked-in `i18n.json` authoritative for app copy without duplicating LOCALE_EN/LOCALE_NL.
|
|
119
|
+
*/
|
|
120
|
+
export function defineAppLocales(
|
|
121
|
+
declaration: AppI18nDeclaration,
|
|
122
|
+
frameworkLocales: Record<string, LocaleCatalog> = {},
|
|
123
|
+
): Record<string, LocaleCatalog> {
|
|
124
|
+
if (!isRecord(declaration) || typeof declaration.sourceLocale !== "string") {
|
|
125
|
+
throw new Error("frontend/i18n.json must declare sourceLocale and a locales map.");
|
|
126
|
+
}
|
|
127
|
+
assertLocaleCatalogs(declaration.locales, declaration.sourceLocale);
|
|
128
|
+
if (!isRecord(frameworkLocales)) {
|
|
129
|
+
throw new Error("Framework locale catalogs must be an object.");
|
|
130
|
+
}
|
|
131
|
+
if (Object.keys(frameworkLocales).length > 0) {
|
|
132
|
+
assertLocaleCatalogs(frameworkLocales);
|
|
133
|
+
}
|
|
134
|
+
const merged = Object.fromEntries(
|
|
135
|
+
Object.entries(declaration.locales).map(([code, app]) => {
|
|
136
|
+
const framework = frameworkLocales[code] ?? {};
|
|
137
|
+
return [
|
|
138
|
+
code,
|
|
139
|
+
{
|
|
140
|
+
...framework,
|
|
141
|
+
...app,
|
|
142
|
+
strings: { ...framework.strings, ...app.strings },
|
|
143
|
+
messages: { ...framework.messages, ...app.messages },
|
|
144
|
+
},
|
|
145
|
+
];
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
assertLocaleCatalogs(merged, declaration.sourceLocale);
|
|
149
|
+
assertFrameworkStringsComplete(merged);
|
|
150
|
+
return merged;
|
|
19
151
|
}
|
|
20
152
|
|
|
21
153
|
/** The built-in English catalog — the bundled defaults, no overrides needed. */
|
|
@@ -151,6 +283,8 @@ export interface LocaleProviderProps {
|
|
|
151
283
|
locales: Record<string, LocaleCatalog>;
|
|
152
284
|
/** Starting locale when the user has not chosen one; default: the first key. */
|
|
153
285
|
defaultLocale?: string;
|
|
286
|
+
/** Locale whose descriptor `message` is the authored fallback; default: the first key. */
|
|
287
|
+
sourceLocale?: string;
|
|
154
288
|
children: ReactNode;
|
|
155
289
|
}
|
|
156
290
|
|
|
@@ -160,11 +294,20 @@ export interface LocaleProviderProps {
|
|
|
160
294
|
* `UiText` context — so every react-core component (and every `UiText` prop) follows the
|
|
161
295
|
* switch with no per-component wiring. Adding a language to an app is one catalog entry.
|
|
162
296
|
*/
|
|
163
|
-
export function LocaleProvider({
|
|
297
|
+
export function LocaleProvider({
|
|
298
|
+
locales,
|
|
299
|
+
defaultLocale,
|
|
300
|
+
sourceLocale,
|
|
301
|
+
children,
|
|
302
|
+
}: LocaleProviderProps) {
|
|
303
|
+
assertLocaleCatalogs(locales, sourceLocale);
|
|
304
|
+
assertFrameworkStringsComplete(locales);
|
|
164
305
|
const codes = Object.keys(locales);
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
306
|
+
if (defaultLocale !== undefined && !codes.includes(defaultLocale)) {
|
|
307
|
+
throw new Error(`Default locale "${defaultLocale}" is not present in the locale catalogs.`);
|
|
308
|
+
}
|
|
309
|
+
const resolvedSourceLocale = sourceLocale ?? codes[0];
|
|
310
|
+
const fallback = defaultLocale ?? codes[0];
|
|
168
311
|
const [locale, setLocaleState] = useState<string>(() => {
|
|
169
312
|
try {
|
|
170
313
|
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
|
|
@@ -173,6 +316,7 @@ export function LocaleProvider({ locales, defaultLocale, children }: LocaleProvi
|
|
|
173
316
|
return fallback ?? "en";
|
|
174
317
|
}
|
|
175
318
|
});
|
|
319
|
+
const activeLocale = codes.includes(locale) ? locale : fallback;
|
|
176
320
|
|
|
177
321
|
const setLocale = useCallback(
|
|
178
322
|
(next: string) => {
|
|
@@ -191,17 +335,49 @@ export function LocaleProvider({ locales, defaultLocale, children }: LocaleProvi
|
|
|
191
335
|
|
|
192
336
|
const value = useMemo<LocaleContextValue>(
|
|
193
337
|
() => ({
|
|
194
|
-
locale,
|
|
338
|
+
locale: activeLocale,
|
|
195
339
|
locales: codes,
|
|
196
340
|
labelOf: (code) => locales[code]?.label ?? code,
|
|
197
341
|
setLocale,
|
|
198
342
|
}),
|
|
199
|
-
[
|
|
343
|
+
[activeLocale, codes.join("\u0000"), setLocale, locales],
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
const resolveText = useCallback(
|
|
347
|
+
(text: UiText): string => {
|
|
348
|
+
if (typeof text === "string") {
|
|
349
|
+
return text;
|
|
350
|
+
}
|
|
351
|
+
if (text.id.trim() === "" || text.message.trim() === "") {
|
|
352
|
+
throw new Error("UiText descriptors require non-empty id and message values.");
|
|
353
|
+
}
|
|
354
|
+
if (activeLocale === resolvedSourceLocale) {
|
|
355
|
+
return text.message;
|
|
356
|
+
}
|
|
357
|
+
const catalog = locales[activeLocale];
|
|
358
|
+
const translated = catalog?.messages?.[text.id];
|
|
359
|
+
if (typeof translated === "string" && translated.trim() !== "") {
|
|
360
|
+
if (translated === text.message && !catalog.allowIdentical?.includes(text.id)) {
|
|
361
|
+
throw new Error(
|
|
362
|
+
`Translation "${text.id}" for locale "${activeLocale}" copies its source text. ` +
|
|
363
|
+
"Translate it or document an intentional proper noun/acronym in allowIdentical.",
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
return translated;
|
|
367
|
+
}
|
|
368
|
+
throw new Error(
|
|
369
|
+
`Missing translation "${text.id}" for locale "${activeLocale}". ` +
|
|
370
|
+
"Add it to frontend/i18n.json and run the frontend lint gate.",
|
|
371
|
+
);
|
|
372
|
+
},
|
|
373
|
+
[activeLocale, locales, resolvedSourceLocale],
|
|
200
374
|
);
|
|
201
375
|
|
|
202
376
|
return (
|
|
203
377
|
<LocaleContext.Provider value={value}>
|
|
204
|
-
<UiTextProvider strings={locales[
|
|
378
|
+
<UiTextProvider strings={locales[activeLocale]?.strings} resolveText={resolveText}>
|
|
379
|
+
{children}
|
|
380
|
+
</UiTextProvider>
|
|
205
381
|
</LocaleContext.Provider>
|
|
206
382
|
);
|
|
207
383
|
}
|
package/src/nav.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ModuleManifest, NavGroup, NavItem } from "@terpjs/contract";
|
|
1
|
+
import type { ModuleManifest, NavGroup, NavItem, UiText } from "@terpjs/contract";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* What a manifest's declared visibility is resolved against.
|
|
@@ -81,7 +81,7 @@ export interface NavSection {
|
|
|
81
81
|
/** The declared group's id, or `null` for the default headerless group. */
|
|
82
82
|
id: string | null;
|
|
83
83
|
/** The label to render above the list, or `null` when the section renders none. */
|
|
84
|
-
label:
|
|
84
|
+
label: UiText | null;
|
|
85
85
|
items: NavItem[];
|
|
86
86
|
}
|
|
87
87
|
|
package/src/router.tsx
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from "@tanstack/react-router";
|
|
15
15
|
import type { ComponentType, ReactNode } from "react";
|
|
16
16
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
17
|
-
import type { ModuleManifest, NavGroup } from "@terpjs/contract";
|
|
17
|
+
import type { ModuleManifest, NavGroup, UiText } from "@terpjs/contract";
|
|
18
18
|
|
|
19
19
|
import { AppShell } from "./AppShell";
|
|
20
20
|
import { ProfileView } from "./ProfileView";
|
|
@@ -238,7 +238,7 @@ export interface BuildAppRouterOptions {
|
|
|
238
238
|
/** Maps a manifest route's `view` id to the component that renders it. */
|
|
239
239
|
views: Record<string, ComponentType>;
|
|
240
240
|
/** App title shown in the shell's sidebar brand. */
|
|
241
|
-
title:
|
|
241
|
+
title: UiText;
|
|
242
242
|
/** Brand mark in the sidebar (any rendered node); default: the placeholder TerpMark. */
|
|
243
243
|
logo?: ReactNode;
|
|
244
244
|
/**
|
package/src/sso.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { TerpClient, TerpClientFor } from "@terpjs/contract";
|
|
1
|
+
import type { TerpClient, TerpClientFor, UiText } from "@terpjs/contract";
|
|
2
2
|
|
|
3
3
|
import { unwrap } from "./unwrap";
|
|
4
4
|
|
|
@@ -61,7 +61,7 @@ export interface SsoProvider {
|
|
|
61
61
|
/** Provider name as mounted on the backend (the `{provider}` path segment). */
|
|
62
62
|
name: string;
|
|
63
63
|
/** Display label for the provider button (defaults to the name). */
|
|
64
|
-
label?:
|
|
64
|
+
label?: UiText;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/** Default SPA path prefix the IdP redirects back to: `/auth/callback/{provider}`. */
|
package/src/styles.test.ts
CHANGED
|
@@ -63,6 +63,38 @@ afterEach(() => {
|
|
|
63
63
|
});
|
|
64
64
|
|
|
65
65
|
describe("injectTerpStyles", () => {
|
|
66
|
+
it("adopts a constructable stylesheet when the document supports one", () => {
|
|
67
|
+
// jsdom exposes the CSSStyleSheet constructor but not document.adoptedStyleSheets,
|
|
68
|
+
// so the property is defined here to reach the branch a real browser takes. That
|
|
69
|
+
// branch is the whole point of the injector: an adopted sheet is not governed by
|
|
70
|
+
// CSP's style-src, so a generated app needs no 'unsafe-inline' — measured in
|
|
71
|
+
// Chromium, where a <style> element is reported as style-src-elem and dropped.
|
|
72
|
+
const sheets: CSSStyleSheet[] = [];
|
|
73
|
+
Object.defineProperty(document, "adoptedStyleSheets", {
|
|
74
|
+
configurable: true,
|
|
75
|
+
get: () => sheets,
|
|
76
|
+
set: (next: CSSStyleSheet[]) => {
|
|
77
|
+
sheets.length = 0;
|
|
78
|
+
sheets.push(...next);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
try {
|
|
82
|
+
injectTerpStyles();
|
|
83
|
+
expect(sheets.length).toBe(1);
|
|
84
|
+
// No element: taking the adopted route must not also append a <style>, or the
|
|
85
|
+
// page would carry the rules twice and still need the CSP keyword.
|
|
86
|
+
expect(document.getElementById(TERP_STYLES_ID)).toBeNull();
|
|
87
|
+
expect(sheets[0]?.cssRules.length ?? 0).toBeGreaterThan(0);
|
|
88
|
+
|
|
89
|
+
// Idempotent by the sheet's own mark rather than by an element id.
|
|
90
|
+
injectTerpStyles();
|
|
91
|
+
injectTerpStyles();
|
|
92
|
+
expect(sheets.length).toBe(1);
|
|
93
|
+
} finally {
|
|
94
|
+
delete (document as unknown as { adoptedStyleSheets?: unknown }).adoptedStyleSheets;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
66
98
|
it("appends the stylesheet once and is idempotent on re-invocation", () => {
|
|
67
99
|
injectTerpStyles();
|
|
68
100
|
injectTerpStyles();
|
package/src/styles.ts
CHANGED
|
@@ -4228,13 +4228,50 @@ button[data-terp="input"][data-placeholder="true"] {
|
|
|
4228
4228
|
}
|
|
4229
4229
|
`;
|
|
4230
4230
|
|
|
4231
|
+
/**
|
|
4232
|
+
* The property stamped on the constructed sheet so a second call recognises it.
|
|
4233
|
+
*
|
|
4234
|
+
* On the sheet object rather than on an element or a `data-` attribute: the sheet
|
|
4235
|
+
* lives on the `document`, so the mark is document-scoped exactly like the old
|
|
4236
|
+
* element-id check was, and it cannot collide with the `data-terp*` selectors
|
|
4237
|
+
* this very stylesheet declares.
|
|
4238
|
+
*/
|
|
4239
|
+
const ADOPTED_MARKER = "__terpStylesId";
|
|
4240
|
+
|
|
4241
|
+
/** A constructed sheet plus the mark identifying it as ours. */
|
|
4242
|
+
type MarkedSheet = CSSStyleSheet & { [ADOPTED_MARKER]?: string };
|
|
4243
|
+
|
|
4244
|
+
/**
|
|
4245
|
+
* `document.adoptedStyleSheets` is absent in jsdom, so the property is read
|
|
4246
|
+
* through a type that admits that. Cast via `unknown` rather than intersected
|
|
4247
|
+
* with `Document`, because the DOM lib declares the property as always present
|
|
4248
|
+
* and an intersection would keep that stricter declaration.
|
|
4249
|
+
*/
|
|
4250
|
+
type AdoptableDocument = { adoptedStyleSheets?: MarkedSheet[] };
|
|
4251
|
+
|
|
4231
4252
|
/**
|
|
4232
4253
|
* Inject the react-core interaction-state stylesheet once per document.
|
|
4233
4254
|
*
|
|
4234
|
-
*
|
|
4235
|
-
*
|
|
4236
|
-
*
|
|
4237
|
-
*
|
|
4255
|
+
* Prefers a **constructable stylesheet** (`new CSSStyleSheet()` +
|
|
4256
|
+
* `document.adoptedStyleSheets`), because that is the only injection route a
|
|
4257
|
+
* Content-Security-Policy does not have to widen for. A `<style>` element's
|
|
4258
|
+
* rules are inline styles as far as CSP is concerned, so shipping them obliged
|
|
4259
|
+
* every generated app to serve `style-src 'unsafe-inline'` — a keyword that,
|
|
4260
|
+
* once present, also permits every *other* inline stylesheet on the page,
|
|
4261
|
+
* including one an injection managed to introduce. Measured in Chromium: an
|
|
4262
|
+
* adopted sheet applies cleanly under `style-src 'self'` while a `<style>`
|
|
4263
|
+
* element is reported as a `style-src-elem` violation and its rules dropped.
|
|
4264
|
+
*
|
|
4265
|
+
* The `<style>` element remains the fallback, because a browser without
|
|
4266
|
+
* constructable stylesheets would otherwise render the chrome unstyled. Under a
|
|
4267
|
+
* strict policy those browsers get no styling either way, so the fallback only
|
|
4268
|
+
* ever helps.
|
|
4269
|
+
*
|
|
4270
|
+
* SSR-safe: no-op when `document` is undefined. Idempotent by either route — the
|
|
4271
|
+
* adopted sheet carries {@link ADOPTED_MARKER}, the element is keyed by
|
|
4272
|
+
* {@link TERP_STYLES_ID} — so repeated calls from any component's module scope
|
|
4273
|
+
* attach the rules exactly once. Neither route touches an HTML sink: the element
|
|
4274
|
+
* path sets `textContent`, never `innerHTML`, and `replaceSync` parses CSS only.
|
|
4238
4275
|
*/
|
|
4239
4276
|
export function injectTerpStyles(): void {
|
|
4240
4277
|
if (typeof document === "undefined") {
|
|
@@ -4243,6 +4280,23 @@ export function injectTerpStyles(): void {
|
|
|
4243
4280
|
if (document.getElementById(TERP_STYLES_ID) !== null) {
|
|
4244
4281
|
return;
|
|
4245
4282
|
}
|
|
4283
|
+
|
|
4284
|
+
const adopted = (document as unknown as AdoptableDocument).adoptedStyleSheets;
|
|
4285
|
+
if (adopted !== undefined && typeof CSSStyleSheet === "function") {
|
|
4286
|
+
if (adopted.some((sheet) => sheet[ADOPTED_MARKER] === TERP_STYLES_ID)) {
|
|
4287
|
+
return;
|
|
4288
|
+
}
|
|
4289
|
+
try {
|
|
4290
|
+
const sheet: MarkedSheet = new CSSStyleSheet();
|
|
4291
|
+
sheet.replaceSync(TERP_STYLES_CSS);
|
|
4292
|
+
sheet[ADOPTED_MARKER] = TERP_STYLES_ID;
|
|
4293
|
+
(document as unknown as AdoptableDocument).adoptedStyleSheets = [...adopted, sheet];
|
|
4294
|
+
return;
|
|
4295
|
+
} catch {
|
|
4296
|
+
// A browser that exposes the API but refuses this sheet still gets styling.
|
|
4297
|
+
}
|
|
4298
|
+
}
|
|
4299
|
+
|
|
4246
4300
|
const el = document.createElement("style");
|
|
4247
4301
|
el.id = TERP_STYLES_ID;
|
|
4248
4302
|
el.textContent = TERP_STYLES_CSS;
|
package/src/uiText.test.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
|
|
5
5
|
import { Page } from "./Page";
|
|
6
6
|
import { ResourceList } from "./ResourceList";
|
|
7
7
|
import { TerpProvider } from "./TerpProvider";
|
|
8
|
-
import { resolveUiText, UiTextProvider } from "./uiText";
|
|
8
|
+
import { resolveUiText, resolveUiTextNode, UiTextProvider } from "./uiText";
|
|
9
9
|
|
|
10
10
|
afterEach(cleanup);
|
|
11
11
|
|
|
@@ -14,6 +14,20 @@ describe("resolveUiText", () => {
|
|
|
14
14
|
expect(resolveUiText("Tasks")).toBe("Tasks");
|
|
15
15
|
expect(resolveUiText({ id: "tasks.title", message: "Tasks" })).toBe("Tasks");
|
|
16
16
|
});
|
|
17
|
+
|
|
18
|
+
it("resolves descriptors in prose slots and preserves rich React nodes", () => {
|
|
19
|
+
const resolve = (text: string | { readonly id: string; readonly message: string }) =>
|
|
20
|
+
typeof text === "string" ? text : `translated:${text.id}`;
|
|
21
|
+
expect(
|
|
22
|
+
resolveUiTextNode(
|
|
23
|
+
{ id: "tasks.empty.description", message: "Create your first task." },
|
|
24
|
+
resolve,
|
|
25
|
+
),
|
|
26
|
+
).toBe("translated:tasks.empty.description");
|
|
27
|
+
|
|
28
|
+
const rich = <strong>Already rendered</strong>;
|
|
29
|
+
expect(resolveUiTextNode(rich, resolve)).toBe(rich);
|
|
30
|
+
});
|
|
17
31
|
});
|
|
18
32
|
|
|
19
33
|
describe("UiTextProvider", () => {
|
package/src/uiText.tsx
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { createContext, useCallback, useContext, useMemo } from "react";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
|
+
import type { UiText } from "@terpjs/contract";
|
|
4
|
+
|
|
5
|
+
export type { UiText } from "@terpjs/contract";
|
|
3
6
|
|
|
4
7
|
/**
|
|
5
8
|
* A piece of user-facing text: either a plain string (used as-is) or a message
|
|
@@ -7,11 +10,38 @@ import type { ReactNode } from "react";
|
|
|
7
10
|
* `message` used as the fallback. Components accept `UiText` so an app can go
|
|
8
11
|
* from hardcoded strings to a full i18n runtime without changing call sites.
|
|
9
12
|
*/
|
|
10
|
-
export type UiText = string | { id: string; message: string };
|
|
11
|
-
|
|
12
13
|
/** Resolves a {@link UiText} to the display string for the active locale. */
|
|
13
14
|
export type ResolveUiText = (text: UiText) => string;
|
|
14
15
|
|
|
16
|
+
/** Textual copy or an already-rendered rich node, for prose-bearing component slots. */
|
|
17
|
+
export type UiTextNode = UiText | ReactNode;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve a descriptor/string while leaving rich React content untouched. Components
|
|
21
|
+
* with prose slots use this instead of making callers choose between localization and
|
|
22
|
+
* inline emphasis/links.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveUiTextNode(
|
|
25
|
+
value: UiTextNode,
|
|
26
|
+
resolve: ResolveUiText = resolveUiText,
|
|
27
|
+
): ReactNode {
|
|
28
|
+
if (typeof value === "string") {
|
|
29
|
+
return resolve(value);
|
|
30
|
+
}
|
|
31
|
+
if (
|
|
32
|
+
typeof value === "object" &&
|
|
33
|
+
value !== null &&
|
|
34
|
+
!Array.isArray(value) &&
|
|
35
|
+
"id" in value &&
|
|
36
|
+
"message" in value &&
|
|
37
|
+
typeof value.id === "string" &&
|
|
38
|
+
typeof value.message === "string"
|
|
39
|
+
) {
|
|
40
|
+
return resolve(value);
|
|
41
|
+
}
|
|
42
|
+
return value as ReactNode;
|
|
43
|
+
}
|
|
44
|
+
|
|
15
45
|
/** The default resolver: plain strings as-is, descriptors via their fallback `message`. */
|
|
16
46
|
export function resolveUiText(text: UiText): string {
|
|
17
47
|
return typeof text === "string" ? text : text.message;
|
|
@@ -385,3 +415,15 @@ export function useUiText(): ResolveUiText {
|
|
|
385
415
|
const { resolveText } = useContext(UiTextContext);
|
|
386
416
|
return useCallback((text: UiText) => resolveText(text), [resolveText]);
|
|
387
417
|
}
|
|
418
|
+
|
|
419
|
+
/** Props for {@link Trans}: one stable catalog id and its source-language fallback. */
|
|
420
|
+
export interface TransProps {
|
|
421
|
+
id: string;
|
|
422
|
+
message: string;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Render authored copy through the active locale resolver, including plain JSX body text. */
|
|
426
|
+
export function Trans({ id, message }: TransProps) {
|
|
427
|
+
const resolve = useUiText();
|
|
428
|
+
return <>{resolve({ id, message })}</>;
|
|
429
|
+
}
|