tina4-nodejs 3.13.71 → 3.13.73

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 (27) hide show
  1. package/CLAUDE.md +3 -3
  2. package/package.json +1 -1
  3. package/packages/core/gallery/auth/src/routes/api/gallery/auth/login/post.ts +2 -2
  4. package/packages/core/gallery/auth/src/routes/api/gallery/auth/verify/get.ts +2 -2
  5. package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +2 -2
  6. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/get.ts +3 -3
  7. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/post.ts +3 -3
  8. package/packages/core/gallery/database/src/routes/api/gallery/db/tables/get.ts +3 -3
  9. package/packages/core/gallery/error-overlay/src/routes/api/gallery/crash/get.ts +1 -1
  10. package/packages/core/gallery/orm/src/routes/api/gallery/products/get.ts +1 -1
  11. package/packages/core/gallery/orm/src/routes/api/gallery/products/post.ts +1 -1
  12. package/packages/core/gallery/queue/src/lib/queueDb.ts +2 -2
  13. package/packages/core/gallery/queue/src/routes/api/gallery/queue/consume/post.ts +1 -1
  14. package/packages/core/gallery/queue/src/routes/api/gallery/queue/fail/post.ts +1 -1
  15. package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +1 -1
  16. package/packages/core/gallery/queue/src/routes/api/gallery/queue/retry/post.ts +1 -1
  17. package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +1 -1
  18. package/packages/core/gallery/queue/src/routes/gallery/queue/get.ts +1 -1
  19. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/get.ts +1 -1
  20. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/post.ts +1 -1
  21. package/packages/core/gallery/templates/src/routes/gallery/page/get.ts +1 -1
  22. package/packages/core/public/js/tina4-dev-admin.js +203 -170
  23. package/packages/core/public/js/tina4-dev-admin.min.js +203 -170
  24. package/packages/core/src/devAdmin.ts +263 -38
  25. package/packages/core/src/request.ts +32 -6
  26. package/packages/frond/src/engine.ts +157 -4
  27. package/packages/orm/src/migration.ts +50 -30
