tina4-nodejs 3.2.1 → 3.5.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.
Files changed (34) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/packages/cli/src/bin.ts +13 -1
  5. package/packages/cli/src/commands/migrate.ts +19 -5
  6. package/packages/cli/src/commands/migrateCreate.ts +29 -28
  7. package/packages/cli/src/commands/migrateRollback.ts +59 -0
  8. package/packages/cli/src/commands/migrateStatus.ts +62 -0
  9. package/packages/core/public/js/tina4-dev-admin.min.js +1 -1
  10. package/packages/core/public/js/tina4js.min.js +47 -0
  11. package/packages/core/src/auth.ts +44 -10
  12. package/packages/core/src/devAdmin.ts +14 -16
  13. package/packages/core/src/index.ts +10 -3
  14. package/packages/core/src/middleware.ts +232 -2
  15. package/packages/core/src/queue.ts +127 -25
  16. package/packages/core/src/queueBackends/mongoBackend.ts +223 -0
  17. package/packages/core/src/request.ts +3 -3
  18. package/packages/core/src/router.ts +115 -51
  19. package/packages/core/src/server.ts +47 -3
  20. package/packages/core/src/session.ts +29 -1
  21. package/packages/core/src/sessionHandlers/databaseHandler.ts +134 -0
  22. package/packages/core/src/sessionHandlers/redisHandler.ts +230 -0
  23. package/packages/core/src/types.ts +12 -6
  24. package/packages/core/src/websocket.ts +11 -2
  25. package/packages/core/src/websocketConnection.ts +4 -2
  26. package/packages/frond/src/engine.ts +66 -1
  27. package/packages/orm/src/autoCrud.ts +17 -12
  28. package/packages/orm/src/baseModel.ts +99 -21
  29. package/packages/orm/src/database.ts +197 -69
  30. package/packages/orm/src/databaseResult.ts +207 -0
  31. package/packages/orm/src/index.ts +6 -3
  32. package/packages/orm/src/migration.ts +296 -71
  33. package/packages/orm/src/model.ts +1 -0
  34. package/packages/orm/src/types.ts +1 -0
@@ -2,6 +2,7 @@
2
2
  * Tina4 Session — Pluggable session backends, zero core dependencies.
3
3
  *
4
4
  * File-based sessions by default. Redis backend available via raw TCP (no ioredis needed).
5
+ * Database (SQLite) backend available via better-sqlite3.
5
6
  *
6
7
  * import { Session, RedisSessionHandler } from "@tina4/core";
7
8
  *
@@ -14,6 +15,10 @@
14
15
  * redisPort: 6379,
15
16
  * });
16
17
  *
18
+ * // Database backend (SQLite via better-sqlite3)
19
+ * const session = new Session("database");
20
+ * // or: new Session("db");
21
+ *
17
22
  * const id = session.start();
18
23
  * session.set("user", { name: "Alice" });
19
24
  * session.get("user"); // { name: "Alice" }
@@ -27,7 +32,7 @@ import { execFileSync } from "node:child_process";
27
32
  // ── Types ─────────────────────────────────────────────────────────
28
33
 
