tina4-nodejs 3.13.148 → 3.13.150

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 (47) hide show
  1. package/CLAUDE.md +4 -3
  2. package/package.json +1 -1
  3. package/packages/cli/dist/bin.js +10898 -9844
  4. package/packages/cli/src/commands/generate.ts +184 -25
  5. package/packages/core/dist/index.js +10864 -9810
  6. package/packages/core/public/bundled-component-provenance.json +2 -2
  7. package/packages/core/public/js/tina4-dev-admin-split.html +202 -0
  8. package/packages/core/public/js/tina4-dev-admin.min.js +147 -138
  9. package/packages/core/src/devAdmin.ts +105 -1
  10. package/packages/core/src/index.ts +3 -0
  11. package/packages/core/src/messenger.ts +57 -1
  12. package/packages/core/src/metrics.ts +5 -1
  13. package/packages/core/src/plan.ts +1 -1
  14. package/packages/core/src/router.ts +11 -1
  15. package/packages/core/src/server.ts +5 -2
  16. package/packages/core/templates/crud/form.twig +26 -0
  17. package/packages/core/templates/crud/modals.twig +31 -0
  18. package/packages/core/templates/crud/page.twig +401 -0
  19. package/packages/core/templates/crud/table.twig +69 -0
  20. package/packages/frond/dist/index.js +9 -1
  21. package/packages/frond/src/engine.ts +9 -0
  22. package/packages/frond/src/index.ts +1 -1
  23. package/packages/orm/dist/index.js +1283 -219
  24. package/packages/orm/src/adapters/mongodb.ts +209 -34
  25. package/packages/orm/src/autoCrud.ts +9 -0
  26. package/packages/orm/src/crud.ts +273 -0
  27. package/packages/orm/src/crudHelpers.ts +194 -0
  28. package/packages/orm/src/crudRender.ts +202 -0
  29. package/packages/orm/src/crudTable.ts +171 -0
  30. package/packages/orm/src/index.ts +7 -1
  31. package/packages/orm/src/migration.ts +167 -4
  32. package/packages/orm/src/query.ts +99 -40
  33. package/packages/orm/src/sqlTranslator.ts +8 -1
  34. package/packages/orm/src/types.ts +19 -0
  35. package/types/core/src/devAdmin.d.ts +23 -0
  36. package/types/core/src/messenger.d.ts +4 -0
  37. package/types/core/src/router.d.ts +0 -5
  38. package/types/frond/src/engine.d.ts +6 -0
  39. package/types/frond/src/index.d.ts +1 -1
  40. package/types/orm/src/crud.d.ts +82 -0
  41. package/types/orm/src/crudHelpers.d.ts +63 -0
  42. package/types/orm/src/crudRender.d.ts +38 -0
  43. package/types/orm/src/crudTable.d.ts +45 -0
  44. package/types/orm/src/index.d.ts +3 -1
  45. package/types/orm/src/migration.d.ts +0 -18
  46. package/types/orm/src/query.d.ts +0 -12
  47. package/types/orm/src/types.d.ts +19 -0
