tina4-nodejs 3.10.92 → 3.10.93
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 +1 -1
- package/packages/core/src/i18n.ts +12 -0
- package/packages/core/src/server.ts +60 -5
- package/packages/frond/src/engine.ts +103 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tina4-nodejs",
|
|
3
|
-
"version": "3.10.
|
|
3
|
+
"version": "3.10.93",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Tina4 for Node.js/TypeScript — 54 built-in features, zero dependencies",
|
|
6
6
|
"keywords": ["tina4", "framework", "web", "api", "orm", "graphql", "websocket", "typescript"],
|
|
@@ -174,6 +174,18 @@ export class I18n {
|
|
|
174
174
|
result[fullKey] = String(value);
|
|
175
175
|
}
|
|
176
176
|
}
|
|
177
|
+
// Add leaf-key aliases: "nav.home" → also store as "home" (first-wins on conflict)
|
|
178
|
+
if (!prefix) {
|
|
179
|
+
for (const [dotKey, val] of Object.entries(result)) {
|
|
180
|
+
const lastDot = dotKey.lastIndexOf(".");
|
|
181
|
+
if (lastDot !== -1) {
|
|
182
|
+
const leafKey = dotKey.substring(lastDot + 1);
|
|
183
|
+
if (!(leafKey in result)) {
|
|
184
|
+
result[leafKey] = val;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
177
189
|
return result;
|
|
178
190
|
}
|
|
179
191
|
}
|
|
@@ -8,7 +8,7 @@ import cluster from "node:cluster";
|
|
|
8
8
|
import os from "node:os";
|
|
9
9
|
import type { Tina4Config, Tina4Request, Tina4Response } from "./types.js";
|
|
10
10
|
import { Router, defaultRouter, runRouteMiddlewares } from "./router.js";
|
|
11
|
-
import { validToken, getPayload } from "./auth.js";
|
|
11
|
+
import { validToken, getPayload, refreshToken } from "./auth.js";
|
|
12
12
|
import { discoverRoutes } from "./routeDiscovery.js";
|
|
13
13
|
import { createRequest } from "./request.js";
|
|
14
14
|
import { createResponse, setDefaultTemplatesDir } from "./response.js";
|
|
@@ -19,6 +19,7 @@ import { createHealthRoute } from "./health.js";
|
|
|
19
19
|
import { rateLimiter } from "./rateLimiter.js";
|
|
20
20
|
import { Log } from "./logger.js";
|
|
21
21
|
import { DevAdmin, RequestInspector } from "./devAdmin.js";
|
|
22
|
+
import { I18n } from "./i18n.js";
|
|
22
23
|
|
|
23
24
|
const __filename = fileURLToPath(import.meta.url);
|
|
24
25
|
const __dirname = dirname(__filename);
|
|
@@ -568,6 +569,22 @@ ${reset}
|
|
|
568
569
|
// Frond not available
|
|
569
570
|
}
|
|
570
571
|
|
|
572
|
+
// Auto-wire i18n → template global t() when locale files exist
|
|
573
|
+
if (frondEngine) {
|
|
574
|
+
const localeDir = resolve(base, process.env.TINA4_LOCALE_DIR ?? "src/locales");
|
|
575
|
+
if (existsSync(localeDir)) {
|
|
576
|
+
try {
|
|
577
|
+
const localeFiles = readdirSync(localeDir).filter((f: string) => f.endsWith(".json"));
|
|
578
|
+
if (localeFiles.length > 0 && !frondEngine.globals?.t) {
|
|
579
|
+
const i18nInstance = new I18n(localeDir, process.env.TINA4_LOCALE ?? "en");
|
|
580
|
+
frondEngine.addGlobal("t", (key: string, params?: Record<string, string>) => i18nInstance.t(key, params));
|
|
581
|
+
}
|
|
582
|
+
} catch {
|
|
583
|
+
// Locale directory unreadable — skip auto-wire
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
571
588
|
// Built-in middleware
|
|
572
589
|
middleware.use(cors());
|
|
573
590
|
middleware.use(requestLogger());
|
|
@@ -807,18 +824,56 @@ ${reset}
|
|
|
807
824
|
if (!proceed || res.raw.writableEnded) return;
|
|
808
825
|
}
|
|
809
826
|
|
|
810
|
-
// Auth enforcement: secure routes require a valid
|
|
827
|
+
// Auth enforcement: secure routes require a valid token
|
|
828
|
+
// Check sources in priority order: Authorization header > body formToken > session token
|
|
811
829
|
// Dev admin routes (/__dev) are always public
|
|
812
830
|
const isDevAdmin = pathname.startsWith("/__dev");
|
|
813
831
|
if (match.secure === true && match.noAuth !== true && !isDevAdmin) {
|
|
814
832
|
const authHeader = req.headers.authorization ?? "";
|
|
815
|
-
const
|
|
816
|
-
|
|
833
|
+
const headerToken = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
|
|
834
|
+
|
|
835
|
+
// Priority 1: Authorization Bearer header
|
|
836
|
+
let resolvedToken = "";
|
|
837
|
+
let tokenSource: "header" | "body" | "session" | "" = "";
|
|
838
|
+
|
|
839
|
+
if (headerToken && validToken(headerToken)) {
|
|
840
|
+
resolvedToken = headerToken;
|
|
841
|
+
tokenSource = "header";
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// Priority 2: formToken from request body
|
|
845
|
+
if (!resolvedToken) {
|
|
846
|
+
const bodyToken = (req.body as Record<string, unknown>)?.formToken as string | undefined;
|
|
847
|
+
if (bodyToken && validToken(bodyToken)) {
|
|
848
|
+
resolvedToken = bodyToken;
|
|
849
|
+
tokenSource = "body";
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Priority 3: Session token
|
|
854
|
+
if (!resolvedToken) {
|
|
855
|
+
const sessionToken = (req as any).session?.get?.("token") as string | undefined;
|
|
856
|
+
if (sessionToken && validToken(sessionToken)) {
|
|
857
|
+
resolvedToken = sessionToken;
|
|
858
|
+
tokenSource = "session";
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (!resolvedToken) {
|
|
817
863
|
res.raw.writeHead(401, { "Content-Type": "application/json" });
|
|
818
864
|
res.raw.end(JSON.stringify({ error: "Unauthorized" }));
|
|
819
865
|
return;
|
|
820
866
|
}
|
|
821
|
-
|
|
867
|
+
|
|
868
|
+
req.user = getPayload(resolvedToken) ?? {};
|
|
869
|
+
|
|
870
|
+
// When body formToken validates, return a FreshToken header with a refreshed JWT
|
|
871
|
+
if (tokenSource === "body") {
|
|
872
|
+
const fresh = refreshToken(resolvedToken);
|
|
873
|
+
if (fresh) {
|
|
874
|
+
res.header("FreshToken", fresh);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
822
877
|
}
|
|
823
878
|
|
|
824
879
|
// Inject path params by name into handler arguments, then request/response
|
|
@@ -357,10 +357,26 @@ function resolveVar(expr: string, context: Record<string, unknown>): unknown {
|
|
|
357
357
|
return null;
|
|
358
358
|
}
|
|
359
359
|
|
|
360
|
+
// Slice syntax: value[1:5], value[:10], value[start:end]
|
|
361
|
+
const isQuotedPart = (part.startsWith('"') && part.endsWith('"')) ||
|
|
362
|
+
(part.startsWith("'") && part.endsWith("'"));
|
|
363
|
+
if (isBracket && part.includes(":") && !isQuotedPart) {
|
|
364
|
+
const sliceParts = part.split(":", 2);
|
|
365
|
+
const sStart = sliceParts[0].trim() ? parseInt(String(evalExpr(sliceParts[0].trim(), context)), 10) : undefined;
|
|
366
|
+
const sEnd = sliceParts[1].trim() ? parseInt(String(evalExpr(sliceParts[1].trim(), context)), 10) : undefined;
|
|
367
|
+
if (Array.isArray(value)) {
|
|
368
|
+
value = (value as unknown[]).slice(sStart ?? 0, sEnd);
|
|
369
|
+
} else if (typeof value === "string") {
|
|
370
|
+
value = (value as string).slice(sStart ?? 0, sEnd);
|
|
371
|
+
} else {
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
|
|
360
377
|
let key: string | number;
|
|
361
378
|
// Check if this part came from bracket access and needs variable resolution
|
|
362
|
-
if (
|
|
363
|
-
(part.startsWith("'") && part.endsWith("'"))) {
|
|
379
|
+
if (isQuotedPart) {
|
|
364
380
|
// Quoted string literal — strip quotes
|
|
365
381
|
key = part.slice(1, -1);
|
|
366
382
|
} else {
|
|
@@ -369,7 +385,7 @@ function resolveVar(expr: string, context: Record<string, unknown>): unknown {
|
|
|
369
385
|
key = asNum;
|
|
370
386
|
} else if (isBracket) {
|
|
371
387
|
// Only resolve as a variable from context for bracket-derived parts
|
|
372
|
-
const resolved = context
|
|
388
|
+
const resolved = evalExpr(part, context);
|
|
373
389
|
key = resolved !== undefined ? String(resolved) : part;
|
|
374
390
|
} else {
|
|
375
391
|
// Dot-derived parts or root — use the part name directly as the key
|
|
@@ -397,10 +413,11 @@ function resolveVar(expr: string, context: Record<string, unknown>): unknown {
|
|
|
397
413
|
function findOutsideQuotes(expr: string, needle: string): number {
|
|
398
414
|
let inQuote: string | null = null;
|
|
399
415
|
let depth = 0;
|
|
416
|
+
let bracketDepth = 0;
|
|
400
417
|
let i = 0;
|
|
401
418
|
while (i <= expr.length - needle.length) {
|
|
402
419
|
const ch = expr[i];
|
|
403
|
-
if ((ch === '"' || ch === "'") && depth === 0) {
|
|
420
|
+
if ((ch === '"' || ch === "'") && depth === 0 && bracketDepth === 0) {
|
|
404
421
|
if (inQuote === null) {
|
|
405
422
|
inQuote = ch;
|
|
406
423
|
} else if (ch === inQuote) {
|
|
@@ -412,7 +429,9 @@ function findOutsideQuotes(expr: string, needle: string): number {
|
|
|
412
429
|
if (inQuote) { i++; continue; }
|
|
413
430
|
if (ch === "(") depth++;
|
|
414
431
|
else if (ch === ")") depth--;
|
|
415
|
-
if (
|
|
432
|
+
else if (ch === "[") bracketDepth++;
|
|
433
|
+
else if (ch === "]") bracketDepth--;
|
|
434
|
+
if (depth === 0 && bracketDepth === 0 && expr.slice(i, i + needle.length) === needle) {
|
|
416
435
|
return i;
|
|
417
436
|
}
|
|
418
437
|
i++;
|
|
@@ -425,10 +444,11 @@ function splitOutsideQuotes(expr: string, sep: string): string[] {
|
|
|
425
444
|
let currentStart = 0;
|
|
426
445
|
let inQuote: string | null = null;
|
|
427
446
|
let depth = 0;
|
|
447
|
+
let bracketDepth = 0;
|
|
428
448
|
let i = 0;
|
|
429
449
|
while (i <= expr.length - sep.length) {
|
|
430
450
|
const ch = expr[i];
|
|
431
|
-
if ((ch === '"' || ch === "'") && depth === 0) {
|
|
451
|
+
if ((ch === '"' || ch === "'") && depth === 0 && bracketDepth === 0) {
|
|
432
452
|
if (inQuote === null) {
|
|
433
453
|
inQuote = ch;
|
|
434
454
|
} else if (ch === inQuote) {
|
|
@@ -440,7 +460,9 @@ function splitOutsideQuotes(expr: string, sep: string): string[] {
|
|
|
440
460
|
if (inQuote) { i++; continue; }
|
|
441
461
|
if (ch === "(") depth++;
|
|
442
462
|
else if (ch === ")") depth--;
|
|
443
|
-
if (
|
|
463
|
+
else if (ch === "[") bracketDepth++;
|
|
464
|
+
else if (ch === "]") bracketDepth--;
|
|
465
|
+
if (depth === 0 && bracketDepth === 0 && expr.slice(i, i + sep.length) === sep) {
|
|
444
466
|
parts.push(expr.slice(currentStart, i));
|
|
445
467
|
i += sep.length;
|
|
446
468
|
currentStart = i;
|
|
@@ -1446,10 +1468,46 @@ export class Frond {
|
|
|
1446
1468
|
|
|
1447
1469
|
private extractBlocks(source: string): Record<string, string> {
|
|
1448
1470
|
const blocks: Record<string, string> = {};
|
|
1449
|
-
const
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1471
|
+
const blockOpen = /\{%[-\s]*block\s+(\w+)\s*[-]?%\}/g;
|
|
1472
|
+
const blockClose = /\{%[-\s]*endblock\s*[-]?%\}/g;
|
|
1473
|
+
|
|
1474
|
+
let pos = 0;
|
|
1475
|
+
while (pos < source.length) {
|
|
1476
|
+
blockOpen.lastIndex = pos;
|
|
1477
|
+
const mOpen = blockOpen.exec(source);
|
|
1478
|
+
if (!mOpen) break;
|
|
1479
|
+
|
|
1480
|
+
const name = mOpen[1];
|
|
1481
|
+
const contentStart = mOpen.index + mOpen[0].length;
|
|
1482
|
+
let depth = 1;
|
|
1483
|
+
let scan = contentStart;
|
|
1484
|
+
|
|
1485
|
+
while (depth > 0 && scan < source.length) {
|
|
1486
|
+
blockOpen.lastIndex = scan;
|
|
1487
|
+
blockClose.lastIndex = scan;
|
|
1488
|
+
const nextOpen = blockOpen.exec(source);
|
|
1489
|
+
const nextClose = blockClose.exec(source);
|
|
1490
|
+
|
|
1491
|
+
if (!nextClose) break; // malformed — no matching endblock
|
|
1492
|
+
|
|
1493
|
+
if (nextOpen && nextOpen.index < nextClose.index) {
|
|
1494
|
+
depth++;
|
|
1495
|
+
scan = nextOpen.index + nextOpen[0].length;
|
|
1496
|
+
} else {
|
|
1497
|
+
depth--;
|
|
1498
|
+
if (depth === 0) {
|
|
1499
|
+
blocks[name] = source.slice(contentStart, nextClose.index);
|
|
1500
|
+
pos = nextClose.index + nextClose[0].length;
|
|
1501
|
+
break;
|
|
1502
|
+
}
|
|
1503
|
+
scan = nextClose.index + nextClose[0].length;
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
if (depth > 0) {
|
|
1508
|
+
// malformed, skip forward
|
|
1509
|
+
pos = contentStart;
|
|
1510
|
+
}
|
|
1453
1511
|
}
|
|
1454
1512
|
return blocks;
|
|
1455
1513
|
}
|
|
@@ -1459,6 +1517,40 @@ export class Frond {
|
|
|
1459
1517
|
context: Record<string, unknown>,
|
|
1460
1518
|
childBlocks: Record<string, string>,
|
|
1461
1519
|
): string {
|
|
1520
|
+
// --- Multi-level extends: check if parent itself extends a grandparent ---
|
|
1521
|
+
const extendsMatch = parentSource.trimStart().match(/\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/);
|
|
1522
|
+
if (extendsMatch) {
|
|
1523
|
+
const grandparentName = extendsMatch[1];
|
|
1524
|
+
const grandparentSource = this.load(grandparentName);
|
|
1525
|
+
|
|
1526
|
+
// Extract block defaults defined in the parent template
|
|
1527
|
+
const parentBlocks = this.extractBlocks(parentSource);
|
|
1528
|
+
|
|
1529
|
+
// Child blocks override parent blocks at the same name
|
|
1530
|
+
const mergedBlocks: Record<string, string> = { ...parentBlocks, ...childBlocks };
|
|
1531
|
+
|
|
1532
|
+
// Resolve nested blocks: if a block value contains {% block inner %} tags,
|
|
1533
|
+
// replace them with mergedBlocks values too
|
|
1534
|
+
const nestedBlockRe = /\{%[-\s]*block\s+(\w+)\s*[-]?%\}([\s\S]*?)\{%[-\s]*endblock\s*[-]?%\}/g;
|
|
1535
|
+
let changed = true;
|
|
1536
|
+
while (changed) {
|
|
1537
|
+
changed = false;
|
|
1538
|
+
for (const name of Object.keys(mergedBlocks)) {
|
|
1539
|
+
const resolved = mergedBlocks[name].replace(nestedBlockRe, (_m, innerName: string, innerDefault: string) => {
|
|
1540
|
+
return mergedBlocks[innerName] ?? innerDefault;
|
|
1541
|
+
});
|
|
1542
|
+
if (resolved !== mergedBlocks[name]) {
|
|
1543
|
+
mergedBlocks[name] = resolved;
|
|
1544
|
+
changed = true;
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
// Recurse up the chain (handles 3+, 4+, ... levels)
|
|
1550
|
+
return this.renderWithBlocks(grandparentSource, context, mergedBlocks);
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
// --- Leaf parent (no extends) — resolve blocks and render ---
|
|
1462
1554
|
const pattern = /\{%[-\s]*block\s+(\w+)\s*[-]?%\}([\s\S]*?)\{%[-\s]*endblock\s*[-]?%\}/g;
|
|
1463
1555
|
const engine = this;
|
|
1464
1556
|
|