@carlonicora/nextjs-jsonapi 5.0.2 → 5.1.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 +3 -0
- package/dist/{BlockNoteEditor-6CUJTOMP.js → BlockNoteEditor-ABPLK4HR.js} +19 -19
- package/dist/{BlockNoteEditor-6CUJTOMP.js.map → BlockNoteEditor-ABPLK4HR.js.map} +1 -1
- package/dist/{BlockNoteEditor-NSTBN2E7.mjs → BlockNoteEditor-QUQIF3WR.mjs} +4 -4
- package/dist/{BlockNoteViewer-77CCTC2U.js → BlockNoteViewer-PHISJQH5.js} +3 -3
- package/dist/{BlockNoteViewer-77CCTC2U.js.map → BlockNoteViewer-PHISJQH5.js.map} +1 -1
- package/dist/{BlockNoteViewer-MDPO6BXN.mjs → BlockNoteViewer-SA24LCOT.mjs} +2 -2
- package/dist/billing/index.js +441 -441
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-ILFFC6BG.mjs → chunk-4R6ZRADN.mjs} +2 -2
- package/dist/{chunk-KJMMBUOA.js → chunk-ENEO2JFI.js} +3 -3
- package/dist/{chunk-KJMMBUOA.js.map → chunk-ENEO2JFI.js.map} +1 -1
- package/dist/{chunk-V72M6XCH.js → chunk-G662QMA3.js} +839 -839
- package/dist/{chunk-V72M6XCH.js.map → chunk-G662QMA3.js.map} +1 -1
- package/dist/{chunk-GPXYQMOZ.js → chunk-M45T54VI.js} +7 -7
- package/dist/{chunk-GPXYQMOZ.js.map → chunk-M45T54VI.js.map} +1 -1
- package/dist/{chunk-UVXHRE67.mjs → chunk-O542QOKH.mjs} +5 -5
- package/dist/{chunk-MQDR6MEE.mjs → chunk-ORHUCNDA.mjs} +2 -2
- package/dist/{chunk-4ORLPEK7.js → chunk-TZG5PZ2Z.js} +21 -2
- package/dist/chunk-TZG5PZ2Z.js.map +1 -0
- package/dist/{chunk-6DRSSW5O.mjs → chunk-UY27K26K.mjs} +20 -1
- package/dist/chunk-UY27K26K.mjs.map +1 -0
- package/dist/{chunk-6SMJRAE3.js → chunk-VAFOYV7R.js} +4 -4
- package/dist/{chunk-6SMJRAE3.js.map → chunk-VAFOYV7R.js.map} +1 -1
- package/dist/{chunk-IUS2RHCX.mjs → chunk-VXOAFEGV.mjs} +2 -2
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.js +4 -4
- package/dist/components/index.mjs +3 -3
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +15 -1
- package/dist/core/index.d.ts +15 -1
- package/dist/core/index.js +4 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +3 -1
- package/dist/features/analytics/index.js +122 -122
- package/dist/features/analytics/index.mjs +4 -4
- package/dist/features/analytics/server.js +4 -4
- package/dist/features/analytics/server.mjs +2 -2
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.js +125 -125
- package/dist/features/tokenusage/index.mjs +4 -4
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4 -2
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +2 -2
- package/messages/en.json +1653 -0
- package/messages/it.json +1653 -0
- package/package.json +5 -3
- package/pwa/offline.template.html +5 -4
- package/scripts/package-surface.spec.ts +15 -0
- package/scripts/validate-messages.mjs +412 -0
- package/scripts/validate-messages.spec.ts +56 -0
- package/src/core/utils/__tests__/mergeMessages.spec.ts +39 -0
- package/src/core/utils/index.ts +1 -0
- package/src/core/utils/mergeMessages.ts +29 -0
- package/dist/chunk-4ORLPEK7.js.map +0 -1
- package/dist/chunk-6DRSSW5O.mjs.map +0 -1
- /package/dist/{BlockNoteEditor-NSTBN2E7.mjs.map → BlockNoteEditor-QUQIF3WR.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-MDPO6BXN.mjs.map → BlockNoteViewer-SA24LCOT.mjs.map} +0 -0
- /package/dist/{chunk-ILFFC6BG.mjs.map → chunk-4R6ZRADN.mjs.map} +0 -0
- /package/dist/{chunk-UVXHRE67.mjs.map → chunk-O542QOKH.mjs.map} +0 -0
- /package/dist/{chunk-MQDR6MEE.mjs.map → chunk-ORHUCNDA.mjs.map} +0 -0
- /package/dist/{chunk-IUS2RHCX.mjs.map → chunk-VXOAFEGV.mjs.map} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carlonicora/nextjs-jsonapi",
|
|
3
|
-
"version": "5.0
|
|
3
|
+
"version": "5.1.0",
|
|
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",
|
|
@@ -89,13 +89,14 @@
|
|
|
89
89
|
"import": "./dist/next-config/index.mjs",
|
|
90
90
|
"require": "./dist/next-config/index.js"
|
|
91
91
|
},
|
|
92
|
-
"./pwa/*": "./pwa/*"
|
|
92
|
+
"./pwa/*": "./pwa/*",
|
|
93
|
+
"./messages/*": "./messages/*"
|
|
93
94
|
},
|
|
94
95
|
"scripts": {
|
|
95
96
|
"build": "rm -rf dist && NODE_OPTIONS='--max-old-space-size=8192' tsup && tsc -p scripts/generate-web-module/tsconfig.json",
|
|
96
97
|
"dev": "node scripts/dev-watch.mjs",
|
|
97
98
|
"clean": "rm -rf dist",
|
|
98
|
-
"lint": "eslint \"src/**/*.ts\" \"src/**/*.tsx\" --fix && node scripts/check-rtl-classes.mjs",
|
|
99
|
+
"lint": "eslint \"src/**/*.ts\" \"src/**/*.tsx\" --fix && node scripts/check-rtl-classes.mjs && node scripts/validate-messages.mjs",
|
|
99
100
|
"format": "prettier --write \"src/**/*.ts\"",
|
|
100
101
|
"prepublishOnly": "pnpm build",
|
|
101
102
|
"prepare": "tsc -p scripts/generate-web-module/tsconfig.json",
|
|
@@ -110,6 +111,7 @@
|
|
|
110
111
|
"src",
|
|
111
112
|
"scripts",
|
|
112
113
|
"pwa",
|
|
114
|
+
"messages",
|
|
113
115
|
"README.md"
|
|
114
116
|
],
|
|
115
117
|
"peerDependencies": {
|
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
build assets, no external script, stylesheet or font, because the build's
|
|
6
6
|
chunks are not available offline and change on every deploy.
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
Filled by hand from @carlonicora/nextjs-jsonapi pwa/offline.template.html:
|
|
9
|
+
the app name, the theme blocks (one root block of the six tokens, then one
|
|
10
|
+
class block per non-light theme), the default-locale strings, and the config
|
|
11
|
+
object read by the script below. After any edit, set offlineRevision in
|
|
12
|
+
public/sw-config.js to the first 8 hex characters of this file's sha256.
|
|
12
13
|
-->
|
|
13
14
|
<html lang="en">
|
|
14
15
|
<head>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import pkg from "../package.json";
|
|
4
|
+
|
|
5
|
+
describe("package surface", () => {
|
|
6
|
+
it("ships and exports messages and pwa", () => {
|
|
7
|
+
expect(pkg.files).toEqual(expect.arrayContaining(["messages", "pwa"]));
|
|
8
|
+
expect((pkg.exports as Record<string, unknown>)["./messages/*"]).toBe("./messages/*");
|
|
9
|
+
expect(pkg.scripts.lint).toContain("node scripts/validate-messages.mjs");
|
|
10
|
+
});
|
|
11
|
+
it("messages resolve through the self-reference", () => {
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
expect(require.resolve("@carlonicora/nextjs-jsonapi/messages/en.json")).toMatch(/messages\/en\.json$/);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
// Lint-time guard: every text the library renders has an English and an
|
|
2
|
+
// Italian value in messages/, no leaf is dead, and no value names an app.
|
|
3
|
+
// Resolver copied from neural-erp's apps/web/scripts/validate-translations.mjs.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
// Patterns to match translation usage
|
|
9
|
+
const USE_TRANSLATIONS_PATTERN = /useTranslations\(\s*["']([^"']*)["']\s*\)/g;
|
|
10
|
+
const USE_TRANSLATIONS_NO_NS_PATTERN = /useTranslations\(\s*\)/g;
|
|
11
|
+
const T_CALL_PATTERN = /\bt\(\s*["']([^"']+)["']/g;
|
|
12
|
+
const T_TEMPLATE_CALL_PATTERN = /\bt\(\s*`([^`]+)`/g;
|
|
13
|
+
const GET_TRANSLATIONS_PATTERN = /getTranslations\(\s*["']([^"']*)["']\s*\)/g;
|
|
14
|
+
|
|
15
|
+
// A dotted string literal anywhere in src: a key reached through a constant
|
|
16
|
+
// (`labelKey: "entities.companies"`, i18n-keys.ts, OPERATION_LABEL_NAMESPACE).
|
|
17
|
+
const LITERAL_PATTERN = /["'`]([a-z0-9_]+(?:\.[a-z0-9_]+)+)["'`]/g;
|
|
18
|
+
|
|
19
|
+
// Keys the library renders but whose text is the app's by nature (owner
|
|
20
|
+
// decision 3: the library holds no information about the application). They
|
|
21
|
+
// must NOT be in messages/*.json; every app provides them, with no fallback.
|
|
22
|
+
export const APP_PROVIDED_KEYS = [
|
|
23
|
+
/^common\.title$/, // the app's own name
|
|
24
|
+
/^common\.description$/, // the app's own tagline
|
|
25
|
+
/^auth\.login_description$/, // the app's product pitch on the login page
|
|
26
|
+
/^auth\.register_description$/, // the app's product pitch on the register page
|
|
27
|
+
/^features\.assistant\.composer_placeholder$/, // names what the app's assistant knows about
|
|
28
|
+
/^features\.assistant\.starters\./, // example questions about the app's own domain
|
|
29
|
+
/^features\.assistant\.empty_state\.subtitle$/, // says what the app's assistant knows about
|
|
30
|
+
/^auth\.gdpr\.marketing_consent$/, // a consent must name who sends the news: the app
|
|
31
|
+
// The app's own entities (CommonDeleter / RecentPagesNavigator render
|
|
32
|
+
// `entities.${type}` for any module). Only the library's own entities ship.
|
|
33
|
+
/^entities\.(?!(?:assistants|companies|howtos|rbac|roles|users)(?:\.|$))/,
|
|
34
|
+
// Token-usage operation types an app backend defines (operation-label.ts: the
|
|
35
|
+
// app supplies this vocabulary, unknown types render raw). Only the types the
|
|
36
|
+
// backend library emits, plus its customer/platform scopes, ship.
|
|
37
|
+
/^token_usage\.types\.(?!(?:analyser|communitySummariser|counterpartIdentificator|customer|embedding|ethicist|graphCreator|imageAnalysis|imageGeneration|operator|platform|responder|strategy|summariser|textGeneration|transcription)$)/,
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// Values the app-word rule would flag although the word is the library's own
|
|
41
|
+
// meaning, not an app's domain. Each entry carries its reason.
|
|
42
|
+
const APP_WORD_EXEMPT_KEYS = new Map([
|
|
43
|
+
["analytics.admin.dimensions.campaign", "the utm_campaign dimension of web analytics"],
|
|
44
|
+
["help.modes.how-to", 'Italian "pratiche" = practical (how-to guides), not a case file'],
|
|
45
|
+
["howto.howToType.how-to", 'Italian "pratica" = practical (how-to guide), not a case file'],
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
// Template keys whose ids an app backend defines at runtime: there is nothing
|
|
49
|
+
// in the library to enumerate, so they are not checked against messages/.
|
|
50
|
+
export const DYNAMIC_KEY_ALLOWLIST = [
|
|
51
|
+
// Notification.tsx: notificationType comes from the app's API; each app owns its types.
|
|
52
|
+
"notification.${notification.notificationType}.title",
|
|
53
|
+
// Notification.tsx: the action button of an app-defined notification type.
|
|
54
|
+
"notification.${notification.notificationType}.buttons.action",
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
// No value may name an app or use an app's domain word (owner decision 3).
|
|
58
|
+
const APP_WORDS = [
|
|
59
|
+
/wyrdli/i,
|
|
60
|
+
/phlow/i,
|
|
61
|
+
/adhstudy/i,
|
|
62
|
+
/neural[- ]erp/i,
|
|
63
|
+
/a360ai/i,
|
|
64
|
+
/avvocato360/i,
|
|
65
|
+
/360studioai/i,
|
|
66
|
+
/only35/i,
|
|
67
|
+
/dreamer/i,
|
|
68
|
+
/\bcampaigns?\b/i,
|
|
69
|
+
/\bcampagn[ae]\b/i,
|
|
70
|
+
/\bscenes?\b/i,
|
|
71
|
+
/\bscen[ae]\b/i,
|
|
72
|
+
/\bnpcs?\b/i,
|
|
73
|
+
/\bpratich?[ae]\b/i,
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
const isAppProvided = (key) => APP_PROVIDED_KEYS.some((p) => p.test(key));
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Recursively get all keys from a nested object
|
|
80
|
+
*/
|
|
81
|
+
function getAllKeys(obj, prefix = "") {
|
|
82
|
+
const keys = new Set();
|
|
83
|
+
|
|
84
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
85
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
86
|
+
|
|
87
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
88
|
+
const nestedKeys = getAllKeys(value, fullKey);
|
|
89
|
+
nestedKeys.forEach((k) => keys.add(k));
|
|
90
|
+
} else {
|
|
91
|
+
keys.add(fullKey);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return keys;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function leafKeys(tree, prefix = "") {
|
|
99
|
+
return getAllKeys(tree, prefix);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Escape a string for literal use inside a RegExp
|
|
104
|
+
*/
|
|
105
|
+
function escapeRegExp(value) {
|
|
106
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Turn a template-literal key (`a.${x}.label`) into a RegExp matching full leaf keys.
|
|
111
|
+
* Returns null when the template cannot be parsed reliably (nested template literals).
|
|
112
|
+
*/
|
|
113
|
+
export function dynamicKeyToRegExp(template) {
|
|
114
|
+
const parts = [];
|
|
115
|
+
let index = 0;
|
|
116
|
+
while (index < template.length) {
|
|
117
|
+
const start = template.indexOf("${", index);
|
|
118
|
+
if (start === -1) {
|
|
119
|
+
parts.push(escapeRegExp(template.slice(index)));
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
parts.push(escapeRegExp(template.slice(index, start)));
|
|
123
|
+
let depth = 1;
|
|
124
|
+
let cursor = start + 2;
|
|
125
|
+
while (cursor < template.length && depth > 0) {
|
|
126
|
+
if (template[cursor] === "{") depth++;
|
|
127
|
+
else if (template[cursor] === "}") depth--;
|
|
128
|
+
cursor++;
|
|
129
|
+
}
|
|
130
|
+
if (depth !== 0) return null;
|
|
131
|
+
parts.push("[^.]+");
|
|
132
|
+
index = cursor;
|
|
133
|
+
}
|
|
134
|
+
return new RegExp(`^${parts.join("")}$`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Extract translation keys from a single file
|
|
139
|
+
*/
|
|
140
|
+
function extractKeysFromFile(filePath) {
|
|
141
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
142
|
+
const usages = [];
|
|
143
|
+
|
|
144
|
+
// Find all useTranslations calls with namespaces in this file
|
|
145
|
+
const namespaces = [];
|
|
146
|
+
|
|
147
|
+
// Match useTranslations("namespace")
|
|
148
|
+
let match;
|
|
149
|
+
const contentForNamespace = content;
|
|
150
|
+
|
|
151
|
+
// Reset regex
|
|
152
|
+
USE_TRANSLATIONS_PATTERN.lastIndex = 0;
|
|
153
|
+
while ((match = USE_TRANSLATIONS_PATTERN.exec(contentForNamespace)) !== null) {
|
|
154
|
+
const lineNumber = content.substring(0, match.index).split("\n").length;
|
|
155
|
+
namespaces.push({ namespace: match[1], line: lineNumber });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Match useTranslations() without namespace
|
|
159
|
+
USE_TRANSLATIONS_NO_NS_PATTERN.lastIndex = 0;
|
|
160
|
+
while ((match = USE_TRANSLATIONS_NO_NS_PATTERN.exec(contentForNamespace)) !== null) {
|
|
161
|
+
const lineNumber = content.substring(0, match.index).split("\n").length;
|
|
162
|
+
namespaces.push({ namespace: "", line: lineNumber });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Match getTranslations("namespace") for server components
|
|
166
|
+
GET_TRANSLATIONS_PATTERN.lastIndex = 0;
|
|
167
|
+
while ((match = GET_TRANSLATIONS_PATTERN.exec(contentForNamespace)) !== null) {
|
|
168
|
+
const lineNumber = content.substring(0, match.index).split("\n").length;
|
|
169
|
+
namespaces.push({ namespace: match[1], line: lineNumber });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// If no useTranslations found but t() is used, assume no namespace
|
|
173
|
+
if (namespaces.length === 0) {
|
|
174
|
+
namespaces.push({ namespace: "", line: 0 });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const activeNamespaceAt = (lineNumber) => {
|
|
178
|
+
let activeNamespace = "";
|
|
179
|
+
for (const ns of namespaces) {
|
|
180
|
+
if (ns.line <= lineNumber) {
|
|
181
|
+
activeNamespace = ns.namespace;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return activeNamespace;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const addUsage = (key, index, dynamic) => {
|
|
188
|
+
const lineNumber = content.substring(0, index).split("\n").length;
|
|
189
|
+
const activeNamespace = activeNamespaceAt(lineNumber);
|
|
190
|
+
usages.push({
|
|
191
|
+
key: activeNamespace ? `${activeNamespace}.${key}` : key,
|
|
192
|
+
file: filePath,
|
|
193
|
+
line: lineNumber,
|
|
194
|
+
dynamic,
|
|
195
|
+
});
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// Find all t("key") / t('key') calls
|
|
199
|
+
T_CALL_PATTERN.lastIndex = 0;
|
|
200
|
+
while ((match = T_CALL_PATTERN.exec(content)) !== null) {
|
|
201
|
+
// Plain-quoted strings cannot interpolate; a "{" means this is not a key
|
|
202
|
+
if (match[1].includes("{")) continue;
|
|
203
|
+
addUsage(match[1], match.index, false);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Find all t(`key`) and t(`prefix.${expr}.suffix`) calls
|
|
207
|
+
T_TEMPLATE_CALL_PATTERN.lastIndex = 0;
|
|
208
|
+
while ((match = T_TEMPLATE_CALL_PATTERN.exec(content)) !== null) {
|
|
209
|
+
const key = match[1];
|
|
210
|
+
const dynamic = key.includes("${");
|
|
211
|
+
if (!dynamic && key.includes("{")) continue;
|
|
212
|
+
// A key that is nothing but an interpolation carries no static information
|
|
213
|
+
if (dynamic && /^\$\{[^]*\}$/.test(key) && dynamicKeyToRegExp(key)?.source === "^[^.]+$") continue;
|
|
214
|
+
addUsage(key, match.index, dynamic);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return usages;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Recursively find all TypeScript/JavaScript files
|
|
222
|
+
*/
|
|
223
|
+
function findSourceFiles(dir, files = []) {
|
|
224
|
+
if (!fs.existsSync(dir)) {
|
|
225
|
+
return files;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
229
|
+
|
|
230
|
+
for (const entry of entries) {
|
|
231
|
+
const fullPath = path.join(dir, entry.name);
|
|
232
|
+
|
|
233
|
+
if (entry.isDirectory()) {
|
|
234
|
+
if (
|
|
235
|
+
entry.name === "node_modules" ||
|
|
236
|
+
entry.name === ".next" ||
|
|
237
|
+
entry.name === "coverage" ||
|
|
238
|
+
entry.name === "__tests__" ||
|
|
239
|
+
entry.name === "tests"
|
|
240
|
+
) {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
findSourceFiles(fullPath, files);
|
|
244
|
+
} else if (
|
|
245
|
+
entry.isFile() &&
|
|
246
|
+
(entry.name.endsWith(".tsx") || entry.name.endsWith(".ts")) &&
|
|
247
|
+
!entry.name.endsWith(".test.ts") &&
|
|
248
|
+
!entry.name.endsWith(".test.tsx") &&
|
|
249
|
+
!entry.name.endsWith(".spec.ts") &&
|
|
250
|
+
!entry.name.endsWith(".spec.tsx") &&
|
|
251
|
+
!entry.name.endsWith(".d.ts")
|
|
252
|
+
) {
|
|
253
|
+
files.push(fullPath);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return files;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function collectUsages(sourceRoot) {
|
|
261
|
+
return findSourceFiles(sourceRoot).flatMap((file) => extractKeysFromFile(file));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Dotted literals in one file's source. Comment lines (`//`, `/*`, ` *`) are
|
|
266
|
+
* skipped: prose there quotes config paths and keys that are not usages.
|
|
267
|
+
*/
|
|
268
|
+
export function literalsIn(content) {
|
|
269
|
+
const literals = new Set();
|
|
270
|
+
for (const line of content.split("\n")) {
|
|
271
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(line)) continue;
|
|
272
|
+
for (const m of line.matchAll(LITERAL_PATTERN)) literals.add(m[1]);
|
|
273
|
+
}
|
|
274
|
+
return literals;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function collectLiterals(sourceRoot) {
|
|
278
|
+
const literals = new Set();
|
|
279
|
+
for (const file of findSourceFiles(sourceRoot)) {
|
|
280
|
+
for (const literal of literalsIn(fs.readFileSync(file, "utf-8"))) literals.add(literal);
|
|
281
|
+
}
|
|
282
|
+
return literals;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function valueAt(tree, key) {
|
|
286
|
+
let node = tree;
|
|
287
|
+
for (const part of key.split(".")) {
|
|
288
|
+
if (node === null || typeof node !== "object") return undefined;
|
|
289
|
+
node = node[part];
|
|
290
|
+
}
|
|
291
|
+
return node;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function dottedPrefixes(key) {
|
|
295
|
+
const parts = key.split(".");
|
|
296
|
+
return parts.map((_, i) => parts.slice(0, i + 1).join("."));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function validate({ en, it, usages, literals }) {
|
|
300
|
+
const findings = [];
|
|
301
|
+
const enKeys = leafKeys(en);
|
|
302
|
+
const itKeys = leafKeys(it);
|
|
303
|
+
const where = (u) => `${u.file}:${u.line}`;
|
|
304
|
+
|
|
305
|
+
const patterns = [];
|
|
306
|
+
for (const usage of usages) {
|
|
307
|
+
if (isAppProvided(usage.key)) continue;
|
|
308
|
+
if (!usage.dynamic) {
|
|
309
|
+
if (!enKeys.has(usage.key)) findings.push({ rule: "missing", key: usage.key, detail: where(usage) });
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (DYNAMIC_KEY_ALLOWLIST.includes(usage.key)) continue;
|
|
313
|
+
const pattern = dynamicKeyToRegExp(usage.key);
|
|
314
|
+
if (!pattern) continue;
|
|
315
|
+
patterns.push(pattern);
|
|
316
|
+
if (![...enKeys].some((k) => pattern.test(k))) {
|
|
317
|
+
findings.push({ rule: "dynamic-unmatched", key: usage.key, detail: where(usage) });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// A dotted string literal anchored in a library namespace (its first segment is
|
|
322
|
+
// a top-level key of en.json) is a key the code reaches without t(): the
|
|
323
|
+
// i18n-keys.ts contracts, labelKey props, OPERATION_LABEL_NAMESPACE. It must
|
|
324
|
+
// resolve to a leaf or to a prefix of leaves, or the app renders the key path.
|
|
325
|
+
const namespaces = new Set(Object.keys(en));
|
|
326
|
+
for (const literal of literals) {
|
|
327
|
+
if (!namespaces.has(literal.split(".")[0])) continue;
|
|
328
|
+
if (isAppProvided(literal) || isAppProvided(`${literal}.`)) continue;
|
|
329
|
+
const resolves = enKeys.has(literal) || [...enKeys].some((k) => k.startsWith(`${literal}.`));
|
|
330
|
+
if (!resolves) findings.push({ rule: "missing", key: literal, detail: "string literal anchored in a library namespace resolves to no text" });
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
for (const key of enKeys) {
|
|
334
|
+
if (!itKeys.has(key)) findings.push({ rule: "parity", key, detail: "in en.json, not in it.json" });
|
|
335
|
+
}
|
|
336
|
+
for (const key of itKeys) {
|
|
337
|
+
if (!enKeys.has(key)) findings.push({ rule: "parity", key, detail: "in it.json, not in en.json" });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const staticKeys = new Set(usages.filter((u) => !u.dynamic).map((u) => u.key));
|
|
341
|
+
for (const key of enKeys) {
|
|
342
|
+
if (isAppProvided(key)) {
|
|
343
|
+
findings.push({ rule: "app-word", key, detail: "app-provided key must not ship in the library" });
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
const used =
|
|
347
|
+
staticKeys.has(key) || patterns.some((p) => p.test(key)) || dottedPrefixes(key).some((p) => literals.has(p));
|
|
348
|
+
if (!used) findings.push({ rule: "unused", key, detail: "no t() call or literal reaches it" });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
for (const [locale, tree, keys] of [
|
|
352
|
+
["en", en, enKeys],
|
|
353
|
+
["it", it, itKeys],
|
|
354
|
+
]) {
|
|
355
|
+
for (const key of keys) {
|
|
356
|
+
if (APP_WORD_EXEMPT_KEYS.has(key)) continue;
|
|
357
|
+
const value = valueAt(tree, key);
|
|
358
|
+
const word = APP_WORDS.find((w) => typeof value === "string" && w.test(value));
|
|
359
|
+
if (word) findings.push({ rule: "app-word", key, detail: `${locale}: "${value}" matches ${word}` });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
for (const key of enKeys) {
|
|
364
|
+
if (!itKeys.has(key)) continue;
|
|
365
|
+
const value = valueAt(en, key);
|
|
366
|
+
if (value === valueAt(it, key)) findings.push({ rule: "identical", key, detail: JSON.stringify(value) });
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return findings;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// `process.argv[1]` is not symlink-resolved by node while `import.meta.url` is,
|
|
373
|
+
// so compare real paths — otherwise the CLI silently does nothing.
|
|
374
|
+
function isDirectInvocation() {
|
|
375
|
+
try {
|
|
376
|
+
return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
377
|
+
} catch {
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (isDirectInvocation()) {
|
|
383
|
+
const root = path.join(fileURLToPath(new URL(".", import.meta.url)), "..");
|
|
384
|
+
const src = path.join(root, "src");
|
|
385
|
+
const read = (name) => JSON.parse(fs.readFileSync(path.join(root, "messages", name), "utf-8"));
|
|
386
|
+
const en = read("en.json");
|
|
387
|
+
const findings = validate({ en, it: read("it.json"), usages: collectUsages(src), literals: collectLiterals(src) });
|
|
388
|
+
|
|
389
|
+
const byRule = new Map();
|
|
390
|
+
for (const f of findings) byRule.set(f.rule, [...(byRule.get(f.rule) ?? []), f]);
|
|
391
|
+
|
|
392
|
+
const identical = byRule.get("identical") ?? [];
|
|
393
|
+
if (identical.length) {
|
|
394
|
+
console.log(`identical en/it values (report only, ${identical.length}):`);
|
|
395
|
+
for (const f of identical) console.log(` ${f.key} ${f.detail}`);
|
|
396
|
+
console.log("");
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
let failures = 0;
|
|
400
|
+
for (const [rule, list] of byRule) {
|
|
401
|
+
if (rule === "identical") continue;
|
|
402
|
+
failures += list.length;
|
|
403
|
+
console.error(`${rule} (${list.length}):`);
|
|
404
|
+
for (const f of list) console.error(` ${f.key} ${f.detail}`);
|
|
405
|
+
console.error("");
|
|
406
|
+
}
|
|
407
|
+
if (failures) {
|
|
408
|
+
console.error(`validate-messages: ${failures} problem(s)`);
|
|
409
|
+
process.exit(1);
|
|
410
|
+
}
|
|
411
|
+
console.log(`validate-messages: clean (${leafKeys(en).size} keys)`);
|
|
412
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { leafKeys, validate, APP_PROVIDED_KEYS, literalsIn } from "./validate-messages.mjs";
|
|
3
|
+
|
|
4
|
+
const en = { auth: { buttons: { login: "Log in" } }, oauth: { ui: { scopes: { read: { name: "Read" } } } }, dead: { key: "x" } };
|
|
5
|
+
const itTree = { auth: { buttons: { login: "Accedi" } }, oauth: { ui: { scopes: { read: { name: "Lettura" } } } }, dead: { key: "x" } };
|
|
6
|
+
const usage = (key: string, dynamic = false) => ({ key, file: "f.tsx", line: 1, dynamic });
|
|
7
|
+
|
|
8
|
+
describe("validate-messages", () => {
|
|
9
|
+
it("leafKeys flattens nested paths", () => {
|
|
10
|
+
expect([...leafKeys(en)].sort()).toEqual(["auth.buttons.login", "dead.key", "oauth.ui.scopes.read.name"]);
|
|
11
|
+
});
|
|
12
|
+
it("reports a static key with no text", () => {
|
|
13
|
+
const f = validate({ en, it: itTree, usages: [usage("auth.buttons.register")], literals: new Set() });
|
|
14
|
+
expect(f).toContainEqual(expect.objectContaining({ rule: "missing", key: "auth.buttons.register" }));
|
|
15
|
+
});
|
|
16
|
+
it("matches a dynamic key by pattern and reports one that matches nothing", () => {
|
|
17
|
+
const ok = validate({ en, it: itTree, usages: [usage("oauth.ui.scopes.${id}.name", true)], literals: new Set(["dead.key"]) });
|
|
18
|
+
expect(ok.filter((x) => x.rule === "dynamic-unmatched")).toHaveLength(0);
|
|
19
|
+
const bad = validate({ en, it: itTree, usages: [usage("oauth.ui.scopes.${id}.label", true)], literals: new Set(["dead.key"]) });
|
|
20
|
+
expect(bad).toContainEqual(expect.objectContaining({ rule: "dynamic-unmatched" }));
|
|
21
|
+
});
|
|
22
|
+
it("reports en/it parity both ways", () => {
|
|
23
|
+
const f = validate({ en, it: { auth: { buttons: { login: "Accedi", extra: "x" } } }, usages: [], literals: new Set() });
|
|
24
|
+
expect(f.filter((x) => x.rule === "parity").map((x) => x.key).sort()).toEqual(["auth.buttons.extra", "dead.key", "oauth.ui.scopes.read.name"]);
|
|
25
|
+
});
|
|
26
|
+
it("counts a leaf as used through t(), a literal, or a dotted prefix literal; otherwise unused", () => {
|
|
27
|
+
const f = validate({ en, it: itTree, usages: [usage("auth.buttons.login")], literals: new Set(["oauth.ui.scopes"]) });
|
|
28
|
+
expect(f.filter((x) => x.rule === "unused").map((x) => x.key)).toEqual(["dead.key"]);
|
|
29
|
+
});
|
|
30
|
+
it("requires a namespace-anchored literal to resolve; ignores literals outside the namespaces", () => {
|
|
31
|
+
const f = validate({
|
|
32
|
+
en,
|
|
33
|
+
it: itTree,
|
|
34
|
+
usages: [usage("auth.buttons.login"), usage("dead.key"), usage("oauth.ui.scopes.read.name")],
|
|
35
|
+
literals: new Set(["auth.buttons.register", "auth.buttons", "next.config", "common.title"]),
|
|
36
|
+
});
|
|
37
|
+
expect(f.filter((x) => x.rule === "missing").map((x) => x.key)).toEqual(["auth.buttons.register"]);
|
|
38
|
+
});
|
|
39
|
+
it("collects dotted literals from code lines only, never from comment lines", () => {
|
|
40
|
+
const src = `// a consumer with no \`handbook.path\`\n/* "x.y.z" */\n * "p.q"\nconst k = "billing.admin.title"; t(\`a.b\`);\n`;
|
|
41
|
+
expect([...literalsIn(src)].sort()).toEqual(["a.b", "billing.admin.title"]);
|
|
42
|
+
});
|
|
43
|
+
it("fails on an app name in any value, case-insensitive", () => {
|
|
44
|
+
const f = validate({ en: { a: "Welcome to Wyrdli" }, it: { a: "Benvenuto in wyrdli" }, usages: [usage("a")], literals: new Set() });
|
|
45
|
+
expect(f.filter((x) => x.rule === "app-word")).toHaveLength(2);
|
|
46
|
+
});
|
|
47
|
+
it("exempts app-provided keys from missing and parity, and never lists them as unused", () => {
|
|
48
|
+
expect(APP_PROVIDED_KEYS.some((p) => p.test("common.title"))).toBe(true);
|
|
49
|
+
const f = validate({ en, it: itTree, usages: [usage("common.title")], literals: new Set() });
|
|
50
|
+
expect(f.filter((x) => x.key === "common.title")).toHaveLength(0);
|
|
51
|
+
});
|
|
52
|
+
it("reports an identical en/it value without failing", () => {
|
|
53
|
+
const f = validate({ en, it: itTree, usages: [usage("auth.buttons.login"), usage("dead.key"), usage("oauth.ui.scopes.read.name")], literals: new Set() });
|
|
54
|
+
expect(f).toContainEqual(expect.objectContaining({ rule: "identical", key: "dead.key" }));
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { mergeMessages, type MessageTree } from "../mergeMessages";
|
|
3
|
+
|
|
4
|
+
describe("mergeMessages", () => {
|
|
5
|
+
const base: MessageTree = { auth: { buttons: { login: "Log in", register: "Register" } }, onlyBase: { x: "b" } };
|
|
6
|
+
const override: MessageTree = { auth: { buttons: { login: "Sign in" }, extra: "e" }, onlyOverride: { y: "o" } };
|
|
7
|
+
|
|
8
|
+
it("override leaf wins, base-only and override-only paths survive", () => {
|
|
9
|
+
const merged = mergeMessages(base, override);
|
|
10
|
+
expect(merged).toEqual({
|
|
11
|
+
auth: { buttons: { login: "Sign in", register: "Register" }, extra: "e" },
|
|
12
|
+
onlyBase: { x: "b" },
|
|
13
|
+
onlyOverride: { y: "o" },
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
it("merges three levels deep", () => {
|
|
17
|
+
expect(mergeMessages({ a: { b: { c: "1", d: "2" } } }, { a: { b: { c: "9" } } })).toEqual({
|
|
18
|
+
a: { b: { c: "9", d: "2" } },
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
it("replaces when one side is a string and the other a tree", () => {
|
|
22
|
+
expect(mergeMessages({ a: "s" }, { a: { b: "t" } })).toEqual({ a: { b: "t" } });
|
|
23
|
+
expect(mergeMessages({ a: { b: "t" } }, { a: "s" })).toEqual({ a: "s" });
|
|
24
|
+
});
|
|
25
|
+
it("inputs untouched, result is a new object", () => {
|
|
26
|
+
const b = { a: { x: "1" } };
|
|
27
|
+
const o = { a: { y: "2" } };
|
|
28
|
+
const merged = mergeMessages(b, o);
|
|
29
|
+
expect(b).toEqual({ a: { x: "1" } });
|
|
30
|
+
expect(o).toEqual({ a: { y: "2" } });
|
|
31
|
+
expect(merged).not.toBe(b);
|
|
32
|
+
expect(merged.a).not.toBe(b.a);
|
|
33
|
+
});
|
|
34
|
+
it("empty override returns a deep copy of the base", () => {
|
|
35
|
+
const merged = mergeMessages(base, {});
|
|
36
|
+
expect(merged).toEqual(base);
|
|
37
|
+
expect(merged).not.toBe(base);
|
|
38
|
+
});
|
|
39
|
+
});
|
package/src/core/utils/index.ts
CHANGED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A next-intl message file: nested objects whose leaves are strings.
|
|
3
|
+
*/
|
|
4
|
+
export type MessageTree = { [key: string]: string | MessageTree };
|
|
5
|
+
|
|
6
|
+
function isMessageTree(value: unknown): value is MessageTree {
|
|
7
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Deep-merges two message trees. At every path the override's leaf wins; a path
|
|
12
|
+
* only in the base stays; a path only in the override is added. A non-object
|
|
13
|
+
* value replaces, never merges. Neither input is mutated; the result is a new
|
|
14
|
+
* object at every level.
|
|
15
|
+
*/
|
|
16
|
+
export function mergeMessages(base: MessageTree, override: MessageTree): MessageTree {
|
|
17
|
+
const merged: MessageTree = {};
|
|
18
|
+
|
|
19
|
+
for (const [key, value] of Object.entries(base)) {
|
|
20
|
+
merged[key] = isMessageTree(value) ? mergeMessages(value, {}) : value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
for (const [key, value] of Object.entries(override)) {
|
|
24
|
+
const current = merged[key];
|
|
25
|
+
merged[key] = isMessageTree(value) ? mergeMessages(isMessageTree(current) ? current : {}, value) : value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return merged;
|
|
29
|
+
}
|