@beechcms/core 0.4.0-preview.1

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 (44) hide show
  1. package/dist/define-seed.d.ts +3 -0
  2. package/dist/define-seed.d.ts.map +1 -0
  3. package/dist/define-seed.js +3 -0
  4. package/dist/engine.d.ts +68 -0
  5. package/dist/engine.d.ts.map +1 -0
  6. package/dist/engine.js +401 -0
  7. package/dist/index.d.ts +19 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +18 -0
  10. package/dist/policies.d.ts +10 -0
  11. package/dist/policies.d.ts.map +1 -0
  12. package/dist/policies.js +28 -0
  13. package/dist/richtext-render.d.ts +11 -0
  14. package/dist/richtext-render.d.ts.map +1 -0
  15. package/dist/richtext-render.js +85 -0
  16. package/dist/richtext.d.ts +11 -0
  17. package/dist/richtext.d.ts.map +1 -0
  18. package/dist/richtext.js +11 -0
  19. package/dist/seeds.d.ts +36 -0
  20. package/dist/seeds.d.ts.map +1 -0
  21. package/dist/seeds.js +179 -0
  22. package/dist/slug-utils.d.ts +18 -0
  23. package/dist/slug-utils.d.ts.map +1 -0
  24. package/dist/slug-utils.js +29 -0
  25. package/dist/types.d.ts +110 -0
  26. package/dist/types.d.ts.map +1 -0
  27. package/dist/types.js +1 -0
  28. package/dist/validation.d.ts +44 -0
  29. package/dist/validation.d.ts.map +1 -0
  30. package/dist/validation.js +571 -0
  31. package/package.json +35 -0
  32. package/src/define-seed.ts +5 -0
  33. package/src/engine.ts +465 -0
  34. package/src/index.ts +19 -0
  35. package/src/policies.test.ts +127 -0
  36. package/src/policies.ts +32 -0
  37. package/src/richtext-render.ts +87 -0
  38. package/src/richtext.ts +16 -0
  39. package/src/seeds.ts +194 -0
  40. package/src/slug-utils.ts +33 -0
  41. package/src/types.ts +121 -0
  42. package/src/validation.ts +667 -0
  43. package/tsconfig.json +14 -0
  44. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,3 @@
