@nitronjs/framework 0.2.27 → 0.3.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.
Files changed (58) hide show
  1. package/README.md +260 -170
  2. package/lib/Auth/Auth.js +2 -2
  3. package/lib/Build/CssBuilder.js +5 -7
  4. package/lib/Build/EffectivePropUsage.js +174 -0
  5. package/lib/Build/FactoryTransform.js +1 -21
  6. package/lib/Build/FileAnalyzer.js +2 -33
  7. package/lib/Build/Manager.js +354 -58
  8. package/lib/Build/PropUsageAnalyzer.js +1189 -0
  9. package/lib/Build/jsxRuntime.js +25 -155
  10. package/lib/Build/plugins.js +212 -146
  11. package/lib/Build/propUtils.js +70 -0
  12. package/lib/Console/Commands/DevCommand.js +30 -10
  13. package/lib/Console/Commands/MakeCommand.js +8 -1
  14. package/lib/Console/Output.js +0 -2
  15. package/lib/Console/Stubs/rsc-consumer.tsx +74 -0
  16. package/lib/Console/Stubs/vendor-dev.tsx +30 -41
  17. package/lib/Console/Stubs/vendor.tsx +25 -1
  18. package/lib/Core/Config.js +0 -6
  19. package/lib/Core/Paths.js +0 -19
  20. package/lib/Database/Migration/Checksum.js +0 -3
  21. package/lib/Database/Migration/MigrationRepository.js +0 -8
  22. package/lib/Database/Migration/MigrationRunner.js +1 -2
  23. package/lib/Database/Model.js +19 -11
  24. package/lib/Database/QueryBuilder.js +25 -4
  25. package/lib/Database/Schema/Blueprint.js +10 -0
  26. package/lib/Database/Schema/Manager.js +2 -0
  27. package/lib/Date/DateTime.js +1 -1
  28. package/lib/Dev/DevContext.js +44 -0
  29. package/lib/Dev/DevErrorPage.js +990 -0
  30. package/lib/Dev/DevIndicator.js +836 -0
  31. package/lib/HMR/Server.js +16 -37
  32. package/lib/Http/Server.js +171 -23
  33. package/lib/Logging/Log.js +34 -2
  34. package/lib/Mail/Mail.js +41 -10
  35. package/lib/Route/Router.js +43 -19
  36. package/lib/Runtime/Entry.js +10 -6
  37. package/lib/Session/Manager.js +103 -1
  38. package/lib/Session/Session.js +0 -4
  39. package/lib/Support/Str.js +6 -4
  40. package/lib/Translation/Lang.js +376 -32
  41. package/lib/Translation/pluralize.js +81 -0
  42. package/lib/Validation/MagicBytes.js +120 -0
  43. package/lib/Validation/Validator.js +46 -29
  44. package/lib/View/Client/hmr-client.js +100 -90
  45. package/lib/View/Client/spa.js +121 -50
  46. package/lib/View/ClientManifest.js +60 -0
  47. package/lib/View/FlightRenderer.js +100 -0
  48. package/lib/View/Layout.js +0 -3
  49. package/lib/View/PropFilter.js +81 -0
  50. package/lib/View/View.js +230 -495
  51. package/lib/index.d.ts +22 -1
  52. package/package.json +2 -2
  53. package/skeleton/config/app.js +1 -0
  54. package/skeleton/config/server.js +13 -0
  55. package/skeleton/config/session.js +3 -0
  56. package/lib/Build/HydrationBuilder.js +0 -190
  57. package/lib/Console/Stubs/page-hydration-dev.tsx +0 -72
  58. package/lib/Console/Stubs/page-hydration.tsx +0 -53
