@terpjs/react-core 0.10.0 → 0.12.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.
@@ -0,0 +1,199 @@
1
+ // The hole the completeness guard cannot see.
2
+ //
3
+ // `locale.test.tsx` already asserts that every framework string is translated in every
4
+ // shipped locale, and it passes at 100%. It is only as wide as the string TABLE: a
5
+ // hardcoded `aria-label="Clear selection"` never becomes a `TerpStrings` key, so it is
6
+ // invisible to a check that walks keys — and "every framework string is translated" and
7
+ // "this control cannot be translated at all" were both true at the same time.
8
+ //
9
+ // This walks the SOURCE instead, so the two guards together cover the round trip: a
10
+ // user-facing string has to reach the table, and everything in the table has to be
11
+ // translated. Reported as "translations are not always present", which is exactly how it
12
+ // looks from an app — most of the chrome localises, a few controls stubbornly do not, and
13
+ // no gate anywhere goes red.
14
+ //
15
+ // Uses Vite's raw glob rather than an fs walk, the way the other scanning tests here do:
16
+ // this package's tsconfig declares no Node types on purpose.
17
+
18
+ import { describe, expect, it } from "vitest";
19
+
20
+ const sources = import.meta.glob("./**/*.{ts,tsx}", {
21
+ query: "?raw",
22
+ import: "default",
23
+ eager: true,
24
+ }) as Record<string, string>;
25
+
26
+ /** Attributes whose value a user reads or hears. */
27
+ const USER_FACING_ATTRIBUTES = ["aria-label", "placeholder", "title", "alt", "aria-description"];
28
+
29
+ /**
30
+ * Files where a bare literal is not a translation defect.
31
+ *
32
+ * Deliberately tiny, and each entry says why it is not one. This is NOT a migration
33
+ * baseline: the framework's chrome is already routed through `TerpStrings`, so there is no
34
+ * debt to ratchet down, and a growing list here would mean the opposite of what this test
35
+ * is for.
36
+ */
37
+ const ALLOWED: Record<string, string> = {
38
+ "./uiText.tsx": "the string table itself — these ARE the source-language defaults",
39
+ "./locale.tsx": "the shipped catalogues — every value here is a translation",
40
+ "./styles.ts":
41
+ "CSS in a template literal, so a match is an attribute SELECTOR " +
42
+ '(data-placeholder="true"), never a string a user reads',
43
+ };
44
+
45
+ /**
46
+ * A line that only *documents* code.
47
+ *
48
+ * These checks forbid a shape, and the clearest way to document a forbidden shape is to
49
+ * write it down — so the prose explaining the fix contains the defect verbatim. The first
50
+ * version of the widened default check duly reported DatePicker's own comment, which says
51
+ * `placeholder = "Select date"` while the code beside it does the right thing. Skipping
52
+ * comment-only lines is the fix; a literal inside a trailing comment on a line of real code
53
+ * is still matched, which is the rarer shape and the one worth a false positive.
54
+ */
55
+ function isComment(line: string): boolean {
56
+ const trimmed = line.trimStart();
57
+ return trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*");
58
+ }
59
+
60
+ function scannable(): [string, string][] {
61
+ return Object.entries(sources).filter(
62
+ ([file]) => !/\.(test|spec)\.tsx?$/.test(file) && ALLOWED[file] === undefined,
63
+ );
64
+ }
65
+
66
+ describe("user-facing strings", () => {
67
+ it("never hardcodes an attribute a user reads", () => {
68
+ const offenders: string[] = [];
69
+
70
+ for (const [file, source] of scannable()) {
71
+ source.split("\n").forEach((line, index) => {
72
+ if (line.includes("i18n-ok") || isComment(line)) {
73
+ return;
74
+ }
75
+ for (const attribute of USER_FACING_ATTRIBUTES) {
76
+ // A double-quoted value starting with a letter — a literal a human reads. A
77
+ // `{expression}` value is not matched, and that is the compliant shape: it
78
+ // resolves a `UiText` or reads a `TerpStrings` key.
79
+ const match = new RegExp(`${attribute}="([A-Za-z][^"]*)"`).exec(line);
80
+ if (match !== null) {
81
+ offenders.push(`${file}:${index + 1} ${attribute}="${match[1]}"`);
82
+ }
83
+ }
84
+ });
85
+ }
86
+
87
+ expect(
88
+ offenders,
89
+ "these strings are rendered to a user and cannot be translated: they never enter " +
90
+ "TerpStrings, so locale.test.tsx's completeness guard passes over them and an app " +
91
+ "has no way to override them. Add a TerpStrings key (with its translations) and " +
92
+ "read it through useStrings() — or, for a caller-supplied string, take a UiText " +
93
+ "prop and resolve it with useUiText(). A genuinely non-linguistic value (a test id, " +
94
+ "a token name) takes an `i18n-ok` comment on the line.",
95
+ ).toEqual([]);
96
+ });
97
+
98
+ it("never hides a literal inside a braced attribute value", () => {
99
+ // The shape the check above is blind to by construction. Its comment claimed a
100
+ // `{expression}` value "is the compliant shape: it resolves a UiText or reads a
101
+ // TerpStrings key" — true of most, and not of a ternary, a `??` fallback or a default,
102
+ // any of which carries the untranslatable literal straight through the braces. Not
103
+ // hypothetical: `aria-label={multiple ? "Clear all selections" : "Clear selection"}`
104
+ // shipped while this very file was being written to forbid it, and the two met in a
105
+ // merge. Treating braces as proof of compliance makes the fix for one control the
106
+ // loophole for the next.
107
+ //
108
+ // Same line only, which is a stated limit rather than a claim: the shapes that carry a
109
+ // literal (ternary, fallback, default) fit on one line under this repo's formatting.
110
+ const offenders: string[] = [];
111
+
112
+ for (const [file, source] of scannable()) {
113
+ source.split("\n").forEach((line, index) => {
114
+ if (line.includes("i18n-ok") || isComment(line)) {
115
+ return;
116
+ }
117
+ for (const attribute of USER_FACING_ATTRIBUTES) {
118
+ // The name must stand alone: `data-placeholder={…}` is not `placeholder`, and
119
+ // matching it as a substring reported a data attribute's `"true"` as prose.
120
+ const opener = new RegExp(`(?<![\w-])${attribute}=\{`, "g");
121
+ for (const opened of line.matchAll(opener)) {
122
+ // Read to the MATCHING brace. Reading to end-of-line swept up whatever attribute
123
+ // came next — `data-terp="breadcrumbs"` on the same element was reported as a
124
+ // user-facing literal.
125
+ const start = opened.index + opened[0].length;
126
+ let cursor = start;
127
+ let depth = 1;
128
+ while (cursor < line.length && depth > 0) {
129
+ if (line[cursor] === "{") depth += 1;
130
+ else if (line[cursor] === "}") depth -= 1;
131
+ if (depth === 0) break;
132
+ cursor += 1;
133
+ }
134
+ for (const [literal, inner] of line.slice(start, cursor).matchAll(/"([A-Za-z][^"]*)"/g)) {
135
+ // Inside an expression most literals are not prose: a discriminant
136
+ // (`kind === "role"`), a placeholder token, a data value. Unlike the direct
137
+ // `attribute="…"` position — where a literal is user-facing almost by
138
+ // definition — this one asks whether the string LOOKS like a label.
139
+ //
140
+ // A heuristic, stated as one: a lowercase single-word label
141
+ // (`aria-label={open ? "close" : "open"}`) slips through. Every user-facing
142
+ // string this framework ships is a capitalised phrase, so that shape does not
143
+ // exist here; if one lands, it wants the direct check's discipline rather than
144
+ // a wider net here, which would flag every discriminant in the package.
145
+ if (/^[A-Z]/.test(inner!) || inner!.includes(" ")) {
146
+ offenders.push(`${file}:${index + 1} ${attribute}={… ${literal} …}`);
147
+ }
148
+ }
149
+ }
150
+ }
151
+ });
152
+ }
153
+
154
+ expect(
155
+ offenders,
156
+ "a user-facing attribute resolves an expression, and the expression still contains a " +
157
+ "hardcoded English string — most often a ternary picking between two literals, or a " +
158
+ "`??` fallback behind a translatable prop. Braces are not evidence of anything: move " +
159
+ "every branch to a TerpStrings key and read them through useStrings().",
160
+ ).toEqual([]);
161
+ });
162
+
163
+ it("never defaults a UiText prop to a bare string", () => {
164
+ // The subtler half, and the one that looks fine in review. A `UiText` prop defaulted to
165
+ // `"Select date"` IS overridable — and still untranslatable: a plain string resolves
166
+ // as-is, so an app that does not pass the prop shows English in every locale. The fix is
167
+ // not a descriptor default either; it is to fall back to a TerpStrings key at the use
168
+ // site, so the app's own catalogue answers when the caller says nothing.
169
+ const offenders: string[] = [];
170
+
171
+ for (const [file, source] of scannable()) {
172
+ const uiTextProps = new Set(
173
+ [...source.matchAll(/^\s*(\w+)\??:\s*UiText[;\s|]/gm)].map((match) => match[1]!),
174
+ );
175
+ source.split("\n").forEach((line, index) => {
176
+ if (line.includes("i18n-ok") || isComment(line)) {
177
+ return;
178
+ }
179
+ // Anywhere on the line, NOT anchored to own it. Anchoring made this depend on
180
+ // formatting: `removeLabel = "Remove"` inside a one-line destructuring
181
+ // (`const { value, onChange, removeLabel = "Remove", ...rest } = props`) was
182
+ // invisible, and became visible only when the line was split for unrelated reasons.
183
+ // A check that a reformat can switch on and off is not a check.
184
+ for (const assignment of line.matchAll(/(\w+)\s*=\s*"([A-Za-z][^"]*)"/g)) {
185
+ if (uiTextProps.has(assignment[1]!)) {
186
+ offenders.push(`${file}:${index + 1} ${assignment[1]} = "${assignment[2]}"`);
187
+ }
188
+ }
189
+ });
190
+ }
191
+
192
+ expect(
193
+ offenders,
194
+ "a UiText prop defaulted to a plain string renders that string in every locale for any " +
195
+ "app that does not override it — the prop is translatable and its default is not. " +
196
+ "Leave the default `undefined` and fall back to a TerpStrings key at the use site.",
197
+ ).toEqual([]);
198
+ });
199
+ });
@@ -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;
@@ -210,9 +240,36 @@ export interface TerpStrings {
210
240
  saved: string;
211
241
  /** Generic failure toast when a request did not go through. */
212
242
  requestFailed: string;
243
+ /** Combobox: clears the single selection. */
244
+ clearSelection: string;
245
+ /** Combobox: clears every selection in multiple mode. */
246
+ clearAllSelections: string;
247
+ /** Combobox: removes one chosen option in multiple mode, prefixed to its label. */
248
+ comboboxRemove: string;
249
+ /** Combobox: shown in the listbox while options are being fetched. */
250
+ comboboxLoading: string;
251
+ /** Combobox: shown in the listbox when the filter matches nothing. */
252
+ comboboxNoOptions: string;
253
+ /** DatePicker: steps the calendar back one month. */
254
+ previousMonth: string;
255
+ /** DatePicker: steps the calendar forward one month. */
256
+ nextMonth: string;
257
+ /** DatePicker: trigger text before a date is chosen. */
258
+ selectDate: string;
259
+ /** DateRangePicker: trigger text before a range is chosen. */
260
+ selectDateRange: string;
213
261
  }
214
262
 
215
263
  export const DEFAULT_STRINGS: TerpStrings = {
264
+ clearSelection: "Clear selection",
265
+ clearAllSelections: "Clear all selections",
266
+ comboboxRemove: "Remove",
267
+ comboboxLoading: "Loading…",
268
+ comboboxNoOptions: "No options",
269
+ previousMonth: "Previous month",
270
+ selectDate: "Select date",
271
+ selectDateRange: "Select date range",
272
+ nextMonth: "Next month",
216
273
  loading: "Loading...",
217
274
  emptyList: "Nothing here yet.",
218
275
  add: "Add",
@@ -358,3 +415,15 @@ export function useUiText(): ResolveUiText {
358
415
  const { resolveText } = useContext(UiTextContext);
359
416
  return useCallback((text: UiText) => resolveText(text), [resolveText]);
360
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
+ }