1
+ import type { Seed } from './types.js';
2
+ export declare function defineSeed(seed: Seed): Seed;
3
+ //# sourceMappingURL=define-seed.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"define-seed.d.ts","sourceRoot":"","sources":["../src/define-seed.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,YAAY,CAAA;AAEtC,wBAAgB,UAAU,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,CAE3C"}
@@ -0,0 +1,3 @@
1
+ export function defineSeed(seed) {
2
+ return seed;
3
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Botanical Engine: Schema Compiler + Query Builder.
3
+ *
4
+ * In v0.4.0 ogni Seed ha una tabella SQL dedicata (`content_{slug}`) con colonne
5
+ * reali tipizzate. Questo modulo genera il DDL e costruisce query parametrizzate.
6
+ * Non conosce HTTP, auth o UI — è una libreria Node.js pura (C6).
7
+ */
8
+ import type { Seed, Branch, SelectOptions, ParameterizedQuery } from './types.js';
9
+ /**
10
+ * Genera `CREATE TABLE IF NOT EXISTS content_{slug}` con colonne di sistema
11
+ * + una colonna per ogni Branch. Funzione pura: stesso Seed → stesso SQL.
12
+ */
13
+ export declare function generateCreateTable(seed: Seed): string;
14
+ /**
15
+ * Genera la tabella bozze `content_{slug}_drafts` per i Seed con `allowDrafts: true`.
16
+ * Tutte le colonne branch sono nullable (le bozze sono parziali).
17
+ * Ritorna null se il Seed non ha `allowDrafts: true`.
18
+ */
19
+ export declare function generateDraftTable(seed: Seed): string | null;
20
+ /**
21
+ * Genera `ALTER TABLE content_{slug} ADD COLUMN {alias} {type}`.
22
+ * Nuove colonne sono sempre nullable (limite SQLite su ALTER TABLE).
23
+ */
24
+ export declare function generateAddColumn(seed: Seed, branch: Branch): string;
25
+ /**
26
+ * Genera indici B-tree per status, created_at e ogni Branch filtrabile
27
+ * con tipo indicizzabile (text, number, date, boolean).
28
+ */
29
+ export declare function generateIndexes(seed: Seed): string[];
30
+ /**
31
+ * Genera la virtual table FTS5 per i Branch text/richtext indicizzabili.
32
+ * Ritorna null se il Seed non ha branch con search abilitato.
33
+ */
34
+ export declare function generateFtsTable(seed: Seed): string | null;
35
+ /**
36
+ * Genera i 3 trigger SQLite (insert/update/delete) che mantengono la FTS
37
+ * sincronizzata automaticamente — elimina la necessità di syncFts manuale.
38
+ * Ritorna array vuoto se il Seed non ha branch indicizzabili.
39
+ */
40
+ export declare function generateFtsTriggers(seed: Seed): string[];
41
+ /**
42
+ * Costruisce una SELECT parametrizzata su `content_{slug}`.
43
+ * Non usa mai json_extract — ogni colonna è una colonna reale.
44
+ * Colonne sconosciute nei filtri/orderBy vengono ignorate (fail-closed).
45
+ */
46
+ export declare function buildSelectQuery(seed: Seed, options?: SelectOptions): ParameterizedQuery;
47
+ export interface SchemaColumn {
48
+ name: string;
49
+ sqlType: 'TEXT' | 'REAL' | 'INTEGER';
50
+ notNull: boolean;
51
+ isPk: boolean;
52
+ }
53
+ /**
54
+ * Ritorna la lista di colonne attese per la tabella di un Seed.
55
+ * Usato da `beech seed:load --diff` per confrontare schema attuale vs atteso.
56
+ */
57
+ export declare function getExpectedColumns(seed: Seed): SchemaColumn[];
58
+ /**
59
+ * Serializza un valore per la scrittura nel DB.
60
+ * boolean → 0/1 | date → Unix timestamp | json/asset-list → JSON string
61
+ */
62
+ export declare function serializeForDb(branch: Branch, value: unknown): string | number | null;
63
+ /**
64
+ * Deserializza un valore letto dal DB per la risposta API.
65
+ * 0/1 → boolean | Unix timestamp → ISO 8601 | JSON string → object
66
+ */
67
+ export declare function deserializeFromDb(branch: Branch, value: unknown): unknown;
68
+ //# sourceMappingURL=engine.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EACV,IAAI,EACJ,MAAM,EAKN,aAAa,EACb,kBAAkB,EACnB,MAAM,YAAY,CAAA;AA+EnB;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAsBtD;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,GAAG,IAAI,CAuB5D;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAGpE;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,EAAE,CAkBpD;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,GAAG,IAAI,CAc1D;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,EAAE,CAgCxD;AAID;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,GAAE,aAAkB,GAAG,kBAAkB,CAqE5F;AA0CD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAA;IACpC,OAAO,EAAE,OAAO,CAAA;IAChB,IAAI,EAAE,OAAO,CAAA;CACd;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,IAAI,GAAG,YAAY,EAAE,CAc7D;AAID;;;GAGG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,IAAI,CAkCrF;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CA4BzE"}
package/dist/engine.js ADDED
@@ -0,0 +1,401 @@
1
+ const BRANCH_TYPE_SQL = {
2
+ text: { sqlType: 'TEXT' },
3
+ number: { sqlType: 'REAL' },
4
+ boolean: { sqlType: 'INTEGER' },
5
+ date: { sqlType: 'INTEGER' }, // Unix timestamp (seconds)
6
+ json: { sqlType: 'TEXT' }, // JSON serializzato
7
+ richtext: { sqlType: 'TEXT' },
8
+ file: { sqlType: 'TEXT' }, // URL singolo o JSON array di URL
9
+ };
10
+ const SYSTEM_COLUMNS = new Set(['id', 'slug', 'status', 'created_at', 'updated_at']);
11
+ // ---- Private helpers ----
12
+ function tableName(seed) {
13
+ return `content_${seed.slug}`;
14
+ }
15
+ function ftsTableName(seed) {
16
+ return `fts_${seed.slug}`;
17
+ }
18
+ function isValidColumn(seed, col) {
19
+ if (SYSTEM_COLUMNS.has(col))
20
+ return true;
21
+ return seed.branches.some(b => b.alias === col);
22
+ }
23
+ function indexableSearchBranches(seed) {
24
+ return seed.branches.filter(b => (b.type === 'text' || b.type === 'richtext') && b.policies?.search !== false);
25
+ }
26
+ function isAssetListBranch(branch) {
27
+ return branch.type === 'file' && (branch.multiple === true || branch.format === 'asset-list');
28
+ }
29
+ function normalizeHttpUrl(value) {
30
+ if (typeof value !== 'string')
31
+ return null;
32
+ const cleaned = value.trim();
33
+ if (!cleaned)
34
+ return null;
35
+ try {
36
+ const parsed = new URL(cleaned);
37
+ return parsed.protocol.startsWith('http') ? cleaned : null;
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ function parseJsonSafe(value) {
44
+ try {
45
+ return JSON.parse(value);
46
+ }
47
+ catch {
48
+ return value;
49
+ }
50
+ }
51
+ function normalizeAssetListValue(rawValue) {
52
+ const input = typeof rawValue === 'string' ? parseJsonSafe(rawValue) : rawValue;
53
+ const values = Array.isArray(input) ? input : [input];
54
+ const normalized = [];
55
+ for (const item of values) {
56
+ if (item == null)
57
+ continue;
58
+ const direct = normalizeHttpUrl(item);
59
+ if (direct) {
60
+ normalized.push(direct);
61
+ continue;
62
+ }
63
+ if (typeof item === 'object' && !Array.isArray(item)) {
64
+ const fromObj = normalizeHttpUrl(item.url);
65
+ if (fromObj)
66
+ normalized.push(fromObj);
67
+ }
68
+ }
69
+ return [...new Set(normalized)];
70
+ }
71
+ // ---- DDL Generators ----
72
+ /**
73
+ * Genera `CREATE TABLE IF NOT EXISTS content_{slug}` con colonne di sistema
74
+ * + una colonna per ogni Branch. Funzione pura: stesso Seed → stesso SQL.
75
+ */
76
+ export function generateCreateTable(seed) {
77
+ const table = tableName(seed);
78
+ const lines = [
79
+ `CREATE TABLE IF NOT EXISTS ${table} (`,
80
+ ` id TEXT NOT NULL PRIMARY KEY,`,
81
+ ` slug TEXT NOT NULL UNIQUE,`,
82
+ ` status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'review', 'published', 'archived')),`,
83
+ ];
84
+ for (const branch of seed.branches) {
85
+ const { sqlType } = BRANCH_TYPE_SQL[branch.type];
86
+ let col = ` ${branch.alias} ${sqlType}`;
87
+ if (branch.requiredOnCreate)
88
+ col += ' NOT NULL';
89
+ if (branch.type === 'boolean')
90
+ col += ` CHECK (${branch.alias} IN (0, 1))`;
91
+ lines.push(col + ',');
92
+ }
93
+ lines.push(` created_at INTEGER NOT NULL DEFAULT (unixepoch()),`);
94
+ lines.push(` updated_at INTEGER NOT NULL DEFAULT (unixepoch())`);
95
+ lines.push(`);`);
96
+ return lines.join('\n');
97
+ }
98
+ /**
99
+ * Genera la tabella bozze `content_{slug}_drafts` per i Seed con `allowDrafts: true`.
100
+ * Tutte le colonne branch sono nullable (le bozze sono parziali).
101
+ * Ritorna null se il Seed non ha `allowDrafts: true`.
102
+ */
103
+ export function generateDraftTable(seed) {
104
+ if (!seed.allowDrafts)
105
+ return null;
106
+ const table = `content_${seed.slug}_drafts`;
107
+ const mainTable = `content_${seed.slug}`;
108
+ const lines = [
109
+ `CREATE TABLE IF NOT EXISTS ${table} (`,
110
+ ` entry_id TEXT NOT NULL PRIMARY KEY`,
111
+ ` REFERENCES ${mainTable}(id) ON DELETE CASCADE,`,
112
+ ];
113
+ for (const branch of seed.branches) {
114
+ const { sqlType } = BRANCH_TYPE_SQL[branch.type];
115
+ let col = ` ${branch.alias} ${sqlType}`;
116
+ // boolean CHECK: in SQLite, NULL IN (0,1) → NULL, che passa il CHECK (solo FALSE lo fallisce)
117
+ if (branch.type === 'boolean')
118
+ col += ` CHECK (${branch.alias} IN (0, 1))`;
119
+ lines.push(col + ',');
120
+ }
121
+ lines.push(` updated_at INTEGER NOT NULL DEFAULT (unixepoch())`);
122
+ lines.push(`);`);
123
+ return lines.join('\n');
124
+ }
125
+ /**
126
+ * Genera `ALTER TABLE content_{slug} ADD COLUMN {alias} {type}`.
127
+ * Nuove colonne sono sempre nullable (limite SQLite su ALTER TABLE).
128
+ */
129
+ export function generateAddColumn(seed, branch) {
130
+ const { sqlType } = BRANCH_TYPE_SQL[branch.type];
131
+ return `ALTER TABLE ${tableName(seed)} ADD COLUMN ${branch.alias} ${sqlType};`;
132
+ }
133
+ /**
134
+ * Genera indici B-tree per status, created_at e ogni Branch filtrabile
135
+ * con tipo indicizzabile (text, number, date, boolean).
136
+ */
137
+ export function generateIndexes(seed) {
138
+ const table = tableName(seed);
139
+ const slug = seed.slug;
140
+ const indexes = [
141
+ `CREATE INDEX IF NOT EXISTS idx_${slug}_status ON ${table}(status);`,
142
+ `CREATE INDEX IF NOT EXISTS idx_${slug}_created_at ON ${table}(created_at);`,
143
+ ];
144
+ for (const branch of seed.branches) {
145
+ if (branch.policies?.filter === false)
146
+ continue;
147
+ if (['text', 'number', 'date', 'boolean'].includes(branch.type)) {
148
+ indexes.push(`CREATE INDEX IF NOT EXISTS idx_${slug}_${branch.alias} ON ${table}(${branch.alias});`);
149
+ }
150
+ }
151
+ return indexes;
152
+ }
153
+ /**
154
+ * Genera la virtual table FTS5 per i Branch text/richtext indicizzabili.
155
+ * Ritorna null se il Seed non ha branch con search abilitato.
156
+ */
157
+ export function generateFtsTable(seed) {
158
+ const rtBranches = indexableSearchBranches(seed);
159
+ if (rtBranches.length === 0)
160
+ return null;
161
+ const ftsTable = ftsTableName(seed);
162
+ const cols = rtBranches.map(b => ` ${b.alias}`).join(',\n');
163
+ return [
164
+ `CREATE VIRTUAL TABLE IF NOT EXISTS ${ftsTable} USING fts5(`,
165
+ ` entry_id UNINDEXED,`,
166
+ `${cols},`,
167
+ ` tokenize = 'unicode61'`,
168
+ `);`,
169
+ ].join('\n');
170
+ }
171
+ /**
172
+ * Genera i 3 trigger SQLite (insert/update/delete) che mantengono la FTS
173
+ * sincronizzata automaticamente — elimina la necessità di syncFts manuale.
174
+ * Ritorna array vuoto se il Seed non ha branch indicizzabili.
175
+ */
176
+ export function generateFtsTriggers(seed) {
177
+ const rtBranches = indexableSearchBranches(seed);
178
+ if (rtBranches.length === 0)
179
+ return [];
180
+ const table = tableName(seed);
181
+ const ftsTable = ftsTableName(seed);
182
+ const slug = seed.slug;
183
+ const cols = rtBranches.map(b => b.alias);
184
+ const ftsColList = ['entry_id', ...cols].join(', ');
185
+ const newValList = ['new.id', ...cols.map(c => `new.${c}`)].join(', ');
186
+ return [
187
+ [
188
+ `CREATE TRIGGER IF NOT EXISTS fts_${slug}_insert`,
189
+ `AFTER INSERT ON ${table} BEGIN`,
190
+ ` INSERT INTO ${ftsTable}(${ftsColList}) VALUES (${newValList});`,
191
+ `END;`,
192
+ ].join('\n'),
193
+ [
194
+ `CREATE TRIGGER IF NOT EXISTS fts_${slug}_update`,
195
+ `AFTER UPDATE OF ${cols.join(', ')} ON ${table} BEGIN`,
196
+ ` DELETE FROM ${ftsTable} WHERE entry_id = old.id;`,
197
+ ` INSERT INTO ${ftsTable}(${ftsColList}) VALUES (${newValList});`,
198
+ `END;`,
199
+ ].join('\n'),
200
+ [
201
+ `CREATE TRIGGER IF NOT EXISTS fts_${slug}_delete`,
202
+ `AFTER DELETE ON ${table} BEGIN`,
203
+ ` DELETE FROM ${ftsTable} WHERE entry_id = old.id;`,
204
+ `END;`,
205
+ ].join('\n'),
206
+ ];
207
+ }
208
+ // ---- Query Builder ----
209
+ /**
210
+ * Costruisce una SELECT parametrizzata su `content_{slug}`.
211
+ * Non usa mai json_extract — ogni colonna è una colonna reale.
212
+ * Colonne sconosciute nei filtri/orderBy vengono ignorate (fail-closed).
213
+ */
214
+ export function buildSelectQuery(seed, options = {}) {
215
+ const table = tableName(seed);
216
+ const { filters = [], orderBy, pagination, status, search, fields } = options;
217
+ const bindings = [];
218
+ const whereClauses = [];
219
+ let joinClause = '';
220
+ // FTS JOIN — solo se il seed ha branch indicizzabili
221
+ const rtBranches = indexableSearchBranches(seed);
222
+ if (search && rtBranches.length > 0) {
223
+ const ftsTable = ftsTableName(seed);
224
+ joinClause = `INNER JOIN ${ftsTable} ON ${ftsTable}.entry_id = ${table}.id`;
225
+ whereClauses.push(`${ftsTable} MATCH ?`);
226
+ // FTS5: prefix match con quote per caratteri speciali
227
+ bindings.push(`"${search.replace(/"/g, '""')}"*`);
228
+ }
229
+ // Filtro status
230
+ if (status !== undefined && status !== null) {
231
+ whereClauses.push(`${table}.status = ?`);
232
+ bindings.push(status);
233
+ }
234
+ // Filtri utente
235
+ for (const group of filters) {
236
+ if (!isValidColumn(seed, group.column))
237
+ continue;
238
+ const col = SYSTEM_COLUMNS.has(group.column)
239
+ ? `${table}.${group.column}`
240
+ : group.column;
241
+ for (const cond of group.conditions) {
242
+ const clause = buildFilterCondition(col, group.type, cond, bindings);
243
+ if (clause)
244
+ whereClauses.push(clause);
245
+ }
246
+ }
247
+ // Proiezione colonne
248
+ let selectCols = `${table}.*`;
249
+ if (fields && fields.length > 0) {
250
+ const valid = fields.filter(f => isValidColumn(seed, f));
251
+ if (valid.length > 0) {
252
+ selectCols = valid
253
+ .map(f => (SYSTEM_COLUMNS.has(f) ? `${table}.${f}` : f))
254
+ .join(', ');
255
+ }
256
+ }
257
+ let sql = `SELECT ${selectCols} FROM ${table}`;
258
+ if (joinClause)
259
+ sql += ` ${joinClause}`;
260
+ if (whereClauses.length > 0)
261
+ sql += ` WHERE ${whereClauses.join(' AND ')}`;
262
+ // ORDER BY
263
+ if (orderBy && isValidColumn(seed, orderBy.column)) {
264
+ const dir = orderBy.dir === 'DESC' ? 'DESC' : 'ASC';
265
+ const col = SYSTEM_COLUMNS.has(orderBy.column)
266
+ ? `${table}.${orderBy.column}`
267
+ : orderBy.column;
268
+ sql += ` ORDER BY ${col} ${dir}`;
269
+ }
270
+ else {
271
+ sql += ` ORDER BY ${table}.created_at DESC`;
272
+ }
273
+ // Paginazione
274
+ if (pagination) {
275
+ sql += ` LIMIT ? OFFSET ?`;
276
+ bindings.push(pagination.limit, pagination.offset);
277
+ }
278
+ return { sql, bindings };
279
+ }
280
+ function buildFilterCondition(col, type, cond, bindings) {
281
+ const { op, value } = cond;
282
+ if (op === 'is_empty') {
283
+ return type === 'text' ? `(${col} IS NULL OR ${col} = '')` : `${col} IS NULL`;
284
+ }
285
+ if (op === 'is_not_empty') {
286
+ return type === 'text' ? `(${col} IS NOT NULL AND ${col} != '')` : `${col} IS NOT NULL`;
287
+ }
288
+ if (value === null || value === undefined)
289
+ return null;
290
+ if (op === 'eq') {
291
+ bindings.push(type === 'boolean' ? (value ? 1 : 0) : value);
292
+ return `${col} = ?`;
293
+ }
294
+ if (op === 'contains') {
295
+ if (type === 'tags') {
296
+ bindings.push(String(value));
297
+ return `EXISTS (SELECT 1 FROM json_each(${col}) WHERE value = ?)`;
298
+ }
299
+ bindings.push(`%${String(value)}%`);
300
+ return `${col} LIKE ?`;
301
+ }
302
+ const mathOps = { gt: '>', gte: '>=', lt: '<', lte: '<=' };
303
+ if (mathOps[op]) {
304
+ bindings.push(value);
305
+ return `${col} ${mathOps[op]} ?`;
306
+ }
307
+ return null;
308
+ }
309
+ /**
310
+ * Ritorna la lista di colonne attese per la tabella di un Seed.
311
+ * Usato da `beech seed:load --diff` per confrontare schema attuale vs atteso.
312
+ */
313
+ export function getExpectedColumns(seed) {
314
+ return [
315
+ { name: 'id', sqlType: 'TEXT', notNull: true, isPk: true },
316
+ { name: 'slug', sqlType: 'TEXT', notNull: true, isPk: false },
317
+ { name: 'status', sqlType: 'TEXT', notNull: true, isPk: false },
318
+ ...seed.branches.map(b => ({
319
+ name: b.alias,
320
+ sqlType: BRANCH_TYPE_SQL[b.type].sqlType,
321
+ notNull: b.requiredOnCreate ?? false,
322
+ isPk: false,
323
+ })),
324
+ { name: 'created_at', sqlType: 'INTEGER', notNull: true, isPk: false },
325
+ { name: 'updated_at', sqlType: 'INTEGER', notNull: true, isPk: false },
326
+ ];
327
+ }
328
+ // ---- Serialization / Deserialization ----
329
+ /**
330
+ * Serializza un valore per la scrittura nel DB.
331
+ * boolean → 0/1 | date → Unix timestamp | json/asset-list → JSON string
332
+ */
333
+ export function serializeForDb(branch, value) {
334
+ if (value === null || value === undefined)
335
+ return null;
336
+ switch (branch.type) {
337
+ case 'boolean':
338
+ return value ? 1 : 0;
339
+ case 'json':
340
+ case 'richtext':
341
+ return typeof value === 'string' ? value : JSON.stringify(value);
342
+ case 'date': {
343
+ if (typeof value === 'number')
344
+ return value;
345
+ if (typeof value === 'string') {
346
+ const d = new Date(value);
347
+ if (isNaN(d.getTime()))
348
+ return null;
349
+ if (branch.format === 'date') {
350
+ const midnight = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
351
+ return Math.floor(midnight / 1000);
352
+ }
353
+ return Math.floor(d.getTime() / 1000);
354
+ }
355
+ return null;
356
+ }
357
+ case 'file':
358
+ if (isAssetListBranch(branch)) {
359
+ return Array.isArray(value) ? JSON.stringify(value) : typeof value === 'string' ? value : null;
360
+ }
361
+ return typeof value === 'string' ? value : null;
362
+ default:
363
+ return typeof value === 'string' ? value : typeof value === 'number' ? value : null;
364
+ }
365
+ }
366
+ /**
367
+ * Deserializza un valore letto dal DB per la risposta API.
368
+ * 0/1 → boolean | Unix timestamp → ISO 8601 | JSON string → object
369
+ */
370
+ export function deserializeFromDb(branch, value) {
371
+ if (value === null || value === undefined)
372
+ return null;
373
+ switch (branch.type) {
374
+ case 'boolean':
375
+ return value === 1 || value === true;
376
+ case 'json':
377
+ case 'richtext': {
378
+ if (typeof value === 'string') {
379
+ try {
380
+ return JSON.parse(value);
381
+ }
382
+ catch {
383
+ return value;
384
+ }
385
+ }
386
+ return value;
387
+ }
388
+ case 'date': {
389
+ if (typeof value !== 'number')
390
+ return null;
391
+ const d = new Date(value * 1000);
392
+ return branch.format === 'date' ? d.toISOString().slice(0, 10) : d.toISOString();
393
+ }
394
+ case 'file':
395
+ if (isAssetListBranch(branch))
396
+ return normalizeAssetListValue(value);
397
+ return typeof value === 'string' ? value : null;
398
+ default:
399
+ return value;
400
+ }
401
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @beechcms/core - Botanical Engine
3
+ *
4
+ * Pacchetto condiviso del monorepo Beech CMS.
5
+ * In v0.4.0 il Botanical Engine è un compilatore di schema SQL: legge i Seed
6
+ * TypeScript e genera DDL deterministico + query parametrizzate.
7
+ *
8
+ * @module @beechcms/core
9
+ */
10
+ export * from './types.js';
11
+ export * from './define-seed.js';
12
+ export * from './seeds.js';
13
+ export * from './engine.js';
14
+ export * from './validation.js';
15
+ export * from './richtext.js';
16
+ export * from './richtext-render.js';
17
+ export * from './slug-utils.js';
18
+ export * from './policies.js';
19
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,cAAc,YAAY,CAAA;AAC1B,cAAc,kBAAkB,CAAA;AAChC,cAAc,YAAY,CAAA;AAC1B,cAAc,aAAa,CAAA;AAC3B,cAAc,iBAAiB,CAAA;AAC/B,cAAc,eAAe,CAAA;AAC7B,cAAc,sBAAsB,CAAA;AACpC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,eAAe,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @beechcms/core - Botanical Engine
3
+ *
4
+ * Pacchetto condiviso del monorepo Beech CMS.
5
+ * In v0.4.0 il Botanical Engine è un compilatore di schema SQL: legge i Seed
6
+ * TypeScript e genera DDL deterministico + query parametrizzate.
7
+ *
8
+ * @module @beechcms/core
9
+ */
10
+ export * from './types.js';
11
+ export * from './define-seed.js';
12
+ export * from './seeds.js';
13
+ export * from './engine.js';
14
+ export * from './validation.js';
15
+ export * from './richtext.js';
16
+ export * from './richtext-render.js';
17
+ export * from './slug-utils.js';
18
+ export * from './policies.js';
@@ -0,0 +1,10 @@
1
+ import type { Branch } from './types.js';
2
+ export declare function sha256hex(value: string): Promise<string>;
3
+ export declare function verifyHashField(stored: string, candidate: string): Promise<boolean>;
4
+ /**
5
+ * Risolve le policy di un branch applicando i valori di default.
6
+ * Tutta la logica di accesso ai campi deve passare per questa funzione,
7
+ * mai con inline `branch.policies?.x ?? default`.
8
+ */
9
+ export declare function resolvePolicies(branch: Branch): Required<NonNullable<Branch['policies']>>;
10
+ //# sourceMappingURL=policies.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"policies.d.ts","sourceRoot":"","sources":["../src/policies.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAExC,wBAAsB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAK9D;AAED,wBAAsB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAEzF;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ,CAAC,WAAW,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC,CAazF"}
@@ -0,0 +1,28 @@
1
+ export async function sha256hex(value) {
2
+ const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
3
+ return Array.from(new Uint8Array(buf))
4
+ .map((b) => b.toString(16).padStart(2, '0'))
5
+ .join('');
6
+ }
7
+ export async function verifyHashField(stored, candidate) {
8
+ return stored === (await sha256hex(candidate));
9
+ }
10
+ /**
11
+ * Risolve le policy di un branch applicando i valori di default.
12
+ * Tutta la logica di accesso ai campi deve passare per questa funzione,
13
+ * mai con inline `branch.policies?.x ?? default`.
14
+ */
15
+ export function resolvePolicies(branch) {
16
+ const privacy = branch.policies?.privacy ?? 'plain';
17
+ // Non-plain privacy implies hidden by default: the CMS hashes/encrypts on write,
18
+ // so returning the stored value would leak the digest to readers.
19
+ const defaultVisibility = privacy !== 'plain' ? 'hidden' : 'full';
20
+ return {
21
+ privacy,
22
+ visibility: branch.policies?.visibility ?? defaultVisibility,
23
+ search: branch.policies?.search ?? true,
24
+ filter: branch.policies?.filter ?? true,
25
+ sort: branch.policies?.sort ?? true,
26
+ public: branch.policies?.public ?? true,
27
+ };
28
+ }
@@ -0,0 +1,11 @@
1
+ import type { JSONContent } from '@tiptap/core';
2
+ /**
3
+ * Accetta JSON TipTap (`{ type: 'doc', ... }`), envelope v1, o stringa HTML legacy.
4
+ */
5
+ export declare function normalizeRichtextForRender(value: unknown): JSONContent | string | null;
6
+ /**
7
+ * Render deterministico JSON → HTML (per display, anteprime, API pubblica).
8
+ * Per stringhe HTML legacy restituisce la stringa sanificata come pass-through (nessun parse TipTap).
9
+ */
10
+ export declare function renderRichText(value: unknown): string;
11
+ //# sourceMappingURL=richtext-render.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"richtext-render.d.ts","sourceRoot":"","sources":["../src/richtext-render.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAuD/C;;GAEG;AACH,wBAAgB,0BAA0B,CAAC,KAAK,EAAE,OAAO,GAAG,WAAW,GAAG,MAAM,GAAG,IAAI,CAatF;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAQrD"}
@@ -0,0 +1,85 @@
1
+ import { generateHTML } from '@tiptap/html';
2
+ import Highlight from '@tiptap/extension-highlight';
3
+ import Image from '@tiptap/extension-image';
4
+ import Link from '@tiptap/extension-link';
5
+ import Subscript from '@tiptap/extension-subscript';
6
+ import Superscript from '@tiptap/extension-superscript';
7
+ import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table';
8
+ import TextAlign from '@tiptap/extension-text-align';
9
+ import { Mathematics } from '@tiptap/extension-mathematics';
10
+ import StarterKit from '@tiptap/starter-kit';
11
+ import { isRichtextEnvelopeV1 } from './richtext.js';
12
+ /**
13
+ * Allinea l'output HTML allo schema TipTap usato dall'editor dashboard.
14
+ * Mantieni sincronizzato con `apps/dashboard/src/features/richtext-editor/extensions/build-editor-extensions.ts`.
15
+ */
16
+ function createRichTextHtmlExtensions() {
17
+ return [
18
+ StarterKit.configure({
19
+ link: false,
20
+ codeBlock: {
21
+ HTMLAttributes: {
22
+ class: 'richtext-code-block',
23
+ },
24
+ },
25
+ }),
26
+ Link.configure({
27
+ openOnClick: false,
28
+ autolink: true,
29
+ defaultProtocol: 'https',
30
+ }),
31
+ Mathematics.configure({
32
+ katexOptions: {
33
+ throwOnError: false,
34
+ },
35
+ }),
36
+ Highlight,
37
+ Superscript,
38
+ Subscript,
39
+ Image.configure({
40
+ allowBase64: false,
41
+ }),
42
+ TextAlign.configure({
43
+ types: ['heading', 'paragraph'],
44
+ }),
45
+ Table.configure({
46
+ resizable: false,
47
+ }),
48
+ TableRow,
49
+ TableHeader,
50
+ TableCell,
51
+ ];
52
+ }
53
+ /**
54
+ * Accetta JSON TipTap (`{ type: 'doc', ... }`), envelope v1, o stringa HTML legacy.
55
+ */
56
+ export function normalizeRichtextForRender(value) {
57
+ if (value == null || value === '')
58
+ return null;
59
+ if (typeof value === 'string')
60
+ return value;
61
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
62
+ const o = value;
63
+ if (isRichtextEnvelopeV1(value)) {
64
+ return o.doc;
65
+ }
66
+ if (o.type === 'doc') {
67
+ return value;
68
+ }
69
+ }
70
+ return null;
71
+ }
72
+ /**
73
+ * Render deterministico JSON → HTML (per display, anteprime, API pubblica).
74
+ * Per stringhe HTML legacy restituisce la stringa sanificata come pass-through (nessun parse TipTap).
75
+ */
76
+ export function renderRichText(value) {
77
+ const normalized = normalizeRichtextForRender(value);
78
+ if (normalized == null)
79
+ return '';
80
+ if (typeof normalized === 'string') {
81
+ return normalized;
82
+ }
83
+ const extensions = createRichTextHtmlExtensions();
84
+ return generateHTML(normalized, extensions);
85
+ }