@@ -142,6 +142,10 @@ class SessionManager {
142
142
  get cookieConfig() {
143
143
  const config = { ...this.#config.cookie, signed: true };
144
144
 
145
+ if (!config.sameSite) {
146
+ config.sameSite = "Lax";
147
+ }
148
+
145
149
  if (config.maxAge) {
146
150
  config.maxAge = Math.floor(config.maxAge / 1000);
147
151
  }
@@ -228,8 +232,9 @@ class SessionManager {
228
232
 
229
233
  server.addHook("preHandler", async (request, response) => {
230
234
  const lastSegment = request.url.split("/").pop().split("?")[0];
235
+ const dotIndex = lastSegment.lastIndexOf(".");
231
236
 
232
- if (lastSegment.includes(".")) {
237
+ if (dotIndex > 0 && STATIC_EXTENSIONS.has(lastSegment.slice(dotIndex + 1).toLowerCase())) {
233
238
  return;
234
239
  }
235
240
 
@@ -258,4 +263,101 @@ class SessionManager {
258
263
  }
259
264
  }
260
265
 
266
+ // ─────────────────────────────────────────────────────────────────────────────
267
+ // Private Constants
268
+ // ─────────────────────────────────────────────────────────────────────────────
269
+
270
+ const STATIC_EXTENSIONS = new Set([
271
+ // ── Styles ──────────────────────────────────────────────────────────────
272
+ "css", "less", "scss", "sass", "styl", "stylus", "pcss", "postcss", "sss",
273
+
274
+ // ── Scripts & Source Maps ───────────────────────────────────────────────
275
+ "js", "mjs", "cjs", "ts", "mts", "cts", "jsx", "tsx",
276
+ "map", "coffee", "litcoffee", "elm", "dart", "vue", "svelte",
277
+
278
+ // ── Images (Raster) ────────────────────────────────────────────────────
279
+ "png", "jpg", "jpeg", "jpe", "jfif", "gif", "bmp", "dib",
280
+ "tiff", "tif", "webp", "avif", "heic", "heif", "ico", "cur",
281
+ "psd", "psb", "ai", "eps", "raw", "cr2", "nef", "orf", "sr2", "arw",
282
+ "dng", "raf", "rw2", "pef", "srw", "x3f", "jxl", "qoi", "hdr", "exr",
283
+
284
+ // ── Images (Vector) ────────────────────────────────────────────────────
285
+ "svg", "svgz", "cgm", "emf", "wmf",
286
+
287
+ // ── Fonts ───────────────────────────────────────────────────────────────
288
+ "woff", "woff2", "ttf", "otf", "eot", "pfb", "pfm",
289
+
290
+ // ── Video ───────────────────────────────────────────────────────────────
291
+ "mp4", "m4v", "webm", "ogv", "ogg", "avi", "mov", "mkv", "flv", "wmv",
292
+ "mpg", "mpeg", "mpe", "3gp", "3g2", "ts", "mts", "m2ts", "vob", "divx",
293
+ "asf", "rm", "rmvb", "f4v", "swf", "hevc", "av1",
294
+
295
+ // ── Audio ───────────────────────────────────────────────────────────────
296
+ "mp3", "wav", "flac", "aac", "m4a", "wma", "opus", "oga",
297
+ "aiff", "aif", "aifc", "mid", "midi", "amr", "ape", "dsf", "dff",
298
+ "ac3", "dts", "pcm", "au", "snd", "ra", "tak", "tta", "wv", "caf",
299
+
300
+ // ── Documents & Office ──────────────────────────────────────────────────
301
+ "pdf", "doc", "docx", "docm", "dot", "dotx", "dotm",
302
+ "xls", "xlsx", "xlsm", "xlsb", "xlt", "xltx", "xltm",
303
+ "ppt", "pptx", "pptm", "pot", "potx", "potm", "pps", "ppsx",
304
+ "odt", "ods", "odp", "odg", "odf", "odb", "odc",
305
+ "rtf", "tex", "latex", "bib", "epub", "mobi", "azw", "azw3",
306
+ "djvu", "djv", "xps", "oxps", "pages", "numbers", "key", "keynote",
307
+
308
+ // ── Data & Config ───────────────────────────────────────────────────────
309
+ "xml", "json", "jsonl", "ndjson", "json5", "jsonc",
310
+ "yaml", "yml", "toml", "ini", "cfg", "conf", "properties",
311
+ "csv", "tsv", "dsv", "parquet", "avro", "arrow", "feather",
312
+ "sql", "sqlite", "db", "mdb", "accdb",
313
+
314
+ // ── Text & Markup ───────────────────────────────────────────────────────
315
+ "txt", "text", "md", "markdown", "rst", "adoc", "asciidoc",
316
+ "log", "diff", "patch", "nfo", "diz",
317
+ "htm", "html", "xhtml", "mhtml", "mht", "shtml",
318
+ "ics", "ical", "vcf", "vcard", "ldif",
319
+
320
+ // ── Archives & Compression ──────────────────────────────────────────────
321
+ "zip", "rar", "7z", "tar", "gz", "gzip", "bz2", "bzip2",
322
+ "xz", "lzma", "lz", "lz4", "zst", "zstd", "br", "brotli",
323
+ "cab", "arj", "ace", "lzh", "lha", "cpio", "rpm", "deb",
324
+ "pkg", "dmg", "iso", "img", "vhd", "vhdx", "vmdk", "ova", "ovf",
325
+ "apk", "ipa", "xpi", "crx", "nupkg", "snap", "flatpak", "appimage",
326
+ "jar", "war", "ear",
327
+
328
+ // ── Executables & Libraries ─────────────────────────────────────────────
329
+ "exe", "msi", "dll", "sys", "so", "dylib", "lib", "a", "o", "obj",
330
+ "bin", "dat", "com", "bat", "cmd", "sh", "bash", "ps1", "psm1",
331
+
332
+ // ── 3D & CAD & Design ───────────────────────────────────────────────────
333
+ "glb", "gltf", "obj", "stl", "fbx", "dae", "3ds", "blend", "max",
334
+ "c4d", "ma", "mb", "lwo", "lws", "ply", "wrl", "vrml", "x3d",
335
+ "dwg", "dxf", "dgn", "step", "stp", "iges", "igs", "sat",
336
+ "skp", "3dm", "usd", "usda", "usdc", "usdz",
337
+
338
+ // ── GIS & Mapping ───────────────────────────────────────────────────────
339
+ "shp", "shx", "dbf", "prj", "cpg", "geojson", "topojson",
340
+ "kml", "kmz", "gpx", "osm", "pbf", "mbtiles",
341
+
342
+ // ── Scientific & Research ───────────────────────────────────────────────
343
+ "hdf5", "h5", "hdf", "nc", "netcdf", "fits", "mat", "sav",
344
+
345
+ // ── WebAssembly & Runtime ───────────────────────────────────────────────
346
+ "wasm", "wat", "swc",
347
+
348
+ // ── Certificates & Keys ─────────────────────────────────────────────────
349
+ "pem", "crt", "cer", "der", "p12", "pfx", "p7b", "p7c",
350
+ "csr", "key", "pub", "asc", "gpg", "sig",
351
+
352
+ // ── Web Manifests & PWA ─────────────────────────────────────────────────
353
+ "webmanifest", "manifest", "browserconfig",
354
+
355
+ // ── Misc ────────────────────────────────────────────────────────────────
356
+ "ani", "icns", "lnk", "url", "webloc", "desktop",
357
+ "torrent", "metalink", "magnet",
358
+ "sub", "srt", "ass", "ssa", "vtt", "sbv", "dfxp", "ttml",
359
+ "bak", "tmp", "temp", "swp", "swo", "lock",
360
+ "pid", "socket", "fifo"
361
+ ]);
362
+
261
363
  export default SessionManager;
@@ -31,10 +31,6 @@ class Session {
31
31
  return this.#createdAt;
32
32
  }
33
33
 
34
- get lastActivity() {
35
- return this.#lastActivity;
36
- }
37
-
38
34
  /**
39
35
  * Gets a session value.
40
36
  * @param {string} key
@@ -1,24 +1,26 @@
1
+ import { randomBytes } from "crypto";
2
+
1
3
  /**
2
4
  * String utility class with helper methods.
3
5
  * Provides Laravel-style string manipulation functions.
4
- *
6
+ *
5
7
  * @example
6
8
  * Str.slug("Hello World"); // "hello-world"
7
9
  * Str.camel("user_name"); // "userName"
8
10
  */
9
11
  class Str {
10
12
  /**
11
- * Generates a random alphanumeric string.
13
+ * Generates a cryptographically secure random alphanumeric string.
12
14
  * @param {number} length - Desired string length
13
15
  * @returns {string} Random string
14
16
  */
15
17
  static random(length) {
16
18
  const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
19
+ const bytes = randomBytes(length);
17
20
  let result = "";
18
- const charactersLength = characters.length;
19
21
 
20
22
  for (let i = 0; i < length; i++) {
21
- result += characters.charAt(Math.floor(Math.random() * charactersLength));
23
+ result += characters.charAt(bytes[i] % characters.length);
22
24
  }
23
25
 
24
26
  return result;
@@ -1,54 +1,398 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { AsyncLocalStorage } from "node:async_hooks";
3
4
  import Paths from "../Core/Paths.js";
5
+ import Config from "../Core/Config.js";
6
+ import Environment from "../Core/Environment.js";
7
+ import { pluralize, replaceParams } from "./pluralize.js";
8
+
9
+ const LangStore = new AsyncLocalStorage();
10
+
11
+ // Safety cap — prevents DoS via arbitrary locale strings in setLocale
12
+ const MAX_LOCALES = 50;
4
13
 
5
- /**
6
- * Translation manager for multi-language support.
7
- * Loads translations from JSON files and supports parameter replacement.
8
- */
9
14
  class Lang {
10
- static #cache = new Map();
15
+ static #translations = {};
16
+ static #flatCache = {};
17
+ static #mtimes = {};
18
+ static #fallbackLocale = null;
19
+
20
+ static setup(server) {
21
+ const rawLocale = Config.get("app.locale", "en");
22
+ const rawFallback = Config.get("app.fallback_locale") || null;
23
+ const defaultLocale = isValidLocale(rawLocale) ? rawLocale : "en";
24
+
25
+ Lang.#fallbackLocale = (rawFallback && isValidLocale(rawFallback)) ? rawFallback : null;
26
+
27
+ if (Environment.isDev) {
28
+ if (rawLocale !== defaultLocale) console.warn(`Lang: Invalid app.locale "${rawLocale}", falling back to "en"`);
29
+ if (rawFallback && !Lang.#fallbackLocale) console.warn(`Lang: Invalid app.fallback_locale "${rawFallback}", ignoring`);
30
+ }
31
+
32
+ server.addHook("onRequest", async (req) => {
33
+ LangStore.enterWith({ locale: defaultLocale, req, langChecked: new Set() });
34
+ req.locale = defaultLocale;
35
+ });
36
+
37
+ globalThis.__nitron_lang_get = (key, params) => Lang.get(key, params);
38
+ }
39
+
40
+ static get(key, params = {}) {
41
+ const value = this.#resolveWithFallback(key);
42
+
43
+ if (!isResolvable(value)) return key;
44
+
45
+ let result = String(value);
46
+
47
+ // Pluralize only when count is provided and value has pipe-separated segments
48
+ if (params && typeof params === "object" && params.count !== undefined && result.includes("|")) {
49
+ result = pluralize(result, params.count);
50
+ }
51
+
52
+ return replaceParams(result, params);
53
+ }
54
+
55
+ static has(key) {
56
+ return isResolvable(this.#resolveWithFallback(key));
57
+ }
58
+
59
+ static locale() {
60
+ const store = LangStore.getStore();
61
+
62
+ if (store) return store.locale;
63
+
64
+ return Config.get("app.locale", "en");
65
+ }
66
+
67
+ static setLocale(locale) {
68
+ if (!isValidLocale(locale)) {
69
+ if (Environment.isDev) {
70
+ console.warn(`Lang.setLocale: Invalid locale format "${locale}"`);
71
+ }
72
+ return;
73
+ }
74
+
75
+ const store = LangStore.getStore();
76
+
77
+ if (!store) return;
78
+
79
+ store.locale = locale;
80
+
81
+ if (store.req) {
82
+ store.req.locale = locale;
83
+ }
84
+ }
85
+
86
+ static getFilteredTranslations(locale, keys) {
87
+ this.#ensureLoaded(locale);
88
+
89
+ const flat = this.#getFlat(locale);
90
+
91
+ const fallback = Lang.#fallbackLocale;
92
+ let fallbackFlat = null;
93
+
94
+ if (fallback && fallback !== locale) {
95
+ this.#ensureLoaded(fallback);
96
+ fallbackFlat = this.#getFlat(fallback);
97
+ }
98
+
99
+ if (keys === null || keys === undefined) {
100
+ if (fallbackFlat) {
101
+ return { ...fallbackFlat, ...flat };
102
+ }
103
+ return flat;
104
+ }
105
+
106
+ if (keys.length === 0) {
107
+ return {};
108
+ }
109
+
110
+ const result = {};
111
+
112
+ for (const key of keys) {
113
+ if (flat[key] !== undefined) {
114
+ result[key] = flat[key];
115
+ }
116
+ else if (fallbackFlat && fallbackFlat[key] !== undefined) {
117
+ result[key] = fallbackFlat[key];
118
+ }
119
+ }
120
+
121
+ return result;
122
+ }
123
+
124
+ // --- Internal: resolve + fallback ---
125
+
126
+ static #resolveWithFallback(key) {
127
+ const locale = this.locale();
128
+
129
+ this.#ensureLoaded(locale);
130
+
131
+ const value = this.#resolveKey(locale, key);
132
+
133
+ if (isResolvable(value)) return value;
134
+
135
+ const fallback = Lang.#fallbackLocale;
136
+
137
+ if (fallback && fallback !== locale) {
138
+ this.#ensureLoaded(fallback);
139
+ return this.#resolveKey(fallback, key);
140
+ }
141
+
142
+ return value;
143
+ }
144
+
145
+ static #ensureLoaded(locale) {
146
+ if (Environment.isDev) {
147
+ const store = LangStore.getStore();
148
+
149
+ if (store && !store.langChecked.has(locale)) {
150
+ store.langChecked.add(locale);
151
+ this.#checkAndReload(locale);
152
+ }
153
+ else if (!this.#translations[locale]) {
154
+ this.#loadTranslations(locale);
155
+ }
156
+
157
+ return;
158
+ }
159
+
160
+ if (!this.#translations[locale]) {
161
+ if (Object.keys(this.#translations).length >= MAX_LOCALES) return;
162
+ this.#loadTranslations(locale);
163
+ }
164
+ }
165
+
166
+ // --- Internal: dev-mode hot reload ---
167
+
168
+ static #checkAndReload(locale) {
169
+ const langsDir = Paths.langs;
170
+
171
+ if (!fs.existsSync(langsDir)) {
172
+ if (!this.#translations[locale]) {
173
+ this.#translations[locale] = {};
174
+ }
175
+ return;
176
+ }
177
+
178
+ const singleFile = path.join(langsDir, locale + ".json");
179
+ const folderDir = path.join(langsDir, locale);
180
+
181
+ let changed = false;
182
+ const currentPaths = new Set();
183
+
184
+ // Check single file mtime
185
+ if (fs.existsSync(singleFile)) {
186
+ currentPaths.add(singleFile);
187
+
188
+ try {
189
+ const mtime = fs.statSync(singleFile).mtimeMs;
190
+ if (this.#mtimes[singleFile] !== mtime) changed = true;
191
+ }
192
+ catch {}
193
+ }
194
+
195
+ // Check folder files mtime (always scan to keep currentPaths complete for deletion detection)
196
+ if (fs.existsSync(folderDir)) {
197
+ try {
198
+ if (fs.statSync(folderDir).isDirectory()) {
199
+ for (const file of fs.readdirSync(folderDir)) {
200
+ if (!file.endsWith(".json")) continue;
11
201
 
12
- /**
13
- * Gets a translated string for the current request language.
14
- * @param {import("fastify").FastifyRequest} req - Fastify request with language property
15
- * @param {string} key - Translation key to look up
16
- * @param {Object|null} params - Optional parameters to replace in translation (e.g., { name: "John" } for ":name")
17
- * @returns {string} Translated string or the key if not found
18
- */
19
- static get(req, key, params = null) {
20
- const currentLang = req.language;
21
- const langPath = path.join(Paths.langs, currentLang + ".json");
202
+ const fullPath = path.join(folderDir, file);
22
203
 
23
- if (!fs.existsSync(langPath)) {
24
- return key;
204
+ currentPaths.add(fullPath);
205
+
206
+ if (!changed) {
207
+ try {
208
+ const mtime = fs.statSync(fullPath).mtimeMs;
209
+ if (this.#mtimes[fullPath] !== mtime) changed = true;
210
+ }
211
+ catch {}
212
+ }
213
+ }
214
+ }
215
+ }
216
+ catch (err) {
217
+ if (Environment.isDev) console.warn(`Lang: Failed to scan ${folderDir}: ${err.message}`);
218
+ }
25
219
  }
26
220
 
27
- let translations = this.#cache.get(currentLang);
221
+ // Detect deleted files — tracked in #mtimes but no longer on disk
222
+ if (!changed) {
223
+ for (const tracked of Object.keys(this.#mtimes)) {
224
+ if (isLocalePath(tracked, singleFile, folderDir) && !currentPaths.has(tracked)) {
225
+ changed = true;
226
+ break;
227
+ }
228
+ }
229
+ }
230
+
231
+ if (changed || !this.#translations[locale]) {
232
+ this.#loadTranslations(locale);
233
+ }
234
+ }
235
+
236
+ // --- Internal: file loading ---
237
+
238
+ static #loadTranslations(locale) {
239
+ const langsDir = Paths.langs;
240
+ const result = {};
241
+
242
+ if (!fs.existsSync(langsDir)) {
243
+ this.#translations[locale] = result;
244
+ delete this.#flatCache[locale];
245
+ return;
246
+ }
247
+
248
+ const singleFile = path.join(langsDir, locale + ".json");
249
+ const folderDir = path.join(langsDir, locale);
250
+
251
+ // Purge stale mtime entries for this locale before repopulating
252
+ for (const tracked of Object.keys(this.#mtimes)) {
253
+ if (isLocalePath(tracked, singleFile, folderDir)) {
254
+ delete this.#mtimes[tracked];
255
+ }
256
+ }
257
+
258
+ // 1. Single file: resources/langs/tr.json
259
+ if (fs.existsSync(singleFile)) {
260
+ try {
261
+ const mtime = fs.statSync(singleFile).mtimeMs;
262
+ const content = fs.readFileSync(singleFile, "utf-8");
263
+ const data = JSON.parse(content);
264
+
265
+ this.#mtimes[singleFile] = mtime;
266
+ this.#deepMerge(result, data);
267
+ }
268
+ catch (err) {
269
+ console.warn(`Lang: Failed to parse ${singleFile}: ${err.message}`);
270
+ }
271
+ }
272
+
273
+ // 2. Folder structure: resources/langs/tr/messages.json (wins over single file keys)
274
+ if (fs.existsSync(folderDir)) {
275
+ try {
276
+ if (fs.statSync(folderDir).isDirectory()) {
277
+ for (const file of fs.readdirSync(folderDir)) {
278
+ if (!file.endsWith(".json")) continue;
279
+
280
+ const fullPath = path.join(folderDir, file);
281
+ const namespace = file.replace(/\.json$/, "");
282
+
283
+ try {
284
+ const mtime = fs.statSync(fullPath).mtimeMs;
285
+ const content = fs.readFileSync(fullPath, "utf-8");
286
+ const data = JSON.parse(content);
287
+
288
+ this.#mtimes[fullPath] = mtime;
289
+
290
+ if (Environment.isDev && result[namespace]) {
291
+ console.warn(`Lang: Key "${namespace}" in ${fullPath} overrides single file`);
292
+ }
28
293
 
29
- if (!translations) {
30
- const langFile = fs.readFileSync(langPath, { encoding: "utf-8", flag: "r" });
31
- translations = JSON.parse(langFile);
32
- this.#cache.set(currentLang, translations);
294
+ result[namespace] = data;
295
+ }
296
+ catch (err) {
297
+ console.warn(`Lang: Failed to parse ${fullPath}: ${err.message}`);
298
+ }
299
+ }
300
+ }
301
+ }
302
+ catch (err) {
303
+ if (Environment.isDev) console.warn(`Lang: Failed to scan ${folderDir}: ${err.message}`);
304
+ }
33
305
  }
34
306
 
35
- let translation = translations[key] || key;
307
+ this.#translations[locale] = result;
308
+ delete this.#flatCache[locale];
309
+ }
310
+
311
+ // --- Internal: cache + key resolution ---
312
+
313
+ static #getFlat(locale) {
314
+ if (this.#flatCache[locale]) return this.#flatCache[locale];
315
+
316
+ const tree = this.#translations[locale];
317
+ const flat = tree ? this.#flattenTree(tree, "") : {};
318
+
319
+ this.#flatCache[locale] = flat;
320
+ return flat;
321
+ }
322
+
323
+ static #resolveKey(locale, key) {
324
+ const tree = this.#translations[locale];
36
325
 
37
- if (params) {
38
- for (const [paramKey, paramValue] of Object.entries(params)) {
39
- translation = translation.replaceAll(":" + paramKey, paramValue);
326
+ if (!tree) return undefined;
327
+
328
+ const parts = key.split(".");
329
+ let current = tree;
330
+
331
+ for (const part of parts) {
332
+ if (current === undefined || current === null || typeof current !== "object") {
333
+ return undefined;
40
334
  }
335
+
336
+ if (!Object.prototype.hasOwnProperty.call(current, part)) {
337
+ return undefined;
338
+ }
339
+
340
+ current = current[part];
41
341
  }
42
342
 
43
- return translation;
343
+ return current;
44
344
  }
45
345
 
46
- /**
47
- * Clears the translation cache. Useful for development hot reload.
48
- */
49
- static clearCache() {
50
- this.#cache.clear();
346
+ // --- Internal: tree utilities (input is always from JSON.parse — no circular refs) ---
347
+
348
+ static #deepMerge(target, source) {
349
+ for (const key of Object.keys(source)) {
350
+ if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
351
+
352
+ if (
353
+ typeof source[key] === "object" && source[key] !== null && !Array.isArray(source[key]) &&
354
+ typeof target[key] === "object" && target[key] !== null && !Array.isArray(target[key])
355
+ ) {
356
+ this.#deepMerge(target[key], source[key]);
357
+ }
358
+ else {
359
+ target[key] = source[key];
360
+ }
361
+ }
362
+ }
363
+
364
+ static #flattenTree(tree, prefix) {
365
+ const result = {};
366
+
367
+ for (const [key, value] of Object.entries(tree)) {
368
+ const fullKey = prefix ? prefix + "." + key : key;
369
+
370
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
371
+ Object.assign(result, this.#flattenTree(value, fullKey));
372
+ }
373
+ else {
374
+ result[fullKey] = value;
375
+ }
376
+ }
377
+
378
+ return result;
51
379
  }
52
380
  }
53
381
 
382
+ // --- Module-level helpers ---
383
+
384
+ function isResolvable(value) {
385
+ return value !== undefined && value !== null && typeof value !== "object";
386
+ }
387
+
388
+ function isLocalePath(tracked, singleFile, folderDir) {
389
+ return tracked === singleFile || tracked.startsWith(folderDir + path.sep);
390
+ }
391
+
392
+ const LOCALE_RE = /^[a-z]{2,3}(-[a-zA-Z0-9]{1,8})*$/i;
393
+
394
+ function isValidLocale(locale) {
395
+ return typeof locale === "string" && LOCALE_RE.test(locale);
396
+ }
397
+
54
398
  export default Lang;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Shared translation utilities — single source of truth.
3
+ * Used by Lang.js (server) and jsxRuntime.js (SSR + client via .toString() embedding).
4
+ *
5
+ * IMPORTANT: These functions are embedded client-side via .toString().
6
+ * Do NOT use arrow functions, template literals, const/let, or modern syntax.
7
+ */
8
+
9
+ export function pluralize(value, count) {
10
+ count = typeof count === "number" ? count : Number(count) || 0;
11
+ if (count < 0) count = 0;
12
+ count = Math.floor(count);
13
+
14
+ var segments = value.split("|");
15
+
16
+ // Try explicit syntax first: {0} zero items | {1} one item | [2,*] many items
17
+ for (var i = 0; i < segments.length; i++) {
18
+ var trimmed = segments[i].trim();
19
+
20
+ // Exact match: {3} exactly three
21
+ var exactMatch = trimmed.match(/^\{(\d+)\}\s*(.*)/);
22
+
23
+ if (exactMatch) {
24
+ if (parseInt(exactMatch[1]) === count) return exactMatch[2];
25
+ continue;
26
+ }
27
+
28
+ // Range match: [2,5] between two and five | [6,*] six or more
29
+ var rangeMatch = trimmed.match(/^\[(\d+),(\d+|\*)\]\s*(.*)/);
30
+
31
+ if (rangeMatch) {
32
+ var min = parseInt(rangeMatch[1]);
33
+ var max = rangeMatch[2] === "*" ? Infinity : parseInt(rangeMatch[2]);
34
+
35
+ if (count >= min && count <= max) return rangeMatch[3];
36
+ continue;
37
+ }
38
+ }
39
+
40
+ // Simple fallback: "one item|many items" (no explicit ranges)
41
+ if (segments.length >= 2 && !hasExplicitRanges(segments)) {
42
+ return count === 1 ? segments[0].trim() : segments[1].trim();
43
+ }
44
+
45
+ return value;
46
+ }
47
+
48
+ export function replaceParams(value, params) {
49
+ if (!params || typeof params !== "object") return value;
50
+
51
+ var keys = Object.keys(params);
52
+
53
+ if (keys.length === 0) return value;
54
+
55
+ // Escape regex special chars in param names
56
+ var escaped = [];
57
+
58
+ for (var i = 0; i < keys.length; i++) {
59
+ escaped.push(keys[i].replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
60
+ }
61
+
62
+ // Sort longest-first so :name_full matches before :name
63
+ escaped.sort(function(a, b) { return b.length - a.length; });
64
+
65
+ var pattern = new RegExp(":(" + escaped.join("|") + ")", "g");
66
+
67
+ return value.replace(pattern, function(match, k) {
68
+ if (!Object.prototype.hasOwnProperty.call(params, k)) return match;
69
+ var v = params[k];
70
+ return (v !== null && v !== undefined) ? String(v) : match;
71
+ });
72
+ }
73
+
74
+ // --- Internal helpers ---
75
+
76
+ function hasExplicitRanges(segments) {
77
+ for (var i = 0; i < segments.length; i++) {
78
+ if (/^\s*(\{\d+\}|\[\d+,(\d+|\*)\])/.test(segments[i])) return true;
79
+ }
80
+ return false;
81
+ }