tina4-nodejs 3.13.26 → 3.13.29
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/CLAUDE.md +3 -3
- package/package.json +1 -1
- package/packages/core/src/docs.ts +69 -2
- package/packages/frond/src/engine.ts +78 -29
package/CLAUDE.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
# CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.
|
|
1
|
+
# CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.29)
|
|
2
2
|
|
|
3
3
|
> This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
|
|
4
4
|
|
|
5
5
|
## What This Project Is
|
|
6
6
|
|
|
7
|
-
Tina4 for Node.js/TypeScript v3.13.
|
|
7
|
+
Tina4 for Node.js/TypeScript v3.13.27 — The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
|
|
8
8
|
|
|
9
9
|
The philosophy: zero ceremony, batteries included, file system as source of truth.
|
|
10
10
|
|
|
@@ -1117,7 +1117,7 @@ When adding new features, add a corresponding `test/<feature>.test.ts` file.
|
|
|
1117
1117
|
## v3 Features Summary
|
|
1118
1118
|
|
|
1119
1119
|
- **45 built-in features**, zero third-party dependencies
|
|
1120
|
-
- **3,
|
|
1120
|
+
- **3,752 tests** passing across all modules
|
|
1121
1121
|
- **Race-safe `getNextId()`** with atomic sequence table (`tina4_sequences`) for SQLite/MySQL/MSSQL; PostgreSQL auto-creates sequences
|
|
1122
1122
|
- **Frond template engine optimizations**: pre-compiled regexes, lazy loop context (copy-on-write), filter chain caching, path split caching, inline common filters (11-15% speedup)
|
|
1123
1123
|
- **Production server auto-detect**: `npx tina4nodejs serve --production` auto-uses cluster mode
|
package/package.json
CHANGED
|
@@ -931,12 +931,59 @@ export class Docs {
|
|
|
931
931
|
return results.slice(0, Math.max(1, k));
|
|
932
932
|
}
|
|
933
933
|
|
|
934
|
+
/**
|
|
935
|
+
* Resolve a class by exact FQN, documented public import path, or bare name.
|
|
936
|
+
*
|
|
937
|
+
* Node stores the bare class name as the FQN (`Database`), but a developer
|
|
938
|
+
* reading the docs may type the published path (`@tina4/orm.Database`,
|
|
939
|
+
* `orm/Database`) or just `Database`. Match exactly first, then by class
|
|
940
|
+
* name (last path segment), disambiguating by requiring the given segments
|
|
941
|
+
* to appear in the stored FQN/file (framework + shortest wins). Unknown
|
|
942
|
+
* names stay `null` — no false positives.
|
|
943
|
+
*/
|
|
944
|
+
private resolveClassEntry(given: string): InternalEntry | null {
|
|
945
|
+
// 1. exact stored key.
|
|
946
|
+
const exact = this.indexCache!.get(given);
|
|
947
|
+
if (exact && exact.kind === "class") return exact;
|
|
948
|
+
|
|
949
|
+
// Split the request on path/namespace separators (`.`, `/`, `\`), dropping
|
|
950
|
+
// a leading scope marker like `@tina4`.
|
|
951
|
+
const gsegs = given.split(/[./\\]+/).filter((s) => s && s !== "@tina4" && !s.startsWith("@"));
|
|
952
|
+
const gname = (gsegs.length ? gsegs[gsegs.length - 1] : given).toLowerCase();
|
|
953
|
+
|
|
954
|
+
const classes: InternalEntry[] = [];
|
|
955
|
+
for (const e of this.indexCache!.values()) {
|
|
956
|
+
if (e.kind === "class") classes.push(e);
|
|
957
|
+
}
|
|
958
|
+
const cands = classes.filter((e) => e.name.toLowerCase() === gname);
|
|
959
|
+
if (cands.length === 1) return cands[0]; // 2a. unique class-name match
|
|
960
|
+
if (cands.length === 0) return null;
|
|
961
|
+
|
|
962
|
+
// 2b. disambiguate by segment subset — the given dotted/slashed segments
|
|
963
|
+
// must all appear in the stored fqn or file path. Prefer framework, then
|
|
964
|
+
// the shortest fqn, then lexical order.
|
|
965
|
+
const lowSegs = gsegs.map((s) => s.toLowerCase());
|
|
966
|
+
const subset = cands.filter((e) => {
|
|
967
|
+
const hay = `${e.fqn} ${e.file}`.toLowerCase().split(/[./\\\s]+/).filter(Boolean);
|
|
968
|
+
return lowSegs.every((s) => hay.includes(s));
|
|
969
|
+
});
|
|
970
|
+
const pool = subset.length ? subset : cands;
|
|
971
|
+
pool.sort((a, b) => {
|
|
972
|
+
const fa = a.source === "framework" ? 0 : 1;
|
|
973
|
+
const fb = b.source === "framework" ? 0 : 1;
|
|
974
|
+
if (fa !== fb) return fa - fb;
|
|
975
|
+
if (a.fqn.length !== b.fqn.length) return a.fqn.length - b.fqn.length;
|
|
976
|
+
return a.fqn.localeCompare(b.fqn);
|
|
977
|
+
});
|
|
978
|
+
return pool[0];
|
|
979
|
+
}
|
|
980
|
+
|
|
934
981
|
/**
|
|
935
982
|
* Return the full spec for a single class, or `null` if not found.
|
|
936
983
|
*/
|
|
937
984
|
classSpec(fqn: string): ClassSpec | null {
|
|
938
985
|
this.ensureIndex();
|
|
939
|
-
const cls = this.
|
|
986
|
+
const cls = this.resolveClassEntry(fqn);
|
|
940
987
|
if (!cls || cls.kind !== "class") return null;
|
|
941
988
|
const methods: ClassSpec["methods"] = [];
|
|
942
989
|
const prefix = `${cls.fqn}.`;
|
|
@@ -980,7 +1027,7 @@ export class Docs {
|
|
|
980
1027
|
*/
|
|
981
1028
|
methodSpec(classFqn: string, methodName: string): MethodSpec | null {
|
|
982
1029
|
this.ensureIndex();
|
|
983
|
-
const cls = this.
|
|
1030
|
+
const cls = this.resolveClassEntry(classFqn);
|
|
984
1031
|
if (!cls) return null;
|
|
985
1032
|
const key = `${cls.fqn}.${methodName}`;
|
|
986
1033
|
const entry = this.indexCache!.get(key);
|
|
@@ -1184,6 +1231,26 @@ export class Docs {
|
|
|
1184
1231
|
for (const tk of tokens) {
|
|
1185
1232
|
if (tk && doc.includes(tk)) score += 1;
|
|
1186
1233
|
}
|
|
1234
|
+
// Class-qualified queries ("Frond.addTest" / "Frond addTest"): score the
|
|
1235
|
+
// owning class so the qualifier steers ranking instead of being dead weight.
|
|
1236
|
+
const parent = (entry.class ?? "").toLowerCase();
|
|
1237
|
+
if (parent) {
|
|
1238
|
+
// Normalise `.`/`:`/whitespace in the joined query to a single `.` so
|
|
1239
|
+
// "frond.addtest", "frond:addtest" and "frondaddtest" all compare alike.
|
|
1240
|
+
const qNorm = joined.replace(/[:.]+/g, ".");
|
|
1241
|
+
if (qNorm === `${parent}.${name}` || qNorm === `${parent}.${stripped}`) {
|
|
1242
|
+
score += 6; // exact "Class.method" intent — the strongest signal
|
|
1243
|
+
}
|
|
1244
|
+
for (const tk of tokens) {
|
|
1245
|
+
if (tk === parent) score += 2.5;
|
|
1246
|
+
else if (tk && parent.startsWith(tk)) score += 1;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
// Any token that is a whole segment of the fqn (module / class / name).
|
|
1250
|
+
const fqnSegs = new Set(entry.fqn.toLowerCase().split(/[.\s:]+/).filter(Boolean));
|
|
1251
|
+
for (const tk of tokens) {
|
|
1252
|
+
if (tk && fqnSegs.has(tk)) score += 1;
|
|
1253
|
+
}
|
|
1187
1254
|
if (joined && score === 0 && name.includes(joined)) score += 2;
|
|
1188
1255
|
return score;
|
|
1189
1256
|
}
|
|
@@ -156,7 +156,10 @@ const FILTER_WITH_ARGS_RE = /^(\w+)\s*\(([\s\S]*)\)$/;
|
|
|
156
156
|
const FILTER_COMPARISON_RE = /^(\w+)\s*(!=|==|>=|<=|>|<)\s*(.+)$/;
|
|
157
157
|
const TITLE_WORD_RE = /\b\w/g;
|
|
158
158
|
const STRIP_TAGS_RE = /<[^>]+>/g;
|
|
159
|
-
|
|
159
|
+
// printf-style conversions: %%, plus %[flags][width][.precision]type for the
|
|
160
|
+
// common types. Matches PHP sprintf / Python % / Ruby format so the `format`
|
|
161
|
+
// filter renders e.g. `{{ '%.2f' | format(n) }}` as "3.14" across all engines.
|
|
162
|
+
const FORMAT_RE = /%%|%([-+ 0]*)(\d+)?(?:\.(\d+))?([sdifFeEgGxXob])/g;
|
|
160
163
|
const LEADING_WS_RE = /^\s+/;
|
|
161
164
|
const TRAILING_WS_RE = /\s+$/;
|
|
162
165
|
const THOUSANDS_RE = /\B(?=(\d{3})+(?!\d))/g;
|
|
@@ -164,7 +167,7 @@ const THOUSANDS_RE = /\B(?=(\d{3})+(?!\d))/g;
|
|
|
164
167
|
// ── Caches (module level) ─────────────────────────────────────
|
|
165
168
|
|
|
166
169
|
/** Cache for parsed filter chains: expr string -> [variable, filters] */
|
|
167
|
-
const filterChainCache = new Map<string, [string, [string,
|
|
170
|
+
const filterChainCache = new Map<string, [string, [string, unknown[]][]]>();
|
|
168
171
|
|
|
169
172
|
/** Cache for parsed dotted/bracket paths: expr string -> [parts, fromBracket] */
|
|
170
173
|
const pathParseCache = new Map<string, [string[], boolean[]]>();
|
|
@@ -896,7 +899,7 @@ function splitFilterNameAndPath(fname: string): [string, string] {
|
|
|
896
899
|
return [fname, ""];
|
|
897
900
|
}
|
|
898
901
|
|
|
899
|
-
function parseFilterChain(expr: string): [string, [string,
|
|
902
|
+
function parseFilterChain(expr: string): [string, [string, unknown[]][]] {
|
|
900
903
|
// Check cache first
|
|
901
904
|
const cached = filterChainCache.get(expr);
|
|
902
905
|
if (cached) return cached;
|
|
@@ -931,7 +934,7 @@ function parseFilterChain(expr: string): [string, [string, string[]][]] {
|
|
|
931
934
|
if (current) parts.push(current);
|
|
932
935
|
|
|
933
936
|
const variable = parts[0].trim();
|
|
934
|
-
const filters: [string,
|
|
937
|
+
const filters: [string, unknown[]][] = [];
|
|
935
938
|
|
|
936
939
|
for (let i = 1; i < parts.length; i++) {
|
|
937
940
|
const f = parts[i].trim();
|
|
@@ -946,18 +949,48 @@ function parseFilterChain(expr: string): [string, [string, string[]][]] {
|
|
|
946
949
|
}
|
|
947
950
|
}
|
|
948
951
|
|
|
949
|
-
const result: [string, [string,
|
|
952
|
+
const result: [string, [string, unknown[]][]] = [variable, filters];
|
|
950
953
|
filterChainCache.set(expr, result);
|
|
951
954
|
return result;
|
|
952
955
|
}
|
|
953
956
|
|
|
954
|
-
|
|
955
|
-
|
|
957
|
+
/**
|
|
958
|
+
* An UNQUOTED bareword filter argument — a variable reference (or dotted/bracket
|
|
959
|
+
* path), not a literal. Resolved against the render context at apply-time so
|
|
960
|
+
* `{{ '%.2f' | format(price) }}` binds `price` to its value. Quoted literals
|
|
961
|
+
* (`default('fb')`) stay plain strings and are never resolved.
|
|
962
|
+
*/
|
|
963
|
+
class VarRef {
|
|
964
|
+
constructor(public name: string) {}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** Coerce an unquoted arg token to a typed value, or a VarRef if it's a name. */
|
|
968
|
+
function coerceArg(t: string): unknown {
|
|
969
|
+
if (/^-?\d+$/.test(t)) return parseInt(t, 10);
|
|
970
|
+
if (/^-?\d*\.\d+$/.test(t)) return parseFloat(t);
|
|
971
|
+
if (t === "true") return true;
|
|
972
|
+
if (t === "false") return false;
|
|
973
|
+
if (t === "null" || t === "none" || t === "nil") return null;
|
|
974
|
+
if ((t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"))) {
|
|
975
|
+
try { return JSON.parse(t); } catch { /* not JSON — fall through */ }
|
|
976
|
+
}
|
|
977
|
+
return new VarRef(t); // bareword / path → resolve at apply-time
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function parseArgs(raw: string): unknown[] {
|
|
981
|
+
const args: unknown[] = [];
|
|
956
982
|
let current = "";
|
|
957
983
|
let inQuote: string | null = null;
|
|
958
984
|
let wasQuoted = false;
|
|
959
985
|
let depth = 0;
|
|
960
986
|
|
|
987
|
+
const flush = (): void => {
|
|
988
|
+
if (wasQuoted) args.push(current);
|
|
989
|
+
else { const t = current.trim(); if (t !== "") args.push(coerceArg(t)); }
|
|
990
|
+
current = "";
|
|
991
|
+
wasQuoted = false;
|
|
992
|
+
};
|
|
993
|
+
|
|
961
994
|
for (const ch of raw) {
|
|
962
995
|
if (inQuote) {
|
|
963
996
|
if (ch === inQuote) {
|
|
@@ -976,19 +1009,10 @@ function parseArgs(raw: string): string[] {
|
|
|
976
1009
|
}
|
|
977
1010
|
if (ch === "(") { depth++; current += ch; continue; }
|
|
978
1011
|
if (ch === ")") { depth--; current += ch; continue; }
|
|
979
|
-
if (ch === "," && depth === 0) {
|
|
980
|
-
args.push(wasQuoted ? current : current.trim());
|
|
981
|
-
current = "";
|
|
982
|
-
wasQuoted = false;
|
|
983
|
-
continue;
|
|
984
|
-
}
|
|
1012
|
+
if (ch === "," && depth === 0) { flush(); continue; }
|
|
985
1013
|
current += ch;
|
|
986
1014
|
}
|
|
987
|
-
|
|
988
|
-
const final = wasQuoted ? current : current.trim();
|
|
989
|
-
if (final !== "" || wasQuoted) {
|
|
990
|
-
args.push(final);
|
|
991
|
-
}
|
|
1015
|
+
flush();
|
|
992
1016
|
|
|
993
1017
|
return args;
|
|
994
1018
|
}
|
|
@@ -1134,7 +1158,7 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
|
|
|
1134
1158
|
escape: (v) => htmlEscape(String(v)),
|
|
1135
1159
|
e: (v) => htmlEscape(String(v)),
|
|
1136
1160
|
striptags: (v) => String(v).replace(STRIP_TAGS_RE, ""),
|
|
1137
|
-
nl2br: (v) => String(v).replace(/\n/g, "<br
|
|
1161
|
+
nl2br: (v) => new SafeString(htmlEscape(String(v)).replace(/\n/g, "<br />\n")),
|
|
1138
1162
|
abs: (v) => typeof v === "number" ? Math.abs(v) : v,
|
|
1139
1163
|
round: (v, decimals) => {
|
|
1140
1164
|
const d = decimals !== undefined ? parseInt(String(decimals), 10) : 0;
|
|
@@ -1221,15 +1245,38 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
|
|
|
1221
1245
|
},
|
|
1222
1246
|
url_encode: (v) => encodeURIComponent(String(v)),
|
|
1223
1247
|
format: (v, ...args) => {
|
|
1224
|
-
let s = String(v);
|
|
1225
|
-
// Simple %s / %d replacement like Python's % operator
|
|
1226
1248
|
let idx = 0;
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
idx
|
|
1230
|
-
|
|
1249
|
+
return String(v).replace(FORMAT_RE, (m, flags, width, prec, type) => {
|
|
1250
|
+
if (m === "%%") return "%";
|
|
1251
|
+
const arg = args[idx++];
|
|
1252
|
+
const p = prec !== undefined ? parseInt(String(prec), 10) : undefined;
|
|
1253
|
+
let out: string;
|
|
1254
|
+
switch (type) {
|
|
1255
|
+
case "s": out = String(arg ?? ""); break;
|
|
1256
|
+
case "d": case "i": out = String(Math.trunc(Number(arg) || 0)); break;
|
|
1257
|
+
case "f": case "F": out = Number(arg).toFixed(p !== undefined ? p : 6); break;
|
|
1258
|
+
case "e": case "E": {
|
|
1259
|
+
out = Number(arg).toExponential(p !== undefined ? p : 6);
|
|
1260
|
+
if (type === "E") out = out.toUpperCase();
|
|
1261
|
+
break;
|
|
1262
|
+
}
|
|
1263
|
+
case "g": case "G": out = String(Number(arg)); break;
|
|
1264
|
+
case "x": out = Math.trunc(Number(arg) || 0).toString(16); break;
|
|
1265
|
+
case "X": out = Math.trunc(Number(arg) || 0).toString(16).toUpperCase(); break;
|
|
1266
|
+
case "o": out = Math.trunc(Number(arg) || 0).toString(8); break;
|
|
1267
|
+
case "b": out = Math.trunc(Number(arg) || 0).toString(2); break;
|
|
1268
|
+
default: out = String(arg ?? "");
|
|
1269
|
+
}
|
|
1270
|
+
if (width) {
|
|
1271
|
+
const w = parseInt(String(width), 10);
|
|
1272
|
+
if (out.length < w) {
|
|
1273
|
+
const f = String(flags || "");
|
|
1274
|
+
if (f.includes("-")) out = out.padEnd(w, " ");
|
|
1275
|
+
else out = out.padStart(w, f.includes("0") ? "0" : " ");
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
return out;
|
|
1231
1279
|
});
|
|
1232
|
-
return s;
|
|
1233
1280
|
},
|
|
1234
1281
|
dump: (v) => JSON.stringify(v),
|
|
1235
1282
|
formToken: (v?: unknown) => _generateFormToken(v != null ? String(v) : ""),
|
|
@@ -1868,7 +1915,8 @@ export class Frond {
|
|
|
1868
1915
|
private evalVarRaw(expr: string, context: Record<string, unknown>): unknown {
|
|
1869
1916
|
const [varName, filters] = parseFilterChain(expr);
|
|
1870
1917
|
let value = evalExpr(varName, context);
|
|
1871
|
-
for (const [fname,
|
|
1918
|
+
for (const [fname, rawArgs] of filters) {
|
|
1919
|
+
const args = rawArgs.map((a) => (a instanceof VarRef ? evalExpr(a.name, context) : a));
|
|
1872
1920
|
if (fname === "raw" || fname === "safe") continue;
|
|
1873
1921
|
|
|
1874
1922
|
// Filter + property-access chain: `first.groupSummary` — apply
|
|
@@ -1944,7 +1992,8 @@ export class Frond {
|
|
|
1944
1992
|
let value = evalExpr(varName, context);
|
|
1945
1993
|
|
|
1946
1994
|
let isSafe = false;
|
|
1947
|
-
for (const [fname,
|
|
1995
|
+
for (const [fname, rawArgs] of filters) {
|
|
1996
|
+
const args = rawArgs.map((a) => (a instanceof VarRef ? evalExpr(a.name, context) : a));
|
|
1948
1997
|
if (fname === "raw" || fname === "safe") {
|
|
1949
1998
|
isSafe = true;
|
|
1950
1999
|
continue;
|
|
@@ -2020,7 +2069,7 @@ export class Frond {
|
|
|
2020
2069
|
// In production this emits an empty SafeString (no leaked state).
|
|
2021
2070
|
value = renderDump(value);
|
|
2022
2071
|
continue;
|
|
2023
|
-
case "nl2br": value = String(value).replace(/\n/g, "<br
|
|
2072
|
+
case "nl2br": value = new SafeString(htmlEscape(String(value)).replace(/\n/g, "<br />\n")); continue;
|
|
2024
2073
|
case "unique": value = Array.isArray(value) ? [...new Set(value)] : value; continue;
|
|
2025
2074
|
case "sort": value = Array.isArray(value) ? [...value].sort() : value; continue;
|
|
2026
2075
|
case "reverse": value = Array.isArray(value) ? [...value].reverse() : String(value).split("").reverse().join(""); continue;
|