@@ -0,0 +1,194 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
9
+ /**
10
+ * Crud (ADR-0094) — the pure helpers behind the HTML admin page.
11
+ *
12
+ * No I/O, no templates, no DB: column/label/alignment derivation, safe-sort
13
+ * resolution, the pre-joined table-data hash, the pagination controls, the
14
+ * injected JS config, and the two linear SQL-listing parsers. Kept in their own
15
+ * module so `crud.ts` stays a thin orchestrator and no single file carries the
16
+ * whole feature (the second-pass split by concern).
17
+ */
18
+
19
+ import { escapeHtml } from "../../frond/src/engine.js";
20
+
21
+ /** A BaseModel-like class: the statics Crud reads + the query statics it calls. */
22
+ export interface CrudModel {
23
+ tableName: string;
24
+ fields: Record<string, { type?: string; primaryKey?: boolean; [k: string]: unknown }>;
25
+ fieldMapping?: Record<string, string>;
26
+ softDelete?: boolean;
27
+ tableFilter?: string;
28
+ name?: string;
29
+ all(limit?: number, offset?: number, include?: string[], orderBy?: string): Promise<any>;
30
+ where(conditions: string, params?: unknown[], limit?: number, offset?: number, include?: string[], orderBy?: string): Promise<any>;
31
+ count(conditions?: string, params?: unknown[]): Promise<number>;
32
+ }
33
+
34
+ /** Escape alias used throughout the CRUD HTML assembly. */
35
+ export const h = escapeHtml;
36
+
37
+ /** The whitespace characters the SQL scanners treat as separators (no regex). */
38
+ const SQL_WS = " \t\n\r\f\v";
39
+
40
+ /** The DB column a property name maps to (fieldMapping or the property itself). */
41
+ export function columnOf(model: CrudModel, prop: string): string {
42
+ return model.fieldMapping?.[prop] ?? prop;
43
+ }
44
+
45
+ /** The primary-key property name (the field flagged primaryKey, else "id"). */
46
+ export function primaryKeyProp(model: CrudModel): string {
47
+ for (const [name, def] of Object.entries(model.fields)) {
48
+ if (def.primaryKey) return name;
49
+ }
50
+ return "id";
51
+ }
52
+
53
+ /** The model's declared string/text property names. */
54
+ export function searchableProps(model: CrudModel): string[] {
55
+ return Object.entries(model.fields)
56
+ .filter(([, def]) => def.type === "string" || def.type === "text")
57
+ .map(([name]) => name);
58
+ }
59
+
60
+ /** Column alignment from the field type: numeric right, everything else left. */
61
+ export function columnAlignment(model: CrudModel | null, col: string): string {
62
+ const type = model?.fields?.[col]?.type;
63
+ return type === "integer" || type === "number" || type === "float" || type === "decimal"
64
+ ? "text-end"
65
+ : "text-start";
66
+ }
67
+
68
+ /** Pretty label from a column name: "user_name" => "User Name". */
69
+ export function prettyLabel(col: string): string {
70
+ return String(col)
71
+ .split("_")
72
+ .map((p) => (p ? p.charAt(0).toUpperCase() + p.slice(1) : p))
73
+ .join(" ");
74
+ }
75
+
76
+ /**
77
+ * ADR-0069 safe sort: resolve the requested sort to a declared field PROPERTY
78
+ * (the grid's headers + client state are keyed by property), else the primary
79
+ * key. A request may name the field by its property or its mapped column; the
80
+ * DB column for the ORDER BY is derived with columnOf(). A rendered page ignores
81
+ * a bad sort rather than erroring.
82
+ */
83
+ export function crudSortProp(model: CrudModel, requested: string | undefined, pkProp: string): string {
84
+ const req = (requested ?? "").trim();
85
+ if (req === "") return pkProp;
86
+ if (Object.hasOwn(model.fields, req)) return req;
87
+ for (const name of Object.keys(model.fields)) {
88
+ if (columnOf(model, name) === req) return name;
89
+ }
90
+ return pkProp;
91
+ }
92
+
93
+ /** Read one column value from a record hash (keyed by property name). */
94
+ export function cellValue(record: Record<string, unknown>, col: string): unknown {
95
+ return record[col];
96
+ }
97
+
98
+ /** Build the pre-joined <td> run for one row (every value escaped + aligned). */
99
+ export function buildCells(
100
+ columns: string[],
101
+ record: Record<string, unknown>,
102
+ editable: boolean,
103
+ aligns: string[],
104
+ ): string {
105
+ return columns
106
+ .map((col, index) => {
107
+ const raw = cellValue(record, col);
108
+ const value = h(raw === null || raw === undefined ? "" : raw);
109
+ const css = aligns[index];
110
+ return editable
111
+ ? `<td class="${css}" contenteditable="true" data-field="${h(col)}">${value}</td>`
112
+ : `<td class="${css}">${value}</td>`;
113
+ })
114
+ .join("");
115
+ }
116
+
117
+ /**
118
+ * Drop a trailing ORDER BY / LIMIT clause from each line of a custom listing SQL.
119
+ * Pure string scanning — no backtracking regex.
120
+ */
121
+ export function stripOrderAndLimit(sql: string): string {
122
+ const cut = (line: string, keyword: string): string => {
123
+ const hasNewline = line.endsWith("\n");
124
+ const content = hasNewline ? line.slice(0, -1) : line;
125
+ const at = content.toUpperCase().indexOf(keyword);
126
+ if (at < 0) return line;
127
+ const after = at + keyword.length;
128
+ if (content.length <= after) return line;
129
+ return content.slice(0, at) + (hasNewline ? "\n" : "");
130
+ };
131
+ const src = String(sql);
132
+ const lines: string[] = [];
133
+ let start = 0;
134
+ for (let i = 0; i < src.length; i++) {
135
+ if (src[i] === "\n") { lines.push(src.slice(start, i + 1)); start = i + 1; }
136
+ }
137
+ lines.push(src.slice(start));
138
+ return lines.map((line) => cut(cut(line, "ORDER BY "), "LIMIT ")).join("").trim();
139
+ }
140
+
141
+ /**
142
+ * The column names a custom listing SQL selects (for the search CAST LIKE).
143
+ * Linear string scan: locate the first SELECT / FROM keywords (whitespace
144
+ * bounded, case-insensitive) and split the projection on commas — no
145
+ * backtracking regex over caller-supplied SQL (ReDoS-safe), mirroring the
146
+ * Ruby master's plain-string SQL parsing.
147
+ */
148
+ export function extractColumns(sql: string): string[] {
149
+ const colsStr = selectProjection(sql);
150
+ if (colsStr === null || colsStr === "*") return ["*"];
151
+ return colsStr.split(",").map((c) => columnAlias(c.trim()));
152
+ }
153
+
154
+ /** The text between the first SELECT and its matching FROM, or null. */
155
+ function selectProjection(sql: string): string | null {
156
+ const upper = sql.toUpperCase();
157
+ const selectAt = keywordIndex(upper, "SELECT", 0);
158
+ if (selectAt < 0) return null;
159
+ const colsStart = selectAt + "SELECT".length;
160
+ const fromAt = keywordIndex(upper, "FROM", colsStart);
161
+ if (fromAt < 0) return null;
162
+ return sql.slice(colsStart, fromAt).trim();
163
+ }
164
+
165
+ /** The alias/column name a single projection item resolves to. */
166
+ function columnAlias(trimmed: string): string {
167
+ const asAt = keywordIndex(trimmed.toUpperCase(), "AS", 0);
168
+ if (asAt >= 0) {
169
+ const after = trimmed.slice(asAt + "AS".length).trim();
170
+ let endIdx = 0;
171
+ while (endIdx < after.length && !SQL_WS.includes(after[endIdx]) && after[endIdx] !== ",") endIdx++;
172
+ const word = after.slice(0, endIdx);
173
+ if (word) return word;
174
+ }
175
+ if (trimmed.includes(".")) return trimmed.split(".").pop()!.trim();
176
+ return trimmed;
177
+ }
178
+
179
+ /**
180
+ * Index of a whitespace-delimited keyword at or after `from`, or -1. Linear
181
+ * scan; the keyword must be flanked by a boundary (string edge or whitespace)
182
+ * so "FROM" inside "FROMAGE" never matches.
183
+ */
184
+ function keywordIndex(upperHaystack: string, keyword: string, from: number): number {
185
+ let at = upperHaystack.indexOf(keyword, from);
186
+ while (at >= 0) {
187
+ const afterIdx = at + keyword.length;
188
+ const boundaryBefore = at === 0 || SQL_WS.includes(upperHaystack[at - 1]);
189
+ const boundaryAfter = afterIdx >= upperHaystack.length || SQL_WS.includes(upperHaystack[afterIdx]);
190
+ if (boundaryBefore && boundaryAfter) return at;
191
+ at = upperHaystack.indexOf(keyword, at + 1);
192
+ }
193
+ return -1;
194
+ }
@@ -0,0 +1,202 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
9
+ /**
10
+ * Crud (ADR-0094) — the Frond rendering layer of the HTML admin page.
11
+ *
12
+ * Resolves the crud/<name>.twig templates app-first-then-framework (an app copy
13
+ * at src/templates/crud/ wins over the framework's shipped copy, the same
14
+ * resolution the error pages use), caches one Frond engine per directory, and
15
+ * exposes the two synchronous public generators (generateTable / generateForm)
16
+ * plus the modal shell renderer toCrud uses.
17
+ */
18
+
19
+ import { existsSync } from "node:fs";
20
+ import { resolve as pathResolve, join as pathJoin, dirname } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+ import { Frond, setCspNonceProvider } from "../../frond/src/engine.js";
23
+ import { currentCspNonce } from "../../core/src/csp.js";
24
+ import { h, prettyLabel } from "./crudHelpers.js";
25
+ import { tableData } from "./crudTable.js";
26
+
27
+ /**
28
+ * Framework crud/ templates dir. The templates ship with @tina4/core
29
+ * (packages/core/templates/crud); from this module (packages/orm/{src,dist})
30
+ * that is two levels up then into core/templates — the same relative path in
31
+ * both the monorepo source tree and the published dist layout.
32
+ */
33
+ const FRAMEWORK_TEMPLATES_DIR = pathResolve(
34
+ dirname(fileURLToPath(import.meta.url)), "..", "..", "core", "templates",
35
+ );
36
+
37
+ /** Cache one Frond engine per template directory. */
38
+ const frondCache = new Map<string, InstanceType<typeof Frond>>();
39
+
40
+ function frondFor(dir: string): InstanceType<typeof Frond> {
41
+ let engine = frondCache.get(dir);
42
+ if (!engine) {
43
+ // Make {{ csp_nonce() }} resolve to the current request's nonce (ADR-0088).
44
+ setCspNonceProvider(currentCspNonce);
45
+ engine = new Frond(dir);
46
+ frondCache.set(dir, engine);
47
+ }
48
+ return engine;
49
+ }
50
+
51
+ /**
52
+ * Render a crud/<name>.twig template, app-first-then-framework: an app override
53
+ * at src/templates/crud/<name>.twig wins over the framework's shipped copy (the
54
+ * same resolution the error pages use).
55
+ */
56
+ export function renderCrudTemplate(name: string, data: Record<string, unknown>): string {
57
+ const templateFile = `crud/${name}.twig`;
58
+ const userDir = pathResolve(process.cwd(), "src", "templates");
59
+ if (existsSync(pathJoin(userDir, templateFile))) {
60
+ return frondFor(userDir).render(templateFile, data);
61
+ }
62
+ return frondFor(FRAMEWORK_TEMPLATES_DIR).render(templateFile, data);
63
+ }
64
+
65
+ /** A modal's create/edit form via crud/form.twig. */
66
+ function renderModalForm(mode: string, columns: string[], pk: string, edit: boolean): string {
67
+ const fields = columns.map((col) => {
68
+ const label = prettyLabel(col);
69
+ return {
70
+ id: `${mode}-${col}`,
71
+ name: h(col),
72
+ label: h(label),
73
+ value: "",
74
+ placeholder: h(`Enter ${label.toLowerCase()}`),
75
+ type: "text",
76
+ required_attr: "",
77
+ input: true,
78
+ };
79
+ });
80
+ return renderCrudTemplate("form", {
81
+ wrap: true,
82
+ form_id_attr: ` id="form-${mode}"`,
83
+ action: "",
84
+ form_method: "POST",
85
+ method_override: null,
86
+ edit,
87
+ mode,
88
+ pk: h(pk),
89
+ modal_footer: true,
90
+ submit_button: false,
91
+ fields,
92
+ });
93
+ }
94
+
95
+ /** Render the create/edit/delete modal shell (crud/modals.twig). */
96
+ export function renderModals(editableColumns: string[], pk: string): string {
97
+ return renderCrudTemplate("modals", {
98
+ create_form: renderModalForm("create", editableColumns, pk, false),
99
+ edit_form: renderModalForm("edit", editableColumns, pk, true),
100
+ });
101
+ }
102
+
103
+ /**
104
+ * Render an HTML table fragment from an array of record hashes via crud/table.twig.
105
+ * Inline-editable (contenteditable cells + Save/Delete wired by the template's
106
+ * delegated listener). Synchronous — no model, no DB.
107
+ */
108
+ export function generateTable(
109
+ records: Array<Record<string, unknown>> | null | undefined,
110
+ options: { tableName?: string; primaryKey?: string; editable?: boolean } = {},
111
+ ): string {
112
+ const rows = records ?? [];
113
+ const tableName = String(options.tableName ?? "data");
114
+ const primaryKey = String(options.primaryKey ?? "id");
115
+ const editable = options.editable ?? true;
116
+ const columns = rows.length === 0 ? [] : Object.keys(rows[0]);
117
+
118
+ return renderCrudTemplate(
119
+ "table",
120
+ tableData({
121
+ columns, records: rows, pk: primaryKey, tableName, editable, sortable: false,
122
+ inlineScript: editable, requestPath: "", search: "", sortCol: "", sortDir: "asc",
123
+ page: 1, limit: 10, tableId: `crud-${tableName}`, model: null,
124
+ }),
125
+ );
126
+ }
127
+
128
+ export interface FormFieldDef {
129
+ name: string;
130
+ type?: string;
131
+ label?: string;
132
+ value?: unknown;
133
+ required?: boolean;
134
+ options?: Array<{ value: unknown; label: unknown }>;
135
+ }
136
+
137
+ function buildOptions(options: Array<{ value: unknown; label: unknown }> | undefined, selectedValue: unknown): string {
138
+ return (options ?? [])
139
+ .map((opt) => {
140
+ const selected = String(opt.value) === String(selectedValue) ? " selected" : "";
141
+ return `<option value="${h(opt.value)}"${selected}>${h(opt.label)}</option>`;
142
+ })
143
+ .join("");
144
+ }
145
+
146
+ /** The type-specific input descriptor layered onto the shared base field. */
147
+ function fieldInputShape(field: FormFieldDef, base: Record<string, unknown>): Record<string, unknown> {
148
+ const value = field.value;
149
+ switch (field.type ?? "string") {
150
+ case "text":
151
+ return { ...base, textarea: true };
152
+ case "boolean":
153
+ return { ...base, checkbox: true, checked_attr: value ? " checked" : "" };
154
+ case "select":
155
+ return { ...base, select: true, options_html: buildOptions(field.options, value) };
156
+ case "date":
157
+ return { ...base, input: true, type: "date" };
158
+ case "integer":
159
+ case "number":
160
+ case "float":
161
+ case "decimal":
162
+ return { ...base, input: true, type: "number" };
163
+ default:
164
+ return { ...base, input: true, type: "text" };
165
+ }
166
+ }
167
+
168
+ function buildCustomField(field: FormFieldDef): Record<string, unknown> {
169
+ const name = String(field.name ?? "");
170
+ const label = field.label ?? (name.charAt(0).toUpperCase() + name.slice(1));
171
+ const base: Record<string, unknown> = {
172
+ id: h(name),
173
+ name: h(name),
174
+ label: h(String(label)),
175
+ value: h(field.value ?? ""),
176
+ placeholder: "",
177
+ required_attr: field.required ? " required" : "",
178
+ };
179
+ return fieldInputShape(field, base);
180
+ }
181
+
182
+ /**
183
+ * Render an HTML form from a field-definition array via crud/form.twig.
184
+ * Synchronous — no model, no DB.
185
+ */
186
+ export function generateForm(
187
+ fields: FormFieldDef[],
188
+ options: { action?: string; method?: string; tableName?: string } = {},
189
+ ): string {
190
+ const verb = String(options.method ?? "POST").toUpperCase();
191
+ return renderCrudTemplate("form", {
192
+ wrap: true,
193
+ form_id_attr: "",
194
+ action: h(options.action ?? "/"),
195
+ form_method: h(verb),
196
+ method_override: ["PUT", "PATCH", "DELETE"].includes(verb) ? verb : null,
197
+ edit: false,
198
+ modal_footer: false,
199
+ submit_button: true,
200
+ fields: (fields ?? []).map((f) => buildCustomField(f)),
201
+ });
202
+ }
@@ -0,0 +1,171 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
9
+ /**
10
+ * Crud (ADR-0094) — table + pagination assembly.
11
+ *
12
+ * The sort links, the pagination controls, the injected JS config, and the
13
+ * pre-joined table-data hash the crud/table.twig template consumes. Split from
14
+ * crudHelpers so neither file carries the whole feature (one concern per file).
15
+ */
16
+
17
+ import {
18
+ type CrudModel,
19
+ h,
20
+ columnAlignment,
21
+ prettyLabel,
22
+ cellValue,
23
+ buildCells,
24
+ } from "./crudHelpers.js";
25
+
26
+ export function sortIndicator(sortCol: string, col: string, sortDir: string): string {
27
+ if (String(sortCol) !== String(col)) return "";
28
+ const arrow = sortDir === "asc" ? "&#9650;" : "&#9660;";
29
+ return ` <span class="sort-indicator">${arrow}</span>`;
30
+ }
31
+
32
+ export function sortUrl(
33
+ requestPath: string,
34
+ col: string,
35
+ nextDir: string,
36
+ page: number,
37
+ search: string,
38
+ limit: number,
39
+ ): string {
40
+ const query =
41
+ `sort=${encodeURIComponent(col)}&sort_dir=${nextDir}` +
42
+ `&page=${page}&search=${encodeURIComponent(search)}&limit=${limit}`;
43
+ return h(`${requestPath}?${query}`);
44
+ }
45
+
46
+ export function pageUrl(
47
+ requestPath: string,
48
+ p: number,
49
+ search: string,
50
+ sortCol: string,
51
+ sortDir: string,
52
+ limit: number,
53
+ ): string {
54
+ const query =
55
+ `page=${p}&search=${encodeURIComponent(search)}` +
56
+ `&sort=${encodeURIComponent(sortCol)}&sort_dir=${sortDir}&limit=${limit}`;
57
+ return h(`${requestPath}?${query}`);
58
+ }
59
+
60
+ /** One flat list of pagination controls (Prev, numbered pages, Next). */
61
+ export function pageControls(
62
+ page: number,
63
+ totalPages: number,
64
+ requestPath: string,
65
+ search: string,
66
+ sortCol: string,
67
+ sortDir: string,
68
+ limit: number,
69
+ ): Array<Record<string, unknown>> {
70
+ if (totalPages <= 1) return [];
71
+ const controls: Array<Record<string, unknown>> = [];
72
+ if (page > 1) {
73
+ controls.push({
74
+ label: "Prev", page: page - 1, active: false, inactive: true,
75
+ url: pageUrl(requestPath, page - 1, search, sortCol, sortDir, limit),
76
+ });
77
+ }
78
+ let startPage = Math.max(page - 3, 1);
79
+ const endPage = Math.min(startPage + 6, totalPages);
80
+ startPage = Math.max(endPage - 6, 1);
81
+ for (let p = startPage; p <= endPage; p++) {
82
+ controls.push({
83
+ label: p, page: p, active: p === page, inactive: p !== page,
84
+ url: pageUrl(requestPath, p, search, sortCol, sortDir, limit),
85
+ });
86
+ }
87
+ if (page < totalPages) {
88
+ controls.push({
89
+ label: "Next", page: page + 1, active: false, inactive: true,
90
+ url: pageUrl(requestPath, page + 1, search, sortCol, sortDir, limit),
91
+ });
92
+ }
93
+ return controls;
94
+ }
95
+
96
+ /** The JSON config injected into the page's nonce'd <script>. */
97
+ export function jsConfig(opts: {
98
+ apiPath: string; pk: string; columns: string[]; editable: string[]; model: CrudModel;
99
+ limit: number; search: string; sortCol: string; sortDir: string; page: number;
100
+ }): string {
101
+ const aligns: Record<string, string> = {};
102
+ const labels: Record<string, string> = {};
103
+ for (const col of opts.columns) {
104
+ aligns[col] = columnAlignment(opts.model, col);
105
+ labels[col] = prettyLabel(col);
106
+ }
107
+ const config = {
108
+ api: opts.apiPath,
109
+ pk: opts.pk,
110
+ columns: opts.columns,
111
+ editable: opts.editable,
112
+ aligns,
113
+ labels,
114
+ limit: opts.limit,
115
+ search: opts.search,
116
+ sort: opts.sortCol,
117
+ sort_dir: opts.sortDir,
118
+ page: opts.page,
119
+ };
120
+ // Unicode-escape < > & so the literal is safe inside the <script> element.
121
+ return JSON.stringify(config)
122
+ .replace(/</g, "\\u003c")
123
+ .replace(/>/g, "\\u003e")
124
+ .replace(/&/g, "\\u0026");
125
+ }
126
+
127
+ export interface TableDataOpts {
128
+ columns: string[]; records: Array<Record<string, unknown>>; pk: string; tableName: string;
129
+ editable: boolean; sortable: boolean; inlineScript: boolean; requestPath: string;
130
+ search: string; sortCol: string; sortDir: string; page: number; limit: number;
131
+ tableId: string | null; model: CrudModel | null;
132
+ }
133
+
134
+ /** Build one sortable/plain header cell for the given column. */
135
+ function headerCell(opts: TableDataOpts, col: string, align: string): Record<string, unknown> {
136
+ const header: Record<string, unknown> = { label: h(prettyLabel(col)), align };
137
+ if (!opts.sortable) {
138
+ header.plain = true;
139
+ return header;
140
+ }
141
+ const nextDir = String(opts.sortCol) === String(col) && opts.sortDir === "asc" ? "desc" : "asc";
142
+ header.sortable = true;
143
+ header.col = h(col);
144
+ header.next_dir = nextDir;
145
+ header.url = sortUrl(opts.requestPath, col, nextDir, opts.page, opts.search, opts.limit);
146
+ header.indicator = sortIndicator(opts.sortCol, col, opts.sortDir);
147
+ return header;
148
+ }
149
+
150
+ /** Build the data hash crud/table.twig consumes (headers + pre-joined rows). */
151
+ export function tableData(opts: TableDataOpts): Record<string, unknown> {
152
+ const aligns = opts.columns.map((col) => columnAlignment(opts.model, col));
153
+ const headers = opts.columns.map((col, index) => headerCell(opts, col, aligns[index]));
154
+ const rows = opts.records.map((record) => ({
155
+ id: h(cellValue(record, opts.pk) ?? ""),
156
+ cells: buildCells(opts.columns, record, opts.editable, aligns),
157
+ }));
158
+
159
+ return {
160
+ headers,
161
+ rows,
162
+ empty: opts.records.length === 0,
163
+ colspan: opts.columns.length + 1,
164
+ editable: opts.editable,
165
+ readonly: !opts.editable,
166
+ inline_script: opts.inlineScript,
167
+ table_name: h(opts.tableName),
168
+ table_id_attr: opts.tableId ? ` id="${h(opts.tableId)}"` : "",
169
+ };
170
+ }
171
+
@@ -60,7 +60,13 @@ export {
60
60
  export type { MigrationResult, MigrationStatus } from "./migration.js";
61
61
  export { AutoCrud, generateCrudRoutes, crudEligibleModels } from "./autoCrud.js";
62
62
  export type { AutoCrudOptions } from "./autoCrud.js";
63
- export { buildQuery, parseQueryString, UnknownFieldError, InvalidQueryParameterError } from "./query.js";
63
+
64
+ // Crud (ADR-0094) — the server-rendered HTML admin page over AutoCrud. Moved
65
+ // from @tina4/core so it stays out of core's eager import graph; import it from
66
+ // "tina4-nodejs/orm".
67
+ export { Crud, CRUD, toCrud, registerBackend, generateTable, generateForm, stripOrderAndLimit, _resetCrudRegistrations } from "./crud.js";
68
+ export type { ToCrudOptions, CrudModel, FormFieldDef } from "./crud.js";
69
+ export { buildQuery, parseQueryString, resolveField, resolveFieldColumn, UnknownFieldError, InvalidQueryParameterError } from "./query.js";
64
70
  export { validate } from "./validation.js";
65
71
  export type { ValidationError } from "./validation.js";
66
72
  export { BaseModel, snakeToCamel, camelToSnake } from "./baseModel.js";