@carlonicora/nextjs-jsonapi 3.14.0 → 3.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlonicora/nextjs-jsonapi",
3
- "version": "3.14.0",
3
+ "version": "3.14.1",
4
4
  "description": "Next.js JSON:API client with server/client support and caching",
5
5
  "author": "Carlo Nicora",
6
6
  "license": "GPL-3.0-or-later",
@@ -0,0 +1,131 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { dirname, join, relative } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ /**
7
+ * Every `features/<feature>/i18n-keys.ts` declares the translation keys this
8
+ * package will ask a consuming app to define. Nothing imported those lists, so
9
+ * they were documentation that happened to compile: a component could add a
10
+ * `t("…")` call without touching the contract, and the omission only surfaced
11
+ * in whichever app rendered that branch first. `handbook.notFound` and the six
12
+ * `billing.tokens.*` keys reached several apps that way.
13
+ *
14
+ * These tests read the lists and the sources and compare them, in both
15
+ * directions, so the two cannot drift apart silently again.
16
+ */
17
+
18
+ const FEATURES_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
19
+ const SRC_DIR = join(FEATURES_DIR, "..");
20
+
21
+ /**
22
+ * Keys a contract promises that no component in this package reads — the app
23
+ * owns the surface that renders them (administration landing cards, the help
24
+ * landing page, handbook screens an app builds itself). They are legitimate
25
+ * contract entries, so they are listed rather than deleted, but the list is
26
+ * closed: a NEW unread key fails the second test until someone either wires it
27
+ * up, deletes it, or justifies it here.
28
+ */
29
+ const DECLARED_FOR_APP_RENDERED_SURFACES = new Set<string>([
30
+ "administration.billing.description",
31
+ "administration.companies.description",
32
+ "administration.group.platform",
33
+ "administration.rbac.description",
34
+ "administration.token_usage.description",
35
+ "administration.users.description",
36
+ "administration.ai_connections.description",
37
+ "handbook.chat.delete_confirm",
38
+ "handbook.chat.rename_placeholder",
39
+ "handbook.contents.title",
40
+ "handbook.notConfigured",
41
+ "handbook.subtitle",
42
+ "help.article.related",
43
+ "help.header.search",
44
+ "help.landing.browseByMode",
45
+ "help.landing.featuredTutorials",
46
+ "help.landing.heading",
47
+ "help.landing.subheading",
48
+ "help.modeIndex.empty",
49
+ "token_usage.series.other",
50
+ "token_usage.timeline.empty",
51
+ ]);
52
+
53
+ /** `t("key")`, `t('key')`, `t(`key`)` and their `t.rich(…)` equivalents. */
54
+ const STATIC_KEY = /\bt(?:\.rich)?\(\s*(?:"([^"${]+)"|'([^'${]+)'|`([^`${]+)`)/g;
55
+ /** ``t(`prefix.${expr}`)`` — only the literal head is knowable. */
56
+ const DYNAMIC_PREFIX = /\bt(?:\.rich)?\(\s*`([^`]*?)\$\{/g;
57
+
58
+ function sourceFiles(dir: string, found: string[] = []): string[] {
59
+ for (const entry of readdirSync(dir)) {
60
+ const path = join(dir, entry);
61
+ if (statSync(path).isDirectory()) {
62
+ if (entry !== "__tests__") sourceFiles(path, found);
63
+ } else if (/\.tsx?$/.test(entry) && !/\.(test|spec)\.tsx?$/.test(entry)) {
64
+ found.push(path);
65
+ }
66
+ }
67
+ return found;
68
+ }
69
+
70
+ /** Where each key is read, so a failure names the file instead of just the key. */
71
+ const readKeys = new Map<string, string>();
72
+ const dynamicPrefixes = new Set<string>();
73
+ for (const file of sourceFiles(SRC_DIR)) {
74
+ const source = readFileSync(file, "utf8");
75
+ for (const match of source.matchAll(STATIC_KEY)) {
76
+ const key = (match[1] ?? match[2] ?? match[3])!;
77
+ if (!readKeys.has(key)) readKeys.set(key, relative(SRC_DIR, file));
78
+ }
79
+ for (const match of source.matchAll(DYNAMIC_PREFIX)) {
80
+ if (match[1]) dynamicPrefixes.add(match[1]);
81
+ }
82
+ }
83
+
84
+ const contracts = new Map<string, string[]>();
85
+ for (const feature of readdirSync(FEATURES_DIR)) {
86
+ const path = join(FEATURES_DIR, feature, "i18n-keys.ts");
87
+ let source: string;
88
+ try {
89
+ source = readFileSync(path, "utf8");
90
+ } catch {
91
+ continue;
92
+ }
93
+ contracts.set(
94
+ feature,
95
+ [...source.matchAll(/"([^"]+)"/g)].map((match) => match[1]!),
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Declared keys pooled across features: namespaces are shared (ai-connection
101
+ * reads `administration.*`), so ownership is per namespace, not per directory.
102
+ */
103
+ const declared = new Set([...contracts.values()].flat());
104
+ const declaredNamespaces = new Set([...declared].map((key) => key.split(".")[0]));
105
+
106
+ describe("i18n key contracts", () => {
107
+ it("finds a contract to check", () => {
108
+ expect(contracts.size).toBeGreaterThan(0);
109
+ expect(readKeys.size).toBeGreaterThan(0);
110
+ });
111
+
112
+ it("declares every key the package reads in a contracted namespace", () => {
113
+ const undeclared = [...readKeys.entries()]
114
+ .filter(([key]) => declaredNamespaces.has(key.split(".")[0]!) && !declared.has(key))
115
+ .map(([key, file]) => `${key} (read in ${file})`)
116
+ .sort();
117
+
118
+ expect(undeclared).toEqual([]);
119
+ });
120
+
121
+ it.each([...contracts.keys()])("has no unread keys in the %s contract", (feature) => {
122
+ const isRead = (key: string) => readKeys.has(key) || [...dynamicPrefixes].some((prefix) => key.startsWith(prefix));
123
+
124
+ const unread = contracts
125
+ .get(feature)!
126
+ .filter((key) => !isRead(key) && !DECLARED_FOR_APP_RENDERED_SURFACES.has(key))
127
+ .sort();
128
+
129
+ expect(unread).toEqual([]);
130
+ });
131
+ });
@@ -93,3 +93,18 @@ export const BILLING_ADMIN_I18N_KEYS = [
93
93
  "billing.admin.prices.restore.description",
94
94
  "billing.admin.prices.restore.confirm",
95
95
  ] as const;
96
+
97
+ /**
98
+ * The `billing.tokens.*` namespace, read by the token status indicator rather
99
+ * than by any administrative surface — which is why it is a second export
100
+ * instead of an addition to the list above, whose contract is scoped to the
101
+ * admin screens.
102
+ */
103
+ export const BILLING_TOKENS_I18N_KEYS = [
104
+ "billing.tokens.status",
105
+ "billing.tokens.monthly",
106
+ "billing.tokens.available",
107
+ "billing.tokens.available_extra",
108
+ "billing.tokens.purchase_extra",
109
+ "billing.tokens.upgrade_plan",
110
+ ] as const;
@@ -17,6 +17,7 @@ export const handbookI18nKeys = [
17
17
  "handbook.syncFailed",
18
18
  "handbook.empty",
19
19
  "handbook.notConfigured",
20
+ "handbook.notFound",
20
21
  "handbook.chat.title",
21
22
  "handbook.chat.placeholder",
22
23
  "handbook.chat.ask",