@@ -550,6 +550,13 @@ export class DevAdmin {
550
550
  { method: "POST", pattern: "/__dev/api/supervise/cancel", handler: handleSuperviseStub },
551
551
  // Execute — proxies to the framework_port+2000 Rust agent (SSE passthrough)
552
552
  { method: "POST", pattern: "/__dev/api/execute", handler: handleExecute },
553
+ // Framework-grounding MCP token config — self-contained (.env upsert)
554
+ { method: "GET", pattern: "/__dev/api/grounding/status", handler: handleGroundingStatus },
555
+ { method: "POST", pattern: "/__dev/api/grounding/token", handler: handleGroundingToken },
556
+ // Scaffold run chips — project-level operations (distinct from create)
557
+ { method: "POST", pattern: "/__dev/api/migrate", handler: handleMigrate },
558
+ { method: "POST", pattern: "/__dev/api/test", handler: handleTest },
559
+ { method: "POST", pattern: "/__dev/api/seed/run", handler: handleSeedRun },
553
560
  // File browser / editor
554
561
  { method: "GET", pattern: "/__dev/api/files", handler: handleFiles },
555
562
  { method: "GET", pattern: "/__dev/api/file", handler: handleFileRead },
@@ -922,27 +929,71 @@ const handleMessagesSearch: RouteHandler = (req, res) => {
922
929
 
923
930
  // -- Queue handlers --
924
931
 
925
- const handleQueue: RouteHandler = (req, res) => {
932
+ /**
933
+ * Map a file-backed QueueJob (LiteBackend on-disk shape) to the shared JS
934
+ * dev-admin format the SPA queue panel renders.
935
+ */
936
+ function mapQueueJob(job: any, topic: string, status: string) {
937
+ return {
938
+ id: job?.id ?? "",
939
+ topic: job?.topic ?? topic,
940
+ status,
941
+ attempts: Number(job?.attempts ?? 0) || 0,
942
+ created_at: job?.createdAt ?? job?.created_at ?? "",
943
+ data: job?.payload ?? {},
944
+ };
945
+ }
946
+
947
+ const handleQueue: RouteHandler = async (req, res) => {
926
948
  const url = new URL(req.url ?? "/", "http://localhost");
949
+ const topic = url.searchParams.get("topic") ?? "default";
927
950
  const statusFilter = url.searchParams.get("status") ?? "";
928
- const data = DevQueue.stats();
929
- let jobs = data.jobs;
930
- if (statusFilter) {
931
- jobs = jobs.filter((j) => j.status === statusFilter);
932
- }
933
- // Map to shared JS format: topic, attempts, created_at, data
934
- const mappedJobs = jobs.map((j) => ({
935
- id: j.id,
936
- topic: j.name,
937
- status: j.status,
938
- attempts: 1,
939
- created_at: j.timestamp,
940
- data: j.payload ?? {},
941
- }));
942
- res.json({
943
- stats: { pending: data.pending, completed: data.completed, failed: data.failed, reserved: data.reserved },
944
- jobs: mappedJobs,
945
- });
951
+ try {
952
+ const { Queue } = await import("./queue.js");
953
+ const queue = new Queue({ topic });
954
+
955
+ const stats = {
956
+ pending: queue.size("pending"),
957
+ completed: queue.size("completed"),
958
+ failed: queue.size("failed"),
959
+ reserved: queue.size("reserved"),
960
+ };
961
+
962
+ // Jobs by status — parity with Python's _api_queue: list PENDING by reading
963
+ // the on-disk queue files directly (data/queue/<topic>/*.queue-data), then
964
+ // fold in the file-backed failed() + deadLetters() jobs. This is why real
965
+ // persisted jobs now show even though DevQueue (in-memory) is empty.
966
+ const jobs: Array<ReturnType<typeof mapQueueJob>> = [];
967
+
968
+ if (!statusFilter || statusFilter === "pending") {
969
+ const queueDir = join(process.cwd(), "data", "queue", topic);
970
+ if (existsSync(queueDir)) {
971
+ for (const filename of readdirSync(queueDir).sort()) {
972
+ if (!filename.endsWith(".queue-data")) continue; // skips failed/ + reserved/ subdirs
973
+ try {
974
+ const job = JSON.parse(readFileSync(join(queueDir, filename), "utf-8"));
975
+ jobs.push(mapQueueJob(job, topic, "pending"));
976
+ } catch {
977
+ // skip corrupt files
978
+ }
979
+ }
980
+ }
981
+ }
982
+ if (!statusFilter || statusFilter === "failed") {
983
+ for (const j of queue.failed()) jobs.push(mapQueueJob(j, topic, "failed"));
984
+ }
985
+ if (!statusFilter || statusFilter === "dead") {
986
+ for (const j of queue.deadLetters()) jobs.push(mapQueueJob(j, topic, "dead_letter"));
987
+ }
988
+
989
+ res.json({ stats, jobs });
990
+ } catch (e: any) {
991
+ res.json({
992
+ stats: { pending: 0, completed: 0, failed: 0, reserved: 0 },
993
+ jobs: [],
994
+ error: String(e?.message ?? e),
995
+ });
996
+ }
946
997
  };
947
998
 
948
999
  const handleQueueTopics: RouteHandler = (_req, res) => {
@@ -970,23 +1021,20 @@ const handleQueueTopics: RouteHandler = (_req, res) => {
970
1021
  }
971
1022
  };
972
1023
 
973
- const handleQueueDeadLetters: RouteHandler = (req, res) => {
1024
+ const handleQueueDeadLetters: RouteHandler = async (req, res) => {
974
1025
  const url = new URL(req.url ?? "/", "http://localhost");
975
1026
  const topic = url.searchParams.get("topic") ?? "default";
976
- // DevQueue is in-memory and has no separate dead-letter store yet;
977
- // surface failed jobs as dead letters until the queue backend is wired in.
978
- const data = DevQueue.stats();
979
- const jobs = data.jobs
980
- .filter((j) => j.status === "failed")
981
- .map((j) => ({
982
- id: j.id,
983
- topic: j.name,
984
- status: "dead_letter",
985
- attempts: 1,
986
- created_at: j.timestamp,
987
- data: j.payload ?? {},
988
- }));
989
- res.json({ jobs, count: jobs.length, topic });
1027
+ // Parity with Python's _api_queue_dead_letters: read the file-backed
1028
+ // dead-letter store (data/queue/<topic>/failed/*.queue-data) so real
1029
+ // exhausted-retry jobs surface, not just the empty in-memory DevQueue.
1030
+ try {
1031
+ const { Queue } = await import("./queue.js");
1032
+ const queue = new Queue({ topic });
1033
+ const jobs = queue.deadLetters().map((j) => mapQueueJob(j, topic, "dead_letter"));
1034
+ res.json({ jobs, count: jobs.length, topic });
1035
+ } catch (e: any) {
1036
+ res.json({ jobs: [], count: 0, topic, error: String(e?.message ?? e) });
1037
+ }
990
1038
  };
991
1039
 
992
1040
  const handleQueueRetry: RouteHandler = (_req, res) => {
@@ -1052,17 +1100,53 @@ const handleMailboxClear: RouteHandler = (req, res) => {
1052
1100
  res.json({ cleared: true, folder: folder ?? "all" });
1053
1101
  };
1054
1102
 
1055
- // -- Database handlers (stubs) --
1103
+ // -- Database handlers --
1056
1104
 
1057
- const handleTable: RouteHandler = (req, res) => {
1105
+ /**
1106
+ * Quote a (optionally schema-qualified) table reference for safe interpolation.
1107
+ * Returns null for anything that isn't one or two plain SQL identifiers, so a
1108
+ * hostile ?name= can never reach the SELECT. Mirrors the identifier guard the
1109
+ * sqlite adapter uses for PRAGMA table_info.
1110
+ */
1111
+ function quoteTableRef(name: string): string | null {
1112
+ const parts = name.split(".");
1113
+ if (parts.length === 0 || parts.length > 2) return null;
1114
+ for (const p of parts) {
1115
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(p)) return null;
1116
+ }
1117
+ return parts.map((p) => `"${p}"`).join(".");
1118
+ }
1119
+
1120
+ const handleTable: RouteHandler = async (req, res) => {
1058
1121
  const url = new URL(req.url ?? "/", "http://localhost");
1059
1122
  const name = url.searchParams.get("name") ?? "";
1060
1123
  if (!name) {
1061
1124
  res.json({ error: "Missing table name parameter" });
1062
1125
  return;
1063
1126
  }
1064
- // Stub responseactual implementation will use ORM adapter
1065
- res.json({ table: name, columns: [], rows: [], message: "Database not connected or table not found" });
1127
+ // Real implementationparity with Python's _api_table_info: return the
1128
+ // table's column list + a sample of up to 20 rows via the same DB adapter
1129
+ // `tables`/`query` use.
1130
+ try {
1131
+ const { getAdapter } = await import("../../orm/src/index.js");
1132
+ const db = getAdapter();
1133
+ const columns = db.columns(name);
1134
+ if (!columns.length) {
1135
+ res.json({ table: name, columns: [], rows: [], message: "Database not connected or table not found" });
1136
+ return;
1137
+ }
1138
+ const ref = quoteTableRef(name);
1139
+ if (!ref) {
1140
+ // Columns resolved but the name isn't a plain identifier we'll splice
1141
+ // into SQL — surface the schema without the sample rather than risk it.
1142
+ res.json({ table: name, columns, rows: [], count: 0, message: "Invalid table name for sampling" });
1143
+ return;
1144
+ }
1145
+ const rows = db.fetch(`SELECT * FROM ${ref} LIMIT 20`);
1146
+ res.json({ table: name, columns, rows, count: rows.length });
1147
+ } catch (e) {
1148
+ res.json({ table: name, columns: [], rows: [], message: (e as Error)?.message ?? "Database not connected or table not found" });
1149
+ }
1066
1150
  };
1067
1151
 
1068
1152
  const handleTables: RouteHandler = async (_req, res) => {
@@ -1422,6 +1506,147 @@ const handleChat: RouteHandler = async (req, res) => {
1422
1506
  await proxyToSupervisor(req, res, "/chat");
1423
1507
  };
1424
1508
 
1509
+ // -- Framework-grounding (mcp.tina4.com) token config --
1510
+ //
1511
+ // TINA4_MCP_TOKEN grounds the coder against mcp.tina4.com's tina4_context.
1512
+ // These routes are self-contained: they read/write the token in the project
1513
+ // .env directly (no dependency on the Rust agent being up), matching the
1514
+ // python/php/ruby dev_admin. The agent still resolves the same TINA4_MCP_TOKEN
1515
+ // (process env → .env) when it runs.
1516
+ // GET /__dev/api/grounding/status → {configured, last4, url}
1517
+ // POST /__dev/api/grounding/token → upsert TINA4_MCP_TOKEN in .env
1518
+ const DEFAULT_MCP_URL = "https://mcp.tina4.com";
1519
+
1520
+ /** Resolve a var from the live process env, falling back to the project .env. */
1521
+ function resolveDevEnvVar(key: string): string {
1522
+ const live = process.env[key];
1523
+ if (live !== undefined && live !== "") return live;
1524
+ const envPath = join(process.cwd(), ".env");
1525
+ if (!existsSync(envPath)) return "";
1526
+ for (const line of readFileSync(envPath, "utf-8").split("\n")) {
1527
+ const t = line.trim();
1528
+ if (!t || t.startsWith("#") || !t.includes("=")) continue;
1529
+ const eq = t.indexOf("=");
1530
+ if (t.slice(0, eq).trim() === key) return t.slice(eq + 1).trim();
1531
+ }
1532
+ return "";
1533
+ }
1534
+
1535
+ /** Upsert KEY=value into the project .env (creates the file if absent). */
1536
+ function upsertDevEnvVar(key: string, value: string): void {
1537
+ const envPath = join(process.cwd(), ".env");
1538
+ const lines = existsSync(envPath) ? readFileSync(envPath, "utf-8").split("\n") : [];
1539
+ let found = false;
1540
+ const out: string[] = [];
1541
+ for (const line of lines) {
1542
+ const t = line.trim();
1543
+ if (!t || t.startsWith("#") || !t.includes("=")) { out.push(line); continue; }
1544
+ if (t.slice(0, t.indexOf("=")).trim() === key) { out.push(`${key}=${value}`); found = true; }
1545
+ else out.push(line);
1546
+ }
1547
+ if (!found) out.push(`${key}=${value}`);
1548
+ writeFileSync(envPath, out.join("\n").replace(/\n+$/, "") + "\n");
1549
+ }
1550
+
1551
+ const handleGroundingStatus: RouteHandler = async (_req, res) => {
1552
+ const token = resolveDevEnvVar("TINA4_MCP_TOKEN");
1553
+ const url = resolveDevEnvVar("TINA4_MCP_URL") || DEFAULT_MCP_URL;
1554
+ res.json({ configured: token.length > 0, last4: token ? token.slice(-4) : "", url });
1555
+ };
1556
+ const handleGroundingToken: RouteHandler = async (req, res) => {
1557
+ const body = (req.body as Record<string, unknown>) || {};
1558
+ const token = String(body.token ?? "").trim();
1559
+ try {
1560
+ upsertDevEnvVar("TINA4_MCP_TOKEN", token);
1561
+ // Reflect into the live process env so a follow-up status reads back at once.
1562
+ process.env.TINA4_MCP_TOKEN = token;
1563
+ res.json({ ok: true, configured: token.length > 0, last4: token ? token.slice(-4) : "" });
1564
+ } catch (e) {
1565
+ res.json({ ok: false, error: (e as Error).message }, 500);
1566
+ }
1567
+ };
1568
+
1569
+ // -- Scaffold run chips: migrate / test / seed-all --
1570
+ //
1571
+ // The dev-admin's ▶ Migrate / ▶ Test / ▶ Seed chips are project-level
1572
+ // operations, distinct from the create endpoint (/scaffold/run). Each uses the
1573
+ // framework's own machinery so behaviour matches the CLI.
1574
+
1575
+ /** POST /__dev/api/migrate — apply pending migrations via the ORM's migrate(). */
1576
+ const handleMigrate: RouteHandler = async (_req, res) => {
1577
+ try {
1578
+ const orm = await import("../../orm/src/index.js");
1579
+ const result = await orm.migrate();
1580
+ res.json({
1581
+ ok: result.failed.length === 0,
1582
+ applied: result.applied,
1583
+ skipped: result.skipped,
1584
+ failed: result.failed,
1585
+ });
1586
+ } catch (e) {
1587
+ res.json({ ok: false, error: (e as Error).message }, 500);
1588
+ }
1589
+ };
1590
+
1591
+ /** POST /__dev/api/seed/run — seed every discovered model, FK-ordered. */
1592
+ const handleSeedRun: RouteHandler = async (req, res) => {
1593
+ const body = (req.body as Record<string, unknown>) || {};
1594
+ const count = parseInt(String(body.count ?? "10"), 10) || 10;
1595
+ try {
1596
+ const orm = await import("../../orm/src/index.js");
1597
+ // Models live in src/orm/ (primary) and src/models/ (fallback) — same dirs
1598
+ // the server discovers on startup.
1599
+ const dirs = ["src/orm", "src/models"].map((d) => resolve(process.cwd(), d)).filter((d) => existsSync(d));
1600
+ const classes: unknown[] = [];
1601
+ for (const dir of dirs) {
1602
+ for (const m of await orm.discoverModels(dir)) classes.push(m.modelClass);
1603
+ }
1604
+ if (classes.length === 0) {
1605
+ res.json({ ok: false, error: "No models found in src/orm/ or src/models/" }, 400);
1606
+ return;
1607
+ }
1608
+ const summaries = await orm.seedModels(classes as never[], count);
1609
+ let seeded = 0, failed = 0;
1610
+ for (const s of Object.values(summaries) as Array<{ seeded: number; failed: number }>) {
1611
+ seeded += s.seeded; failed += s.failed;
1612
+ }
1613
+ res.json({ ok: failed === 0, seeded, failed, tables: Object.keys(summaries).length });
1614
+ } catch (e) {
1615
+ res.json({ ok: false, error: (e as Error).message }, 500);
1616
+ }
1617
+ };
1618
+
1619
+ /** POST /__dev/api/test — run the project test suite (`npm test`) and stream back
1620
+ * the combined output + exit code. Bounded timeout so a hung suite can't wedge
1621
+ * the dev server. */
1622
+ const handleTest: RouteHandler = async (_req, res) => {
1623
+ try {
1624
+ const { execFile } = await import("node:child_process");
1625
+ const { promisify } = await import("node:util");
1626
+ const run = promisify(execFile);
1627
+ try {
1628
+ const { stdout, stderr } = await run("npm", ["test"], {
1629
+ cwd: resolve(process.cwd()),
1630
+ timeout: 180_000,
1631
+ encoding: "utf-8",
1632
+ maxBuffer: 8 * 1024 * 1024,
1633
+ });
1634
+ res.json({ ok: true, code: 0, output: `${stdout}${stderr}` });
1635
+ } catch (err) {
1636
+ // Non-zero exit (failing tests) lands here — that's a valid, reportable
1637
+ // result, not a 500. Surface the output + code so the UI shows failures.
1638
+ const e = err as { code?: number; stdout?: string; stderr?: string; message?: string };
1639
+ res.json({
1640
+ ok: false,
1641
+ code: typeof e.code === "number" ? e.code : 1,
1642
+ output: `${e.stdout ?? ""}${e.stderr ?? ""}` || e.message || "tests failed",
1643
+ });
1644
+ }
1645
+ } catch (e) {
1646
+ res.json({ ok: false, error: (e as Error).message }, 500);
1647
+ }
1648
+ };
1649
+
1425
1650
  // -- Threads handlers --
1426
1651
 
1427
1652
  /**
@@ -46,10 +46,36 @@ export function createRequest(req: IncomingMessage): Tina4Request {
46
46
  const host = (Array.isArray(xfHost) ? xfHost[0] : xfHost)
47
47
  ?? (req.headers.host ?? "localhost");
48
48
 
49
- const url = new URL(req.url ?? "/", `${proto}://${host}`);
49
+ // Parse the request-target into path + query. The WHATWG `URL` parser THROWS
50
+ // `ERR_INVALID_URL` on malformed targets like `//`, `///`, and `/\` — and this
51
+ // runs BEFORE the dispatch try/catch, with the uncaughtException net only wired
52
+ // under TINA4_DEBUG, so an unguarded throw crashes the worker in production
53
+ // (unauthenticated remote DoS — scanners send `//` routinely). Guard it: on a
54
+ // parse failure, derive the path/query straight from the raw target so routing
55
+ // proceeds to a normal 404 instead of taking the process down. (#33)
50
56
  const query: Record<string, string> = {};
51
- for (const [key, value] of url.searchParams) {
52
- query[key] = value;
57
+ let path: string;
58
+ let queryString: string;
59
+ let fullUrl: string;
60
+ try {
61
+ const url = new URL(req.url ?? "/", `${proto}://${host}`);
62
+ for (const [key, value] of url.searchParams) {
63
+ query[key] = value;
64
+ }
65
+ path = url.pathname;
66
+ queryString = url.search.replace(/^\?/, "");
67
+ fullUrl = url.toString();
68
+ } catch {
69
+ const rawTarget = req.url ?? "/";
70
+ const questionIdx = rawTarget.indexOf("?");
71
+ path = questionIdx >= 0 ? rawTarget.slice(0, questionIdx) : rawTarget;
72
+ queryString = questionIdx >= 0 ? rawTarget.slice(questionIdx + 1) : "";
73
+ try {
74
+ for (const [key, value] of new URLSearchParams(queryString)) {
75
+ query[key] = value;
76
+ }
77
+ } catch { /* unparseable query — leave query empty, still route the path */ }
78
+ fullUrl = `${proto}://${host}${rawTarget}`;
53
79
  }
54
80
 
55
81
  tReq.params = {};
@@ -58,9 +84,9 @@ export function createRequest(req: IncomingMessage): Tina4Request {
58
84
  // `path` is the URL path only; `queryString` is the raw query without "?".
59
85
  // `url` is overridden from Node's IncomingMessage.url (path+query) to the
60
86
  // full absolute URL — parity with PHP/Python/Ruby.
61
- tReq.path = url.pathname;
62
- tReq.queryString = url.search.replace(/^\?/, "");
63
- tReq.url = url.toString();
87
+ tReq.path = path;
88
+ tReq.queryString = queryString;
89
+ tReq.url = fullUrl;
64
90
  tReq.body = undefined;
65
91
  tReq.files = {};
66
92
  tReq.contentType = (req.headers["content-type"] ?? "") as string;
@@ -628,6 +628,41 @@ function evalExpr(expr: string, context: Record<string, unknown>): unknown {
628
628
  }
629
629
  }
630
630
 
631
+ // Filter pipe: value | filter1 | filter2(args). In Twig `|` binds TIGHTER than
632
+ // concat (`~`), comparisons and arithmetic, but looser than member/function
633
+ // access — so it is resolved here, AFTER those operators have had a chance to
634
+ // split the expression. Handling it inside the expression evaluator (not only
635
+ // at the {{ }} output layer) makes filters work at any nesting depth: concat
636
+ // operands, ternary branches, and parenthesised sub-expressions. This fixes
637
+ // both the `|`-vs-`~` precedence bug and the "pipe inside parens returns empty"
638
+ // defect. (#171)
639
+ if (findOutsideQuotes(expr, "|") >= 0) {
640
+ const [baseExpr, filters] = parseFilterChain(expr);
641
+ if (filters.length > 0) {
642
+ const baseValue = evalExpr(baseExpr, context);
643
+ const applier = (context as {
644
+ __frond_apply_filters__?: (
645
+ value: unknown,
646
+ filters: [string, unknown[]][],
647
+ context: Record<string, unknown>,
648
+ ) => unknown;
649
+ }).__frond_apply_filters__;
650
+ if (applier) {
651
+ return applier(baseValue, filters, context);
652
+ }
653
+ // No Frond instance in context (evalExpr used stand-alone) — apply the
654
+ // built-in filters directly so common cases still resolve.
655
+ let value = baseValue;
656
+ for (const [fname, rawArgs] of filters) {
657
+ if (fname === "raw" || fname === "safe") continue;
658
+ const args = rawArgs.map((a) => (a instanceof VarRef ? evalExpr(a.name, context) : a));
659
+ const fn = BUILTIN_FILTERS[fname];
660
+ if (fn) value = fn(value, ...args);
661
+ }
662
+ return value;
663
+ }
664
+ }
665
+
631
666
  // Function call: name("arg1", "arg2") — supports dotted names like user.t("key")
632
667
  const fnMatch = expr.match(FN_CALL_RE);
633
668
  if (fnMatch) {
@@ -1131,12 +1166,17 @@ function wordwrap(text: string, width: number): string {
1131
1166
  return lines.join("\n");
1132
1167
  }
1133
1168
 
1134
- function numberFormat(value: unknown, decimals: number): string {
1169
+ function numberFormat(
1170
+ value: unknown,
1171
+ decimals: number,
1172
+ decimalPoint = ".",
1173
+ thousandsSep = ",",
1174
+ ): string {
1135
1175
  const num = parseFloat(String(value));
1136
1176
  const fixed = num.toFixed(decimals);
1137
1177
  const [intPart, decPart] = fixed.split(".");
1138
- const formatted = intPart.replace(THOUSANDS_RE, ",");
1139
- return decPart ? `${formatted}.${decPart}` : formatted;
1178
+ const formatted = intPart.replace(THOUSANDS_RE, thousandsSep);
1179
+ return decPart ? `${formatted}${decimalPoint}${decPart}` : formatted;
1140
1180
  }
1141
1181
 
1142
1182
  const BUILTIN_FILTERS: Record<string, FilterFn> = {
@@ -1248,7 +1288,15 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
1248
1288
  return null;
1249
1289
  });
1250
1290
  },
1251
- number_format: (v, decimals) => numberFormat(v, decimals !== undefined ? parseInt(String(decimals), 10) : 0),
1291
+ // Twig signature: number_format(decimals=0, decimalPoint='.', thousandsSep=',').
1292
+ // 1-arg (or no-arg) calls keep the original output; args 2/3 enable localized
1293
+ // formats like `1.234,50`. (#170)
1294
+ number_format: (v, decimals, decimalPoint, thousandsSep) => numberFormat(
1295
+ v,
1296
+ decimals !== undefined ? parseInt(String(decimals), 10) : 0,
1297
+ decimalPoint !== undefined ? String(decimalPoint) : ".",
1298
+ thousandsSep !== undefined ? String(thousandsSep) : ",",
1299
+ ),
1252
1300
  date: (v, fmt) => dateFilter(v, fmt !== undefined ? String(fmt) : "%Y-%m-%d"),
1253
1301
  truncate: (v, length) => {
1254
1302
  const s = String(v);
@@ -1486,6 +1534,13 @@ export class Frond {
1486
1534
  /** Token pre-compilation cache for string templates */
1487
1535
  private compiledStrings = new Map<string, { tokens: Token[]; cachedAt: number }>();
1488
1536
 
1537
+ /**
1538
+ * Bound reference to `applyFilters`, stashed into the render context as
1539
+ * `__frond_apply_filters__` so the module-level `evalExpr` can resolve a
1540
+ * filter pipe using THIS instance's registered filters. Bound once. (#171)
1541
+ */
1542
+ private readonly _applyFiltersBound = this.applyFilters.bind(this);
1543
+
1489
1544
  getTemplateDir(): string { return this.templateDir; }
1490
1545
 
1491
1546
  constructor(templateDir: string = "src/templates") {
@@ -1819,6 +1874,12 @@ export class Frond {
1819
1874
  }
1820
1875
 
1821
1876
  private renderTokens(tokens: Token[], context: Record<string, unknown>): string {
1877
+ // Expose this instance's filter engine to the module-level evalExpr so a
1878
+ // filter pipe resolves at any expression depth with the right (custom)
1879
+ // filters. The currently-rendering engine always wins — an included/macro
1880
+ // template rendered by another engine re-stamps its own here. (#171)
1881
+ (context as { __frond_apply_filters__?: unknown }).__frond_apply_filters__ = this._applyFiltersBound;
1882
+
1822
1883
  const output: string[] = [];
1823
1884
  let i = 0;
1824
1885
 
@@ -1948,6 +2009,78 @@ export class Frond {
1948
2009
  return i;
1949
2010
  }
1950
2011
 
2012
+ /**
2013
+ * Apply a parsed filter chain to an already-evaluated value. This is the
2014
+ * instance-aware filter engine used by `evalExpr` (via the
2015
+ * `__frond_apply_filters__` hook in the render context) so filters resolve
2016
+ * with this Frond's registered/custom filters at ANY nesting depth — inside
2017
+ * concat operands, ternary branches, and parenthesised sub-expressions — not
2018
+ * only at the top-level {{ }} output. Mirrors the filter loop in
2019
+ * `evalVarRaw`: `first`/`last` tail-paths, registered `this.filters`, and the
2020
+ * trailing-comparison form (`length != 1`). Auto-escaping stays the caller's
2021
+ * concern (`evalVarInner`). (#171)
2022
+ */
2023
+ private applyFilters(
2024
+ value: unknown,
2025
+ filters: [string, unknown[]][],
2026
+ context: Record<string, unknown>,
2027
+ ): unknown {
2028
+ for (const [fname, rawArgs] of filters) {
2029
+ const args = rawArgs.map((a) => (a instanceof VarRef ? evalExpr(a.name, context) : a));
2030
+ if (fname === "raw" || fname === "safe") continue;
2031
+
2032
+ // Sandbox: a blocked filter is silently skipped (value passes through
2033
+ // unchanged) — same gate as evalVarInner. applyFilters is reached by the
2034
+ // folded filter pipe in evalExpr (`x|f ~ y`, #171), so without this gate a
2035
+ // non-allow-listed filter would run in sandbox mode.
2036
+ if (this._sandbox && this._allowedFilters !== null && !this._allowedFilters.has(fname)) continue;
2037
+
2038
+ const [realFname, tailPath] = splitFilterNameAndPath(fname);
2039
+ if (tailPath) {
2040
+ let applied = false;
2041
+ if (realFname === "first") {
2042
+ value = Array.isArray(value) ? value[0] ?? null : null;
2043
+ applied = true;
2044
+ } else if (realFname === "last") {
2045
+ value = Array.isArray(value) ? value[value.length - 1] ?? null : null;
2046
+ applied = true;
2047
+ } else if (this.filters[realFname]) {
2048
+ value = this.filters[realFname](value, ...args);
2049
+ applied = true;
2050
+ }
2051
+ if (applied) {
2052
+ value = evalExpr("__frondFilterTmp." + tailPath, { __frondFilterTmp: value });
2053
+ continue;
2054
+ }
2055
+ }
2056
+
2057
+ const fn = this.filters[fname];
2058
+ if (fn) {
2059
+ value = fn(value, ...args);
2060
+ } else {
2061
+ // The filter name may carry a trailing comparison operator, e.g.
2062
+ // "length != 1" — apply the real filter, then evaluate the comparison.
2063
+ const m = fname.match(FILTER_COMPARISON_RE);
2064
+ if (m) {
2065
+ const fn2 = this.filters[m[1]];
2066
+ if (fn2) value = fn2(value, ...args);
2067
+ const right = evalExpr(m[3].trim(), context);
2068
+ switch (m[2]) {
2069
+ case "!=": value = value !== right; break;
2070
+ case "==": value = value === right; break;
2071
+ case ">=": value = (value as number) >= (right as number); break;
2072
+ case "<=": value = (value as number) <= (right as number); break;
2073
+ case ">": value = (value as number) > (right as number); break;
2074
+ case "<": value = (value as number) < (right as number); break;
2075
+ }
2076
+ } else {
2077
+ value = evalExpr(fname, context);
2078
+ }
2079
+ }
2080
+ }
2081
+ return value;
2082
+ }
2083
+
1951
2084
  private evalVar(expr: string, context: Record<string, unknown>): unknown {
1952
2085
  // Check for top-level ternary BEFORE splitting filters so that
1953
2086
  // expressions like ``products|length != 1 ? "s" : ""`` work correctly.
@@ -1974,6 +2107,12 @@ export class Frond {
1974
2107
  const args = rawArgs.map((a) => (a instanceof VarRef ? evalExpr(a.name, context) : a));
1975
2108
  if (fname === "raw" || fname === "safe") continue;
1976
2109
 
2110
+ // Sandbox: a blocked filter is silently skipped (value passes through
2111
+ // unchanged) — same gate as evalVarInner. evalVarRaw is reached by a
2112
+ // ternary condition (`x|f ? a : b`), evalComparison, and set, none of
2113
+ // which gated filters before, so a non-allow-listed filter could run.
2114
+ if (this._sandbox && this._allowedFilters !== null && !this._allowedFilters.has(fname)) continue;
2115
+
1977
2116
  // Filter + property-access chain: `first.groupSummary` — apply
1978
2117
  // the filter, then traverse the path on the result via a
1979
2118
  // synthetic context so evalExpr's dotted resolution does the
@@ -2044,6 +2183,20 @@ export class Frond {
2044
2183
  }
2045
2184
  }
2046
2185
 
2186
+ // Concat precedence (#171): a top-level `~` means the whole expression is a
2187
+ // concatenation, and `|` binds TIGHTER than `~`. parseFilterChain above
2188
+ // wrongly glued the trailing `~ ...` onto the last filter, so evaluate the
2189
+ // WHOLE expression through evalExpr (which resolves the filter pipe at the
2190
+ // correct precedence, at any depth) and only auto-escape the result here.
2191
+ if (findOutsideQuotes(expr, "~") >= 0) {
2192
+ let concatValue = evalExpr(expr, context);
2193
+ if (concatValue instanceof SafeString) return concatValue.value;
2194
+ if (this._autoEscape && typeof concatValue === "string") {
2195
+ concatValue = htmlEscape(concatValue);
2196
+ }
2197
+ return concatValue;
2198
+ }
2199
+
2047
2200
  let value = evalExpr(varName, context);
2048
2201
 
2049
2202
  let isSafe = false;