29
34
  export interface SessionConfig {
30
- /** Session backend type: "file" or "redis" */
35
+ /** Session backend type: "file", "redis", "valkey", "mongo", "database" (or "db") */
31
36
  backend?: string;
32
37
  /** File storage path (default: "data/sessions") */
33
38
  path?: string;
@@ -297,6 +302,11 @@ export class Session {
297
302
  case "redis":
298
303
  this.handler = new RedisSessionHandler(config);
299
304
  break;
305
+ case "redis-npm": {
306
+ const { RedisNpmSessionHandler } = require("./sessionHandlers/redisHandler.js");
307
+ this.handler = new RedisNpmSessionHandler(config);
308
+ break;
309
+ }
300
310
  case "valkey": {
301
311
  const { ValkeySessionHandler } = require("./sessionHandlers/valkeyHandler.js");
302
312
  this.handler = new ValkeySessionHandler(config);
@@ -308,6 +318,12 @@ export class Session {
308
318
  this.handler = new MongoSessionHandler(config);
309
319
  break;
310
320
  }
321
+ case "database":
322
+ case "db": {
323
+ const { DatabaseSessionHandler } = require("./sessionHandlers/databaseHandler.js");
324
+ this.handler = new DatabaseSessionHandler(config);
325
+ break;
326
+ }
311
327
  case "file":
312
328
  default:
313
329
  this.handler = new FileSessionHandler(config?.path);
@@ -382,6 +398,18 @@ export class Session {
382
398
  this.save();
383
399
  }
384
400
 
401
+ /**
402
+ * Clear all session data without destroying the session.
403
+ * The session ID and cookie remain — only the data is wiped.
404
+ */
405
+ clear(): void {
406
+ if (!this.data) return;
407
+ for (const key of Object.keys(this.data)) {
408
+ delete this.data[key];
409
+ }
410
+ this.save();
411
+ }
412
+
385
413
  /**
386
414
  * Destroy the entire session.
387
415
  */
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Tina4 Database Session Handler — SQLite via better-sqlite3, zero extra dependencies.
3
+ *
4
+ * Uses the same `better-sqlite3` library the ORM already depends on.
5
+ * Stores sessions in a `tina4_session` table with JSON data and expiry.
6
+ *
7
+ * Configure via environment variables:
8
+ * DATABASE_URL (default: "sqlite:///data/tina4_sessions.db")
9
+ *
10
+ * The handler dynamically imports `better-sqlite3` and throws a clear
11
+ * error if the package is not installed.
12
+ */
13
+ import { createRequire } from "node:module";
14
+ import type { SessionHandler } from "../session.js";
15
+
16
+ const _require = createRequire(import.meta.url);
17
+
18
+ interface SessionData {
19
+ _created: number;
20
+ _accessed: number;
21
+ [key: string]: unknown;
22
+ }
23
+
24
+ export interface DatabaseSessionConfig {
25
+ /** SQLite database file path (default: extracted from DATABASE_URL or "data/tina4_sessions.db") */
26
+ dbPath?: string;
27
+ }
28
+
29
+ /**
30
+ * Database session handler using better-sqlite3 (synchronous SQLite).
31
+ *
32
+ * Stores session data as JSON in a `tina4_session` table.
33
+ * Expiry is checked on read; expired rows are cleaned up lazily.
34
+ */
35
+ export class DatabaseSessionHandler implements SessionHandler {
36
+ private db: any;
37
+ private initialized = false;
38
+
39
+ constructor(config?: DatabaseSessionConfig) {
40
+ const dbPath = config?.dbPath ?? this.resolveDbPath();
41
+
42
+ let Database: any;
43
+ try {
44
+ Database = _require("better-sqlite3");
45
+ } catch {
46
+ throw new Error(
47
+ "DatabaseSessionHandler requires 'better-sqlite3'. " +
48
+ "Install it with: npm install better-sqlite3"
49
+ );
50
+ }
51
+
52
+ this.db = new Database(dbPath);
53
+ this.db.pragma("journal_mode = WAL");
54
+ }
55
+
56
+ /**
57
+ * Resolve the database file path from DATABASE_URL or use the default.
58
+ */
59
+ private resolveDbPath(): string {
60
+ const url = process.env.DATABASE_URL;
61
+ if (url && url.startsWith("sqlite://")) {
62
+ // sqlite:///path/to/db or sqlite://./relative/path
63
+ return url.replace(/^sqlite:\/\//, "");
64
+ }
65
+ return "data/tina4_sessions.db";
66
+ }
67
+
68
+ /**
69
+ * Ensure the session table exists (called once on first use).
70
+ */
71
+ private ensureTable(): void {
72
+ if (this.initialized) return;
73
+ this.db.exec(`
74
+ CREATE TABLE IF NOT EXISTS tina4_session (
75
+ session_id TEXT PRIMARY KEY,
76
+ data TEXT NOT NULL,
77
+ expires_at REAL NOT NULL
78
+ )
79
+ `);
80
+ this.initialized = true;
81
+ }
82
+
83
+ read(sessionId: string): SessionData | null {
84
+ this.ensureTable();
85
+
86
+ const row = this.db
87
+ .prepare("SELECT data, expires_at FROM tina4_session WHERE session_id = ?")
88
+ .get(sessionId) as { data: string; expires_at: number } | undefined;
89
+
90
+ if (!row) return null;
91
+
92
+ // Check expiry
93
+ const now = Date.now() / 1000;
94
+ if (row.expires_at < now) {
95
+ // Expired — clean up and return null
96
+ this.destroy(sessionId);
97
+ return null;
98
+ }
99
+
100
+ try {
101
+ return JSON.parse(row.data) as SessionData;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ write(sessionId: string, data: SessionData, ttl: number): void {
108
+ this.ensureTable();
109
+
110
+ const json = JSON.stringify(data);
111
+ const expiresAt = (Date.now() / 1000) + (ttl > 0 ? ttl : 3600);
112
+
113
+ const existing = this.db
114
+ .prepare("SELECT 1 FROM tina4_session WHERE session_id = ?")
115
+ .get(sessionId);
116
+
117
+ if (existing) {
118
+ this.db
119
+ .prepare("UPDATE tina4_session SET data = ?, expires_at = ? WHERE session_id = ?")
120
+ .run(json, expiresAt, sessionId);
121
+ } else {
122
+ this.db
123
+ .prepare("INSERT INTO tina4_session (session_id, data, expires_at) VALUES (?, ?, ?)")
124
+ .run(sessionId, json, expiresAt);
125
+ }
126
+ }
127
+
128
+ destroy(sessionId: string): void {
129
+ this.ensureTable();
130
+ this.db
131
+ .prepare("DELETE FROM tina4_session WHERE session_id = ?")
132
+ .run(sessionId);
133
+ }
134
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Tina4 Redis Session Handler — Redis via `redis` npm package (optional dependency).
3
+ *
4
+ * Provides a session handler backed by the official `redis` npm package,
5
+ * complementing the built-in raw-TCP RedisSessionHandler in session.ts.
6
+ *
7
+ * This handler uses synchronous child-process execution (same pattern as
8
+ * valkeyHandler.ts) so it fits the synchronous SessionHandler interface
9
+ * without requiring async refactoring.
10
+ *
11
+ * Configure via environment variables:
12
+ * TINA4_SESSION_REDIS_HOST (default: "127.0.0.1")
13
+ * TINA4_SESSION_REDIS_PORT (default: 6379)
14
+ * TINA4_SESSION_REDIS_URL (optional — full redis:// URL, overrides host/port)
15
+ * TINA4_SESSION_REDIS_PASSWORD (optional)
16
+ * TINA4_SESSION_REDIS_PREFIX (default: "tina4:session:")
17
+ * TINA4_SESSION_REDIS_DB (default: 0)
18
+ */
19
+ import { execFileSync } from "node:child_process";
20
+ import type { SessionHandler } from "../session.js";
21
+
22
+ interface SessionData {
23
+ _created: number;
24
+ _accessed: number;
25
+ [key: string]: unknown;
26
+ }
27
+
28
+ export interface RedisNpmSessionConfig {
29
+ host?: string;
30
+ port?: number;
31
+ url?: string;
32
+ password?: string;
33
+ prefix?: string;
34
+ db?: number;
35
+ }
36
+
37
+ /**
38
+ * Redis session handler using the `redis` npm package.
39
+ *
40
+ * Falls back to raw TCP (RESP protocol) if the `redis` package is not
41
+ * installed, matching the approach used by the Valkey handler.
42
+ *
43
+ * Stores session data as JSON strings with Redis TTL for automatic expiry.
44
+ */
45
+ export class RedisNpmSessionHandler implements SessionHandler {
46
+ private host: string;
47
+ private port: number;
48
+ private url: string;
49
+ private password: string;
50
+ private prefix: string;
51
+ private db: number;
52
+
53
+ constructor(config?: RedisNpmSessionConfig) {
54
+ this.url = config?.url
55
+ ?? process.env.TINA4_SESSION_REDIS_URL
56
+ ?? "";
57
+ this.host = config?.host
58
+ ?? process.env.TINA4_SESSION_REDIS_HOST
59
+ ?? "127.0.0.1";
60
+ this.port = config?.port
61
+ ?? (process.env.TINA4_SESSION_REDIS_PORT
62
+ ? parseInt(process.env.TINA4_SESSION_REDIS_PORT, 10)
63
+ : 6379);
64
+ this.password = config?.password
65
+ ?? process.env.TINA4_SESSION_REDIS_PASSWORD
66
+ ?? "";
67
+ this.prefix = config?.prefix
68
+ ?? process.env.TINA4_SESSION_REDIS_PREFIX
69
+ ?? "tina4:session:";
70
+ this.db = config?.db
71
+ ?? (process.env.TINA4_SESSION_REDIS_DB
72
+ ? parseInt(process.env.TINA4_SESSION_REDIS_DB, 10)
73
+ : 0);
74
+ }
75
+
76
+ /**
77
+ * Execute a Redis command synchronously via a short-lived child process.
78
+ *
79
+ * Attempts to use the `redis` npm package first. If unavailable, falls
80
+ * back to raw TCP (RESP protocol) — same as valkeyHandler.ts.
81
+ */
82
+ private execSync(args: string[]): string {
83
+ const script = `
84
+ const net = require("node:net");
85
+ const host = ${JSON.stringify(this.url || this.host)};
86
+ const port = ${this.port};
87
+ const password = ${JSON.stringify(this.password)};
88
+ const db = ${this.db};
89
+ const useUrl = ${JSON.stringify(!!this.url)};
90
+ const url = ${JSON.stringify(this.url)};
91
+ const args = ${JSON.stringify(args)};
92
+
93
+ // Try the redis npm package first
94
+ let redisAvailable = false;
95
+ try {
96
+ require.resolve("redis");
97
+ redisAvailable = true;
98
+ } catch {}
99
+
100
+ if (redisAvailable) {
101
+ const redis = require("redis");
102
+ (async () => {
103
+ try {
104
+ const clientOpts = useUrl
105
+ ? { url }
106
+ : { socket: { host, port }, password: password || undefined, database: db };
107
+ const client = redis.createClient(clientOpts);
108
+ client.on("error", () => {});
109
+ await client.connect();
110
+
111
+ const cmd = args[0].toUpperCase();
112
+ let result;
113
+ if (cmd === "GET") {
114
+ result = await client.get(args[1]);
115
+ } else if (cmd === "SET") {
116
+ result = await client.set(args[1], args[2]);
117
+ } else if (cmd === "SETEX") {
118
+ result = await client.setEx(args[1], parseInt(args[2], 10), args[3]);
119
+ } else if (cmd === "DEL") {
120
+ result = await client.del(args[1]);
121
+ }
122
+ await client.quit();
123
+
124
+ if (result === null || result === undefined) {
125
+ process.stdout.write("__NULL__");
126
+ } else {
127
+ process.stdout.write(String(result));
128
+ }
129
+ } catch (err) {
130
+ process.stderr.write(err.message);
131
+ process.exit(1);
132
+ }
133
+ })();
134
+ } else {
135
+ // Fallback: raw TCP RESP protocol (no redis package needed)
136
+ const actualHost = useUrl ? (() => {
137
+ try { const u = new URL(url); return u.hostname || "127.0.0.1"; } catch { return "127.0.0.1"; }
138
+ })() : host;
139
+ const actualPort = useUrl ? (() => {
140
+ try { const u = new URL(url); return parseInt(u.port, 10) || 6379; } catch { return 6379; }
141
+ })() : port;
142
+
143
+ function buildCommand(a) {
144
+ let cmd = "*" + a.length + "\\r\\n";
145
+ for (const s of a) cmd += "$" + Buffer.byteLength(s) + "\\r\\n" + s + "\\r\\n";
146
+ return cmd;
147
+ }
148
+
149
+ const sock = net.createConnection({ host: actualHost, port: actualPort }, () => {
150
+ let commands = "";
151
+ if (password) commands += buildCommand(["AUTH", password]);
152
+ if (db !== 0) commands += buildCommand(["SELECT", String(db)]);
153
+ commands += buildCommand(args);
154
+ sock.write(commands);
155
+ });
156
+
157
+ let buffer = Buffer.alloc(0);
158
+ sock.on("data", (chunk) => {
159
+ buffer = Buffer.concat([buffer, chunk]);
160
+ });
161
+ sock.on("end", () => {
162
+ const lines = buffer.toString("utf-8").split("\\r\\n");
163
+ let responses = [];
164
+ let i = 0;
165
+ while (i < lines.length) {
166
+ const line = lines[i];
167
+ if (!line) { i++; continue; }
168
+ if (line.startsWith("+") || line.startsWith("-") || line.startsWith(":")) {
169
+ responses.push(line);
170
+ i++;
171
+ } else if (line.startsWith("$")) {
172
+ const len = parseInt(line.slice(1), 10);
173
+ if (len === -1) { responses.push(null); i++; }
174
+ else { responses.push(lines[i+1] || ""); i += 2; }
175
+ } else { i++; }
176
+ }
177
+ const result = responses[responses.length - 1];
178
+ if (result === null) process.stdout.write("__NULL__");
179
+ else if (typeof result === "string" && result.startsWith("-")) process.stdout.write("__ERR__" + result);
180
+ else process.stdout.write(String(result ?? "__NULL__"));
181
+ });
182
+ sock.on("error", (err) => {
183
+ process.stderr.write(err.message);
184
+ process.exit(1);
185
+ });
186
+ setTimeout(() => { sock.destroy(); process.exit(1); }, 3000);
187
+ }
188
+ `;
189
+
190
+ try {
191
+ const result = execFileSync(process.execPath, ["-e", script], {
192
+ encoding: "utf-8",
193
+ timeout: 5000,
194
+ stdio: ["pipe", "pipe", "pipe"],
195
+ });
196
+ if (result === "__NULL__") return "";
197
+ if (result.startsWith("__ERR__")) return "";
198
+ return result;
199
+ } catch {
200
+ return "";
201
+ }
202
+ }
203
+
204
+ private key(sessionId: string): string {
205
+ return `${this.prefix}${sessionId}`;
206
+ }
207
+
208
+ read(sessionId: string): SessionData | null {
209
+ const raw = this.execSync(["GET", this.key(sessionId)]);
210
+ if (!raw) return null;
211
+ try {
212
+ return JSON.parse(raw) as SessionData;
213
+ } catch {
214
+ return null;
215
+ }
216
+ }
217
+
218
+ write(sessionId: string, data: SessionData, ttl: number): void {
219
+ const json = JSON.stringify(data);
220
+ if (ttl > 0) {
221
+ this.execSync(["SETEX", this.key(sessionId), String(ttl), json]);
222
+ } else {
223
+ this.execSync(["SET", this.key(sessionId), json]);
224
+ }
225
+ }
226
+
227
+ destroy(sessionId: string): void {
228
+ this.execSync(["DEL", this.key(sessionId)]);
229
+ }
230
+ }
@@ -3,8 +3,8 @@ import type { IncomingMessage, ServerResponse } from "node:http";
3
3
  export interface UploadedFile {
4
4
  fieldName: string;
5
5
  filename: string;
6
- contentType: string;
7
- data: Buffer;
6
+ type: string;
7
+ content: Buffer;
8
8
  size: number;
9
9
  }
10
10
 
@@ -69,6 +69,10 @@ export interface RouteDefinition {
69
69
  middlewares?: Middleware[];
70
70
  /** Template file to render when handler returns a plain object */
71
71
  template?: string;
72
+ /** Whether this route requires bearer-token authentication */
73
+ secure?: boolean;
74
+ /** Whether this route's response should be cached */
75
+ cached?: boolean;
72
76
  }
73
77
 
74
78
  export interface RouteMeta {
@@ -103,12 +107,14 @@ export type Middleware = (
103
107
 
104
108
  /**
105
109
  * Handler for WebSocket routes.
106
- * conna connection-like object with send/close methods.
107
- * messagethe incoming text or binary message.
110
+ * connection — object with send/broadcast/close methods and route params.
111
+ * eventone of "open", "message", or "close".
112
+ * data — the incoming text message (only present for "message" events).
108
113
  */
109
114
  export type WebSocketRouteHandler = (
110
- conn: { id: string; send: (data: string) => void; close: () => void },
111
- message: string | Buffer,
115
+ connection: import("./websocketConnection.js").WebSocketConnection,
116
+ event: "open" | "message" | "close",
117
+ data: string,
112
118
  ) => void | Promise<void>;
113
119
 
114
120
  export interface WebSocketRouteDefinition {
@@ -52,6 +52,8 @@ export interface WebSocketClient {
52
52
  ip: string;
53
53
  connectedAt: number;
54
54
  closed: boolean;
55
+ /** The URL path this client connected on (e.g. "/chat", "/notifications"). */
56
+ path: string;
55
57
  }
56
58
 
57
59
  type EventHandler = (...args: unknown[]) => void;
@@ -185,14 +187,20 @@ export class WebSocketServer {
185
187
 
186
188
  /**
187
189
  * Broadcast a message to all connected clients.
190
+ *
191
+ * When `path` is provided, only clients connected on that specific path
192
+ * receive the message (matching PHP's WebSocket::broadcast behaviour).
193
+ * When `path` is omitted/undefined, all clients receive the message
194
+ * (backward compatible).
188
195
  */
189
- broadcast(message: string, excludeIds?: string[]): void {
196
+ broadcast(message: string, excludeIds?: string[], path?: string): void {
190
197
  const frame = buildFrame(OP_TEXT, Buffer.from(message, "utf-8"));
191
198
  const exclude = new Set(excludeIds ?? []);
192
199
 
193
200
  for (const [id, client] of this.clients) {
194
201
  if (exclude.has(id)) continue;
195
202
  if (client.closed) continue;
203
+ if (path !== undefined && client.path !== path) continue;
196
204
  try {
197
205
  client.socket.write(frame);
198
206
  } catch {
@@ -310,7 +318,7 @@ export class WebSocketServer {
310
318
 
311
319
  socket.write(response);
312
320
 
313
- // Create client
321
+ // Create client — track the URL path for path-scoped broadcast
314
322
  const clientId = randomUUID().slice(0, 8);
315
323
  const client: WebSocketClient = {
316
324
  id: clientId,
@@ -318,6 +326,7 @@ export class WebSocketServer {
318
326
  ip: (socket.remoteAddress ?? "unknown"),
319
327
  connectedAt: Date.now(),
320
328
  closed: false,
329
+ path: req.url ?? "/",
321
330
  };
322
331
 
323
332
  this.clients.set(clientId, client);
@@ -5,12 +5,14 @@
5
5
  export interface WebSocketConnection {
6
6
  /** Unique connection identifier */
7
7
  id: string;
8
- /** Send a message to this connection */
8
+ /** Send a message to this connection only */
9
9
  send(message: string): void;
10
- /** Broadcast a message to all other connections on the same path */
10
+ /** Broadcast a message to all connections on the same path (path-scoped) */
11
11
  broadcast(message: string): void;
12
12
  /** Close this connection */
13
13
  close(): void;
14
14
  /** The WebSocket route path this connection is on */
15
15
  path: string;
16
+ /** Route parameters extracted from `{param}` segments in the path */
17
+ params: Record<string, string>;
16
18
  }
@@ -757,8 +757,16 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
757
757
  slug: (v) => String(v).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""),
758
758
  md5: (v) => createHash("md5").update(String(v)).digest("hex"),
759
759
  sha256: (v) => createHash("sha256").update(String(v)).digest("hex"),
760
- base64_encode: (v) => Buffer.from(String(v)).toString("base64"),
760
+ base64_encode: (v) => Buffer.isBuffer(v) ? v.toString("base64") : Buffer.from(String(v)).toString("base64"),
761
761
  base64_decode: (v) => Buffer.from(String(v), "base64").toString("utf-8"),
762
+ data_uri: (v) => {
763
+ if (v && typeof v === "object" && "content" in v) {
764
+ const ct = (v as any).type ?? "application/octet-stream";
765
+ const raw = Buffer.isBuffer((v as any).content) ? (v as any).content : Buffer.from(String((v as any).content));
766
+ return `data:${ct};base64,${raw.toString("base64")}`;
767
+ }
768
+ return String(v);
769
+ },
762
770
  url_encode: (v) => encodeURIComponent(String(v)),
763
771
  format: (v, ...args) => {
764
772
  let s = String(v);
@@ -1155,6 +1163,63 @@ export class Frond {
1155
1163
  }
1156
1164
 
1157
1165
  private evalVar(expr: string, context: Record<string, unknown>): unknown {
1166
+ // Check for top-level ternary BEFORE splitting filters so that
1167
+ // expressions like ``products|length != 1 ? "s" : ""`` work correctly.
1168
+ const ternaryIdx = findTernary(expr);
1169
+ if (ternaryIdx !== -1) {
1170
+ const condPart = expr.slice(0, ternaryIdx).trim();
1171
+ const rest = expr.slice(ternaryIdx + 1);
1172
+ const colonIdx = findColon(rest);
1173
+ if (colonIdx !== -1) {
1174
+ const truePart = rest.slice(0, colonIdx).trim();
1175
+ const falsePart = rest.slice(colonIdx + 1).trim();
1176
+ const cond = this.evalVarRaw(condPart, context);
1177
+ return cond ? this.evalVar(truePart, context) : this.evalVar(falsePart, context);
1178
+ }
1179
+ }
1180
+
1181
+ return this.evalVarInner(expr, context);
1182
+ }
1183
+
1184
+ private evalVarRaw(expr: string, context: Record<string, unknown>): unknown {
1185
+ const [varName, filters] = parseFilterChain(expr);
1186
+ let value = evalExpr(varName, context);
1187
+ for (const [fname, args] of filters) {
1188
+ if (fname === "raw" || fname === "safe") continue;
1189
+ const fn = this.filters[fname];
1190
+ if (fn) {
1191
+ value = fn(value, ...args);
1192
+ } else {
1193
+ // The filter name may include a trailing comparison operator,
1194
+ // e.g. "length != 1". Extract the real filter name and the
1195
+ // comparison suffix, apply the filter, then evaluate the comparison.
1196
+ const m = fname.match(/^(\w+)\s*(!=|==|>=|<=|>|<)\s*(.+)$/);
1197
+ if (m) {
1198
+ const realFilter = m[1];
1199
+ const op = m[2];
1200
+ const rightExpr = m[3].trim();
1201
+ const fn2 = this.filters[realFilter];
1202
+ if (fn2) {
1203
+ value = fn2(value, ...args);
1204
+ }
1205
+ const right = evalExpr(rightExpr, context);
1206
+ switch (op) {
1207
+ case "!=": value = value !== right; break;
1208
+ case "==": value = value === right; break;
1209
+ case ">=": value = (value as number) >= (right as number); break;
1210
+ case "<=": value = (value as number) <= (right as number); break;
1211
+ case ">": value = (value as number) > (right as number); break;
1212
+ case "<": value = (value as number) < (right as number); break;
1213
+ }
1214
+ } else {
1215
+ value = evalExpr(fname, context);
1216
+ }
1217
+ }
1218
+ }
1219
+ return value;
1220
+ }
1221
+
1222
+ private evalVarInner(expr: string, context: Record<string, unknown>): unknown {
1158
1223
  const [varName, filters] = parseFilterChain(expr);
1159
1224
 
1160
1225
  // Sandbox: check variable access