@nitronjs/framework 0.2.26 → 0.3.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 +260 -170
- package/lib/Auth/Auth.js +2 -2
- package/lib/Build/CssBuilder.js +5 -7
- package/lib/Build/EffectivePropUsage.js +174 -0
- package/lib/Build/FactoryTransform.js +1 -21
- package/lib/Build/FileAnalyzer.js +2 -33
- package/lib/Build/Manager.js +390 -58
- package/lib/Build/PropUsageAnalyzer.js +1189 -0
- package/lib/Build/jsxRuntime.js +25 -155
- package/lib/Build/plugins.js +212 -146
- package/lib/Build/propUtils.js +70 -0
- package/lib/Console/Commands/DevCommand.js +30 -10
- package/lib/Console/Commands/MakeCommand.js +8 -1
- package/lib/Console/Output.js +0 -2
- package/lib/Console/Stubs/rsc-consumer.tsx +74 -0
- package/lib/Console/Stubs/vendor-dev.tsx +30 -41
- package/lib/Console/Stubs/vendor.tsx +25 -1
- package/lib/Core/Config.js +0 -6
- package/lib/Core/Paths.js +0 -19
- package/lib/Database/Migration/Checksum.js +0 -3
- package/lib/Database/Migration/MigrationRepository.js +0 -8
- package/lib/Database/Migration/MigrationRunner.js +1 -2
- package/lib/Database/Model.js +19 -11
- package/lib/Database/QueryBuilder.js +25 -4
- package/lib/Database/Schema/Blueprint.js +10 -0
- package/lib/Database/Schema/Manager.js +2 -0
- package/lib/Date/DateTime.js +1 -1
- package/lib/Dev/DevContext.js +44 -0
- package/lib/Dev/DevErrorPage.js +990 -0
- package/lib/Dev/DevIndicator.js +836 -0
- package/lib/HMR/Server.js +16 -37
- package/lib/Http/Server.js +177 -24
- package/lib/Logging/Log.js +34 -2
- package/lib/Mail/Mail.js +41 -10
- package/lib/Route/Router.js +43 -19
- package/lib/Runtime/Entry.js +10 -6
- package/lib/Session/Manager.js +144 -1
- package/lib/Session/Redis.js +117 -0
- package/lib/Session/Session.js +0 -4
- package/lib/Support/Str.js +6 -4
- package/lib/Translation/Lang.js +376 -32
- package/lib/Translation/pluralize.js +81 -0
- package/lib/Validation/MagicBytes.js +120 -0
- package/lib/Validation/Validator.js +46 -29
- package/lib/View/Client/hmr-client.js +100 -90
- package/lib/View/Client/spa.js +121 -50
- package/lib/View/ClientManifest.js +60 -0
- package/lib/View/FlightRenderer.js +100 -0
- package/lib/View/Layout.js +0 -3
- package/lib/View/PropFilter.js +81 -0
- package/lib/View/View.js +230 -495
- package/lib/index.d.ts +22 -1
- package/package.json +3 -2
- package/skeleton/config/app.js +1 -0
- package/skeleton/config/server.js +13 -0
- package/skeleton/config/session.js +4 -0
- package/lib/Build/HydrationBuilder.js +0 -190
- package/lib/Console/Stubs/page-hydration-dev.tsx +0 -72
- package/lib/Console/Stubs/page-hydration.tsx +0 -53
package/lib/Translation/Lang.js
CHANGED
|
@@ -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 #
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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 (
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
343
|
+
return current;
|
|
44
344
|
}
|
|
45
345
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Signatures ordered by total check bytes (offset + bytes.length) descending
|
|
2
|
+
const SIGNATURES = [
|
|
3
|
+
// 12-byte region: WebP (RIFF at 0, WEBP at 8)
|
|
4
|
+
{ regions: [{ bytes: [0x52, 0x49, 0x46, 0x46], offset: 0 }, { bytes: [0x57, 0x45, 0x42, 0x50], offset: 8 }], mime: "image/webp" },
|
|
5
|
+
// 8 bytes
|
|
6
|
+
{ bytes: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], offset: 0, mime: "image/png" },
|
|
7
|
+
// 8-byte region: MP4/ISOBMFF (ftyp at offset 4)
|
|
8
|
+
{ bytes: [0x66, 0x74, 0x79, 0x70], offset: 4, mime: "video/mp4" },
|
|
9
|
+
// 6 bytes
|
|
10
|
+
{ bytes: [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C], offset: 0, mime: "application/x-7z-compressed" },
|
|
11
|
+
{ bytes: [0x52, 0x61, 0x72, 0x21, 0x1A, 0x07], offset: 0, mime: "application/x-rar-compressed" },
|
|
12
|
+
// 4 bytes
|
|
13
|
+
{ bytes: [0x25, 0x50, 0x44, 0x46], offset: 0, mime: "application/pdf" },
|
|
14
|
+
{ bytes: [0x50, 0x4B, 0x03, 0x04], offset: 0, mime: "application/zip" },
|
|
15
|
+
{ bytes: [0x47, 0x49, 0x46, 0x38], offset: 0, mime: "image/gif" },
|
|
16
|
+
{ bytes: [0x1A, 0x45, 0xDF, 0xA3], offset: 0, mime: "video/webm" },
|
|
17
|
+
{ bytes: [0x00, 0x00, 0x01, 0x00], offset: 0, mime: "image/x-icon" },
|
|
18
|
+
// 3 bytes
|
|
19
|
+
{ bytes: [0xFF, 0xD8, 0xFF], offset: 0, mime: "image/jpeg" },
|
|
20
|
+
{ bytes: [0x49, 0x44, 0x33], offset: 0, mime: "audio/mpeg" },
|
|
21
|
+
// 2 bytes
|
|
22
|
+
{ bytes: [0x1F, 0x8B], offset: 0, mime: "application/gzip" },
|
|
23
|
+
{ bytes: [0x42, 0x4D], offset: 0, mime: "image/bmp" }
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const MIME_FAMILIES = [
|
|
27
|
+
// ZIP family (jar/apk excluded)
|
|
28
|
+
["application/zip", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "application/vnd.oasis.opendocument.text", "application/vnd.oasis.opendocument.spreadsheet", "application/vnd.oasis.opendocument.presentation"],
|
|
29
|
+
// MPEG-4/ISOBMFF family
|
|
30
|
+
["video/mp4", "audio/mp4", "audio/m4a", "video/quicktime", "video/3gpp"],
|
|
31
|
+
// EBML family
|
|
32
|
+
["video/webm", "video/x-matroska", "audio/webm"]
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Detect MIME type from buffer magic bytes.
|
|
37
|
+
* Returns null if buffer is empty or no signature matches.
|
|
38
|
+
*/
|
|
39
|
+
export function detectMime(buffer) {
|
|
40
|
+
if (!buffer || buffer.length === 0) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const sig of SIGNATURES) {
|
|
45
|
+
if (sig.regions) {
|
|
46
|
+
let allMatch = true;
|
|
47
|
+
|
|
48
|
+
for (const region of sig.regions) {
|
|
49
|
+
if (buffer.length < region.offset + region.bytes.length) {
|
|
50
|
+
allMatch = false;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (let i = 0; i < region.bytes.length; i++) {
|
|
55
|
+
if (buffer[region.offset + i] !== region.bytes[i]) {
|
|
56
|
+
allMatch = false;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!allMatch) break;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (allMatch) return sig.mime;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
if (buffer.length < sig.offset + sig.bytes.length) continue;
|
|
68
|
+
|
|
69
|
+
let match = true;
|
|
70
|
+
|
|
71
|
+
for (let i = 0; i < sig.bytes.length; i++) {
|
|
72
|
+
if (buffer[sig.offset + i] !== sig.bytes[i]) {
|
|
73
|
+
match = false;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (match) return sig.mime;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Check if two MIME types belong to the same family.
|
|
87
|
+
* ZIP family includes Office XML formats. MPEG-4 family shares ftyp. EBML family shares header.
|
|
88
|
+
*/
|
|
89
|
+
export function isSameMimeFamily(mime1, mime2) {
|
|
90
|
+
if (!mime1 || !mime2) return false;
|
|
91
|
+
|
|
92
|
+
const m1 = mime1.toLowerCase(), m2 = mime2.toLowerCase();
|
|
93
|
+
|
|
94
|
+
if (m1 === m2) return true;
|
|
95
|
+
|
|
96
|
+
for (const family of MIME_FAMILIES) {
|
|
97
|
+
const has1 = family.includes(m1) || (family === MIME_FAMILIES[0] && (m1.startsWith("application/vnd.openxmlformats-officedocument.") || m1.startsWith("application/vnd.oasis.opendocument.")));
|
|
98
|
+
const has2 = family.includes(m2) || (family === MIME_FAMILIES[0] && (m2.startsWith("application/vnd.openxmlformats-officedocument.") || m2.startsWith("application/vnd.oasis.opendocument.")));
|
|
99
|
+
|
|
100
|
+
if (has1 && has2) return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Regex: null bytes, URL-encoded null, bidi/invisible Unicode, control chars
|
|
107
|
+
const UNSAFE_CHARS = /\x00|%00|[\u202A-\u202E\u200E\u200F\u2066-\u2069\u200B-\u200D\uFEFF]|[\x01-\x08\x0B\x0C\x0E-\x1F]/gi;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Strip dangerous characters from filename.
|
|
111
|
+
* Removes null bytes, URL-encoded nulls, Unicode bidi/invisible chars, control chars.
|
|
112
|
+
* Returns "unnamed" if result is empty.
|
|
113
|
+
*/
|
|
114
|
+
export function sanitizeFilename(filename) {
|
|
115
|
+
if (!filename) return "unnamed";
|
|
116
|
+
|
|
117
|
+
const cleaned = filename.replace(UNSAFE_CHARS, "");
|
|
118
|
+
|
|
119
|
+
return cleaned || "unnamed";
|
|
120
|
+
}
|