@thebes/cadmus 0.2.1 → 0.4.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.
- package/dist/cms/index.cjs +689 -3
- package/dist/cms/index.cjs.map +1 -1
- package/dist/cms/index.d.cts +2 -2
- package/dist/cms/index.d.ts +2 -2
- package/dist/cms/index.js +671 -5
- package/dist/cms/index.js.map +1 -1
- package/dist/email/index.cjs +1 -1
- package/dist/email/index.js +1 -1
- package/dist/{errors-CW6Lz0AQ.cjs → errors-BhoibM6Z.cjs} +24 -1
- package/dist/{errors-CW6Lz0AQ.cjs.map → errors-BhoibM6Z.cjs.map} +1 -1
- package/dist/{errors-mZIqZJO4.js → errors-C8SqkFjl.js} +19 -2
- package/dist/{errors-mZIqZJO4.js.map → errors-C8SqkFjl.js.map} +1 -1
- package/dist/hono/index.cjs +6 -1
- package/dist/hono/index.cjs.map +1 -1
- package/dist/hono/index.d.cts +1 -1
- package/dist/hono/index.d.cts.map +1 -1
- package/dist/hono/index.d.ts +1 -1
- package/dist/hono/index.d.ts.map +1 -1
- package/dist/hono/index.js +6 -1
- package/dist/hono/index.js.map +1 -1
- package/dist/index-sB3YOadC.d.cts +1304 -0
- package/dist/index-sB3YOadC.d.cts.map +1 -0
- package/dist/index-sB3YOadC.d.ts +1304 -0
- package/dist/index-sB3YOadC.d.ts.map +1 -0
- package/dist/index.cjs +22 -1
- package/dist/index.d.cts +3 -89
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +3 -89
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/queues/index.cjs +1 -1
- package/dist/queues/index.js +1 -1
- package/dist/rate-limit/index.cjs +1 -1
- package/dist/rate-limit/index.js +1 -1
- package/dist/session/index.cjs +1 -1
- package/dist/session/index.js +1 -1
- package/dist/storage/index.cjs +1 -1
- package/dist/storage/index.cjs.map +1 -1
- package/dist/storage/index.d.cts +31 -2
- package/dist/storage/index.d.cts.map +1 -1
- package/dist/storage/index.d.ts +31 -2
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +1 -1
- package/dist/storage/index.js.map +1 -1
- package/package.json +1 -1
- package/dist/index-BUrCSGVb.d.cts +0 -616
- package/dist/index-BUrCSGVb.d.cts.map +0 -1
- package/dist/index-BUrCSGVb.d.ts +0 -616
- package/dist/index-BUrCSGVb.d.ts.map +0 -1
package/dist/cms/index.cjs
CHANGED
|
@@ -1,8 +1,65 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_errors = require("../errors-
|
|
2
|
+
const require_errors = require("../errors-BhoibM6Z.cjs");
|
|
3
3
|
const require_queues_index = require("../queues/index.cjs");
|
|
4
4
|
let drizzle_orm_sqlite_core = require("drizzle-orm/sqlite-core");
|
|
5
5
|
let drizzle_orm = require("drizzle-orm");
|
|
6
|
+
//#region src/cms/blocks.ts
|
|
7
|
+
/**
|
|
8
|
+
* Create a block renderer registry. Seed it with an initial `type → renderer`
|
|
9
|
+
* map and/or an `options.fallback` for unknown types.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* const registry = createBlockRegistry<StringBlockRenderer>({
|
|
13
|
+
* divider: () => "<hr>",
|
|
14
|
+
* });
|
|
15
|
+
* registry.register("hero", (b) => `<h1>${b.heading}</h1>`);
|
|
16
|
+
* renderBlocksToString(blocks, registry);
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
function createBlockRegistry(initial = {}, options = {}) {
|
|
20
|
+
const renderers = new Map(Object.entries(initial));
|
|
21
|
+
let fallback = options.fallback;
|
|
22
|
+
const registry = {
|
|
23
|
+
register(type, renderer) {
|
|
24
|
+
renderers.set(type, renderer);
|
|
25
|
+
return registry;
|
|
26
|
+
},
|
|
27
|
+
registerMany(map) {
|
|
28
|
+
for (const [type, renderer] of Object.entries(map)) renderers.set(type, renderer);
|
|
29
|
+
return registry;
|
|
30
|
+
},
|
|
31
|
+
get(type) {
|
|
32
|
+
return renderers.get(type);
|
|
33
|
+
},
|
|
34
|
+
has(type) {
|
|
35
|
+
return renderers.has(type);
|
|
36
|
+
},
|
|
37
|
+
types() {
|
|
38
|
+
return [...renderers.keys()];
|
|
39
|
+
},
|
|
40
|
+
setFallback(renderer) {
|
|
41
|
+
fallback = renderer;
|
|
42
|
+
return registry;
|
|
43
|
+
},
|
|
44
|
+
resolve(type) {
|
|
45
|
+
return renderers.get(type) ?? fallback;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
return registry;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Render an array of blocks to a single HTML string via a registry of
|
|
52
|
+
* {@link StringBlockRenderer}s. Blocks whose type resolves to no renderer
|
|
53
|
+
* (and no fallback) contribute the empty string — the same forgiving
|
|
54
|
+
* behavior the old hand-rolled `switch` had for unknown types.
|
|
55
|
+
*/
|
|
56
|
+
function renderBlocksToString(blocks, registry) {
|
|
57
|
+
return blocks.map((block) => {
|
|
58
|
+
const renderer = registry.resolve(block.type);
|
|
59
|
+
return renderer ? renderer(block) : "";
|
|
60
|
+
}).join("");
|
|
61
|
+
}
|
|
62
|
+
//#endregion
|
|
6
63
|
//#region src/cms/types.ts
|
|
7
64
|
/**
|
|
8
65
|
* Expands every `group` field in `fields` into its flattened equivalents
|
|
@@ -250,6 +307,366 @@ function defineCmsConfig(config) {
|
|
|
250
307
|
return resolved;
|
|
251
308
|
}
|
|
252
309
|
//#endregion
|
|
310
|
+
//#region src/cms/patch.ts
|
|
311
|
+
function deepEqual(a, b) {
|
|
312
|
+
if (a === b) return true;
|
|
313
|
+
if (a === null || b === null) return false;
|
|
314
|
+
if (typeof a !== typeof b) return false;
|
|
315
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
316
|
+
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
|
317
|
+
if (a.length !== b.length) return false;
|
|
318
|
+
return a.every((item, i) => deepEqual(item, b[i]));
|
|
319
|
+
}
|
|
320
|
+
if (typeof a === "object" && typeof b === "object") {
|
|
321
|
+
const ak = Object.keys(a);
|
|
322
|
+
const bk = Object.keys(b);
|
|
323
|
+
if (ak.length !== bk.length) return false;
|
|
324
|
+
return ak.every((key) => Object.hasOwn(b, key) && deepEqual(a[key], b[key]));
|
|
325
|
+
}
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Field-level diff between two document snapshots — the per-field
|
|
330
|
+
* added/removed/changed list a version-history UI renders. Values are
|
|
331
|
+
* compared structurally (deep-equal), so a field only shows as `changed`
|
|
332
|
+
* when its content actually differs.
|
|
333
|
+
*/
|
|
334
|
+
function diffDocuments(before, after, options = {}) {
|
|
335
|
+
const ignore = new Set(options.ignore ?? []);
|
|
336
|
+
const keys = options.fields ? options.fields : [...new Set([...Object.keys(before), ...Object.keys(after)])];
|
|
337
|
+
const changes = [];
|
|
338
|
+
for (const path of keys) {
|
|
339
|
+
if (ignore.has(path)) continue;
|
|
340
|
+
const inBefore = Object.hasOwn(before, path);
|
|
341
|
+
const inAfter = Object.hasOwn(after, path);
|
|
342
|
+
if (inBefore && !inAfter) changes.push({
|
|
343
|
+
path,
|
|
344
|
+
kind: "removed",
|
|
345
|
+
before: before[path]
|
|
346
|
+
});
|
|
347
|
+
else if (!inBefore && inAfter) changes.push({
|
|
348
|
+
path,
|
|
349
|
+
kind: "added",
|
|
350
|
+
after: after[path]
|
|
351
|
+
});
|
|
352
|
+
else if (inBefore && inAfter && !deepEqual(before[path], after[path])) changes.push({
|
|
353
|
+
path,
|
|
354
|
+
kind: "changed",
|
|
355
|
+
before: before[path],
|
|
356
|
+
after: after[path]
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
return changes;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* The {@link Patch} that transforms `before` into `after`: `set` for each
|
|
363
|
+
* added/changed field, `unset` for each removed field. `applyPatch(before,
|
|
364
|
+
* computePatch(before, after))` deep-equals `after`.
|
|
365
|
+
*/
|
|
366
|
+
function computePatch(before, after) {
|
|
367
|
+
return diffDocuments(before, after).map((change) => change.kind === "removed" ? {
|
|
368
|
+
op: "unset",
|
|
369
|
+
path: change.path
|
|
370
|
+
} : {
|
|
371
|
+
op: "set",
|
|
372
|
+
path: change.path,
|
|
373
|
+
value: change.after
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Apply a {@link Patch} to a document, returning a new document (the input is
|
|
378
|
+
* never mutated). Unknown ops are ignored defensively.
|
|
379
|
+
*/
|
|
380
|
+
function applyPatch(doc, patch) {
|
|
381
|
+
const next = { ...doc };
|
|
382
|
+
for (const op of patch) if (op.op === "set") next[op.path] = op.value;
|
|
383
|
+
else if (op.op === "unset") delete next[op.path];
|
|
384
|
+
return next;
|
|
385
|
+
}
|
|
386
|
+
//#endregion
|
|
387
|
+
//#region src/cms/validation.ts
|
|
388
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
389
|
+
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
390
|
+
/**
|
|
391
|
+
* Immutable, chainable rule builder — the value a field's `validation`
|
|
392
|
+
* function receives and returns. Build a `Rule` with the module-level
|
|
393
|
+
* {@link rule} factory, or accept the one passed to your `validation`
|
|
394
|
+
* callback.
|
|
395
|
+
*/
|
|
396
|
+
var Rule = class Rule {
|
|
397
|
+
checks;
|
|
398
|
+
constructor(checks = []) {
|
|
399
|
+
this.checks = checks;
|
|
400
|
+
}
|
|
401
|
+
add(check) {
|
|
402
|
+
return new Rule([...this.checks, check]);
|
|
403
|
+
}
|
|
404
|
+
/** Override the message of the most recently added check. */
|
|
405
|
+
error(message) {
|
|
406
|
+
return this.withLast({
|
|
407
|
+
message,
|
|
408
|
+
severity: "error"
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Demote the most recently added check to a warning (non-blocking),
|
|
413
|
+
* optionally with a message. Sanity's `Rule.warning()` analogue.
|
|
414
|
+
*/
|
|
415
|
+
warning(message) {
|
|
416
|
+
return this.withLast({
|
|
417
|
+
severity: "warning",
|
|
418
|
+
...message ? { message } : {}
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
withLast(patch) {
|
|
422
|
+
if (this.checks.length === 0) return this;
|
|
423
|
+
const next = this.checks.slice();
|
|
424
|
+
next[next.length - 1] = {
|
|
425
|
+
...next[next.length - 1],
|
|
426
|
+
...patch
|
|
427
|
+
};
|
|
428
|
+
return new Rule(next);
|
|
429
|
+
}
|
|
430
|
+
required() {
|
|
431
|
+
return this.add({ kind: "required" });
|
|
432
|
+
}
|
|
433
|
+
/** Minimum string length / array length / numeric value. */
|
|
434
|
+
min(n) {
|
|
435
|
+
return this.add({
|
|
436
|
+
kind: "min",
|
|
437
|
+
n
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
/** Maximum string length / array length / numeric value. */
|
|
441
|
+
max(n) {
|
|
442
|
+
return this.add({
|
|
443
|
+
kind: "max",
|
|
444
|
+
n
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
/** Exact string/array length. */
|
|
448
|
+
length(n) {
|
|
449
|
+
return this.add({
|
|
450
|
+
kind: "length",
|
|
451
|
+
n
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
regex(re, label = "match the required format") {
|
|
455
|
+
return this.add({
|
|
456
|
+
kind: "regex",
|
|
457
|
+
re,
|
|
458
|
+
label
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
email() {
|
|
462
|
+
return this.add({
|
|
463
|
+
kind: "regex",
|
|
464
|
+
re: EMAIL_RE,
|
|
465
|
+
label: "be a valid email"
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
/** Lowercase kebab-case slug format. Pair with `.unique()` for slugs. */
|
|
469
|
+
slug() {
|
|
470
|
+
return this.add({
|
|
471
|
+
kind: "regex",
|
|
472
|
+
re: SLUG_RE,
|
|
473
|
+
label: "be a lowercase, hyphen-separated slug"
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
integer() {
|
|
477
|
+
return this.add({ kind: "integer" });
|
|
478
|
+
}
|
|
479
|
+
positive() {
|
|
480
|
+
return this.add({ kind: "positive" });
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Value must be unique across the collection (DB-backed; skipped in a
|
|
484
|
+
* pure client-side pass). A first-class rule rather than the hand-rolled
|
|
485
|
+
* column `unique` flag, so the failure is a clear field message instead of
|
|
486
|
+
* a raw UNIQUE-constraint write error.
|
|
487
|
+
*/
|
|
488
|
+
unique() {
|
|
489
|
+
return this.add({ kind: "unique" });
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* For a `relationship` field: the referenced id must exist in the related
|
|
493
|
+
* collection (DB-backed; skipped client-side).
|
|
494
|
+
*/
|
|
495
|
+
reference() {
|
|
496
|
+
return this.add({ kind: "reference" });
|
|
497
|
+
}
|
|
498
|
+
custom(fn) {
|
|
499
|
+
return this.add({
|
|
500
|
+
kind: "custom",
|
|
501
|
+
fn
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
/** Internal: the accumulated checks, read by {@link validateDocument}. */
|
|
505
|
+
toChecks() {
|
|
506
|
+
return this.checks;
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
/** Fresh, empty rule — the root of a chain. */
|
|
510
|
+
function rule() {
|
|
511
|
+
return new Rule();
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Identity helper mirroring Sanity's `defineField` — returns the field
|
|
515
|
+
* config unchanged but gives editors autocomplete and a single, greppable
|
|
516
|
+
* call site for field definitions. Optional: a plain object literal is still
|
|
517
|
+
* a valid field.
|
|
518
|
+
*/
|
|
519
|
+
function defineField(field) {
|
|
520
|
+
return field;
|
|
521
|
+
}
|
|
522
|
+
function resolveChecks(field) {
|
|
523
|
+
if (!field.validation) return [];
|
|
524
|
+
const built = field.validation(new Rule());
|
|
525
|
+
return (Array.isArray(built) ? built : [built]).flatMap((r) => r.toChecks());
|
|
526
|
+
}
|
|
527
|
+
function isEmpty(value) {
|
|
528
|
+
return value === void 0 || value === null || typeof value === "string" && value.length === 0;
|
|
529
|
+
}
|
|
530
|
+
function sizeOf(value) {
|
|
531
|
+
if (typeof value === "string") return {
|
|
532
|
+
size: value.length,
|
|
533
|
+
unit: "character"
|
|
534
|
+
};
|
|
535
|
+
if (Array.isArray(value)) return {
|
|
536
|
+
size: value.length,
|
|
537
|
+
unit: "item"
|
|
538
|
+
};
|
|
539
|
+
if (typeof value === "number") return {
|
|
540
|
+
size: value,
|
|
541
|
+
unit: ""
|
|
542
|
+
};
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Evaluate every field's validation rules against `doc`, returning all
|
|
547
|
+
* violations (both errors and warnings). `doc` is the nested document; field
|
|
548
|
+
* values are read from its flattened form so group subfields validate too.
|
|
549
|
+
*/
|
|
550
|
+
async function validateDocument(config, doc, options) {
|
|
551
|
+
const flatFields = flattenFields(config.fields);
|
|
552
|
+
const flatDoc = flattenDocShallow(config, doc);
|
|
553
|
+
const violations = [];
|
|
554
|
+
for (const [path, field] of Object.entries(flatFields)) {
|
|
555
|
+
if (options.onlyFields && !options.onlyFields.has(path)) continue;
|
|
556
|
+
const checks = resolveChecks(field);
|
|
557
|
+
if (checks.length === 0) continue;
|
|
558
|
+
const value = flatDoc[path];
|
|
559
|
+
const ctx = {
|
|
560
|
+
document: doc,
|
|
561
|
+
path,
|
|
562
|
+
operation: options.operation,
|
|
563
|
+
...options.id !== void 0 ? { id: options.id } : {}
|
|
564
|
+
};
|
|
565
|
+
for (const check of checks) {
|
|
566
|
+
const violation = await evaluateCheck(check, value, field, ctx, options);
|
|
567
|
+
if (violation) violations.push(violation);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return violations;
|
|
571
|
+
}
|
|
572
|
+
function flattenDocShallow(config, doc) {
|
|
573
|
+
return Object.values(config.fields).some((f) => f.type === "group") ? flattenDoc(config.fields, doc) : doc;
|
|
574
|
+
}
|
|
575
|
+
async function evaluateCheck(check, value, field, ctx, options) {
|
|
576
|
+
const fail = (defaultMessage) => ({
|
|
577
|
+
path: ctx.path,
|
|
578
|
+
message: check.message ?? `${ctx.path} must ${defaultMessage}`,
|
|
579
|
+
severity: check.severity ?? "error"
|
|
580
|
+
});
|
|
581
|
+
switch (check.kind) {
|
|
582
|
+
case "required": return isEmpty(value) ? fail("not be empty") : null;
|
|
583
|
+
case "min": {
|
|
584
|
+
if (isEmpty(value)) return null;
|
|
585
|
+
const s = sizeOf(value);
|
|
586
|
+
if (s && s.size < check.n) return fail(s.unit ? `have at least ${check.n} ${s.unit}${check.n === 1 ? "" : "s"}` : `be at least ${check.n}`);
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
case "max": {
|
|
590
|
+
if (isEmpty(value)) return null;
|
|
591
|
+
const s = sizeOf(value);
|
|
592
|
+
if (s && s.size > check.n) return fail(s.unit ? `have at most ${check.n} ${s.unit}${check.n === 1 ? "" : "s"}` : `be at most ${check.n}`);
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
case "length": {
|
|
596
|
+
if (isEmpty(value)) return null;
|
|
597
|
+
const s = sizeOf(value);
|
|
598
|
+
if (s?.unit && s.size !== check.n) return fail(`be exactly ${check.n} ${s.unit}${check.n === 1 ? "" : "s"}`);
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
case "regex":
|
|
602
|
+
if (isEmpty(value)) return null;
|
|
603
|
+
if (typeof value !== "string" || !check.re.test(value)) return fail(check.label);
|
|
604
|
+
return null;
|
|
605
|
+
case "integer":
|
|
606
|
+
if (isEmpty(value)) return null;
|
|
607
|
+
return typeof value === "number" && Number.isInteger(value) ? null : fail("be an integer");
|
|
608
|
+
case "positive":
|
|
609
|
+
if (isEmpty(value)) return null;
|
|
610
|
+
return typeof value === "number" && value > 0 ? null : fail("be a positive number");
|
|
611
|
+
case "unique": return evaluateUnique(value, ctx, options, check);
|
|
612
|
+
case "reference": return evaluateReference(value, field, ctx, options, check);
|
|
613
|
+
case "custom": {
|
|
614
|
+
const result = await check.fn(value, ctx);
|
|
615
|
+
if (result === true || result === void 0) return null;
|
|
616
|
+
if (result === false) return fail("be valid");
|
|
617
|
+
if (typeof result === "string") return {
|
|
618
|
+
path: ctx.path,
|
|
619
|
+
message: result,
|
|
620
|
+
severity: check.severity ?? "error"
|
|
621
|
+
};
|
|
622
|
+
return {
|
|
623
|
+
path: ctx.path,
|
|
624
|
+
message: result.message,
|
|
625
|
+
severity: result.severity ?? check.severity ?? "error"
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
async function evaluateUnique(value, ctx, options, check) {
|
|
631
|
+
if (isEmpty(value) || !options.db || !options.table) return null;
|
|
632
|
+
const column = options.table[ctx.path];
|
|
633
|
+
if (!column) return null;
|
|
634
|
+
const where = ctx.id !== void 0 ? (0, drizzle_orm.and)((0, drizzle_orm.eq)(column, value), (0, drizzle_orm.ne)(options.table.id, ctx.id)) : (0, drizzle_orm.eq)(column, value);
|
|
635
|
+
if ((await options.db.select({ id: options.table.id }).from(options.table).where(where).limit(1)).length > 0) return {
|
|
636
|
+
path: ctx.path,
|
|
637
|
+
message: check.message ?? `${ctx.path} "${String(value)}" is already taken`,
|
|
638
|
+
severity: check.severity ?? "error"
|
|
639
|
+
};
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
642
|
+
async function evaluateReference(value, field, ctx, options, check) {
|
|
643
|
+
if (isEmpty(value) || !options.db || !options.registry) return null;
|
|
644
|
+
if (field.type !== "relationship") return null;
|
|
645
|
+
const target = options.registry.tables[field.relationTo];
|
|
646
|
+
if (!target) return null;
|
|
647
|
+
if ((await options.db.select({ id: target.id }).from(target).where((0, drizzle_orm.eq)(target.id, value)).limit(1)).length === 0) return {
|
|
648
|
+
path: ctx.path,
|
|
649
|
+
message: check.message ?? `${ctx.path} references a "${field.relationTo}" that does not exist`,
|
|
650
|
+
severity: check.severity ?? "error"
|
|
651
|
+
};
|
|
652
|
+
return null;
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Run {@link validateDocument} and throw {@link CadmusValidationError} if any
|
|
656
|
+
* `"error"`-severity violations are found. Warnings are returned (never
|
|
657
|
+
* thrown) so a caller can still surface them. The thrown error's message is
|
|
658
|
+
* a readable, joined summary of every blocking violation.
|
|
659
|
+
*/
|
|
660
|
+
async function assertValid(config, doc, options) {
|
|
661
|
+
const violations = await validateDocument(config, doc, options);
|
|
662
|
+
const errors = violations.filter((v) => v.severity === "error");
|
|
663
|
+
if (errors.length > 0) {
|
|
664
|
+
const summary = errors.map((v) => v.message).join("; ");
|
|
665
|
+
throw new require_errors.CadmusValidationError(`Validation failed for collection "${config.slug}": ${summary}`, violations);
|
|
666
|
+
}
|
|
667
|
+
return violations;
|
|
668
|
+
}
|
|
669
|
+
//#endregion
|
|
253
670
|
//#region src/cms/localApi.ts
|
|
254
671
|
function validateRequiredFields(config, input) {
|
|
255
672
|
for (const [key, field] of Object.entries(flattenFields(config.fields))) {
|
|
@@ -430,9 +847,16 @@ function createLocalApi(db, table, config, registry) {
|
|
|
430
847
|
},
|
|
431
848
|
async create(context, input) {
|
|
432
849
|
await checkAccess(config, "create", context);
|
|
433
|
-
const
|
|
850
|
+
const data = await runBeforeChange(config, input);
|
|
851
|
+
const flatData = toFlatDoc(data);
|
|
434
852
|
validateRequiredFields(config, flatData);
|
|
435
853
|
rejectUnknownFields(config, flatData);
|
|
854
|
+
await assertValid(config, data, {
|
|
855
|
+
operation: "create",
|
|
856
|
+
db,
|
|
857
|
+
table,
|
|
858
|
+
registry
|
|
859
|
+
});
|
|
436
860
|
let row;
|
|
437
861
|
try {
|
|
438
862
|
const [inserted] = await db.insert(table).values(flatData).returning();
|
|
@@ -447,8 +871,17 @@ function createLocalApi(db, table, config, registry) {
|
|
|
447
871
|
},
|
|
448
872
|
async update(context, id, input) {
|
|
449
873
|
await checkAccess(config, "update", context);
|
|
450
|
-
const
|
|
874
|
+
const data = await runBeforeChange(config, input);
|
|
875
|
+
const flatData = toFlatDoc(data);
|
|
451
876
|
rejectUnknownFields(config, flatData);
|
|
877
|
+
await assertValid(config, data, {
|
|
878
|
+
operation: "update",
|
|
879
|
+
id,
|
|
880
|
+
onlyFields: new Set(Object.keys(flatData)),
|
|
881
|
+
db,
|
|
882
|
+
table,
|
|
883
|
+
registry
|
|
884
|
+
});
|
|
452
885
|
let row;
|
|
453
886
|
try {
|
|
454
887
|
const [updated] = await db.update(table).set(flatData).where((0, drizzle_orm.eq)(idColumn, id)).returning();
|
|
@@ -511,6 +944,13 @@ function createVersionedLocalApi(db, table, versionsTable, config, registry) {
|
|
|
511
944
|
validateRequiredFields(config, data);
|
|
512
945
|
rejectUnknownFields(config, data);
|
|
513
946
|
const parentId = versionRecord.parentId;
|
|
947
|
+
await assertValid(config, data, {
|
|
948
|
+
operation: "update",
|
|
949
|
+
id: parentId,
|
|
950
|
+
db,
|
|
951
|
+
table,
|
|
952
|
+
registry
|
|
953
|
+
});
|
|
514
954
|
let doc;
|
|
515
955
|
try {
|
|
516
956
|
const [row] = await db.update(table).set({
|
|
@@ -532,6 +972,21 @@ function createVersionedLocalApi(db, table, versionsTable, config, registry) {
|
|
|
532
972
|
const [row] = await db.update(table).set({ publishedVersionId: null }).where((0, drizzle_orm.eq)(idColumn, id)).returning();
|
|
533
973
|
if (!row) notFound(config, id);
|
|
534
974
|
return row;
|
|
975
|
+
},
|
|
976
|
+
async diffVersions(context, fromVersionId, toVersionId) {
|
|
977
|
+
await checkAccess(config, "read", context);
|
|
978
|
+
const rows = await db.select().from(versionsTable).where((0, drizzle_orm.inArray)(versionsIdColumn, [fromVersionId, toVersionId]));
|
|
979
|
+
const byId = new Map(rows.map((r) => [r.id, r.versionData]));
|
|
980
|
+
const before = byId.get(fromVersionId);
|
|
981
|
+
const after = byId.get(toVersionId);
|
|
982
|
+
if (!before) notFoundVersion(config, fromVersionId);
|
|
983
|
+
if (!after) notFoundVersion(config, toVersionId);
|
|
984
|
+
return diffDocuments(before, after, { ignore: [
|
|
985
|
+
"id",
|
|
986
|
+
"createdAt",
|
|
987
|
+
"status",
|
|
988
|
+
"publishedVersionId"
|
|
989
|
+
] });
|
|
535
990
|
}
|
|
536
991
|
};
|
|
537
992
|
}
|
|
@@ -545,6 +1000,50 @@ function getCollectionsMeta(config) {
|
|
|
545
1000
|
}));
|
|
546
1001
|
}
|
|
547
1002
|
//#endregion
|
|
1003
|
+
//#region src/cms/migrate.ts
|
|
1004
|
+
/** Identity helper — gives a migration definition its type + a greppable call site. */
|
|
1005
|
+
function defineMigration(migration) {
|
|
1006
|
+
return migration;
|
|
1007
|
+
}
|
|
1008
|
+
function patchToUpdate(patch) {
|
|
1009
|
+
const values = {};
|
|
1010
|
+
for (const op of patch) values[op.path] = op.op === "set" ? op.value : null;
|
|
1011
|
+
return values;
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* Run a migration over every document in a collection. Reads all documents
|
|
1015
|
+
* through `api.find`, applies `migration.document`, and (unless `dryRun`)
|
|
1016
|
+
* writes the resulting patch through `api.update`. Returns a report of what
|
|
1017
|
+
* changed — run it `dryRun` first, then apply.
|
|
1018
|
+
*/
|
|
1019
|
+
async function runMigration(migration, options) {
|
|
1020
|
+
const { api, context, dryRun = false } = options;
|
|
1021
|
+
const rows = await api.find(context);
|
|
1022
|
+
const changes = [];
|
|
1023
|
+
const errors = [];
|
|
1024
|
+
let changed = 0;
|
|
1025
|
+
for (const before of rows) try {
|
|
1026
|
+
const patch = computePatch(before, await migration.document(before) ?? before);
|
|
1027
|
+
if (patch.length === 0) continue;
|
|
1028
|
+
changes.push({
|
|
1029
|
+
id: before.id,
|
|
1030
|
+
patch
|
|
1031
|
+
});
|
|
1032
|
+
changed++;
|
|
1033
|
+
if (!dryRun) await api.update(context, before.id, patchToUpdate(patch));
|
|
1034
|
+
} catch (err) {
|
|
1035
|
+
errors.push(`document ${before.id}: ${String(err)}`);
|
|
1036
|
+
}
|
|
1037
|
+
return {
|
|
1038
|
+
migration: migration.name,
|
|
1039
|
+
dryRun,
|
|
1040
|
+
scanned: rows.length,
|
|
1041
|
+
changed,
|
|
1042
|
+
changes,
|
|
1043
|
+
errors
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
//#endregion
|
|
548
1047
|
//#region src/cms/schema-gen.ts
|
|
549
1048
|
function toSnakeCase(value) {
|
|
550
1049
|
return value.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
|
@@ -660,6 +1159,173 @@ function generateSchemaSource(config) {
|
|
|
660
1159
|
].join("\n");
|
|
661
1160
|
}
|
|
662
1161
|
//#endregion
|
|
1162
|
+
//#region src/cms/structure.ts
|
|
1163
|
+
/**
|
|
1164
|
+
* Cadmea's Structure Builder — the framework half of issue #12.
|
|
1165
|
+
*
|
|
1166
|
+
* Adopts Sanity's `sanity/structure` idea (pattern, not code): **decouple
|
|
1167
|
+
* the admin nav from the raw collection list.** Instead of mapping every
|
|
1168
|
+
* `config.collections` entry to an `/admin/<slug>` link — which surfaces
|
|
1169
|
+
* system/log tables as editable links and produces dead links — the sidebar
|
|
1170
|
+
* renders from an explicit, grouped structure derived here from each
|
|
1171
|
+
* collection's `admin` hints (see {@link CollectionAdminConfig}) plus
|
|
1172
|
+
* optional per-slug overrides supplied at the call site.
|
|
1173
|
+
*
|
|
1174
|
+
* Pure data in / pure data out: no SolidJS, no DOM, no server imports — so
|
|
1175
|
+
* it's safe to import from a client studio component (e.g. the site's
|
|
1176
|
+
* `PanelNav`) and trivially testable.
|
|
1177
|
+
*/
|
|
1178
|
+
/** Default group heading for collections that don't declare `admin.group`. */
|
|
1179
|
+
const DEFAULT_STUDIO_GROUP = "Content";
|
|
1180
|
+
function capitalize(value) {
|
|
1181
|
+
return value.length === 0 ? value : value[0].toUpperCase() + value.slice(1);
|
|
1182
|
+
}
|
|
1183
|
+
function resolveAdmin(collection, overrides) {
|
|
1184
|
+
return {
|
|
1185
|
+
...collection.admin,
|
|
1186
|
+
...overrides?.[collection.slug]
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
/**
|
|
1190
|
+
* Build the studio sidebar structure from a resolved CMS config.
|
|
1191
|
+
*
|
|
1192
|
+
* - Hidden collections (`admin.hidden`) are dropped entirely.
|
|
1193
|
+
* - Each remaining collection is placed in its `admin.group` (or
|
|
1194
|
+
* {@link DEFAULT_STUDIO_GROUP}).
|
|
1195
|
+
* - Within a group, items sort by `admin.order` (ascending; unset sorts
|
|
1196
|
+
* after set), then by their original position in `config.collections` —
|
|
1197
|
+
* so config order is the stable tiebreaker.
|
|
1198
|
+
* - Groups render in `options.groupOrder` first, then first-appearance
|
|
1199
|
+
* order for the rest.
|
|
1200
|
+
*
|
|
1201
|
+
* The input is expected to be the *resolved* config (post-plugins), since
|
|
1202
|
+
* that's what carries plugin-injected collections like `products`.
|
|
1203
|
+
*/
|
|
1204
|
+
function buildStudioStructure(config, options = {}) {
|
|
1205
|
+
const basePath = options.basePath ?? "/admin";
|
|
1206
|
+
const groupOrder = options.groupOrder ?? [];
|
|
1207
|
+
const ranked = config.collections.map((collection, index) => ({
|
|
1208
|
+
collection,
|
|
1209
|
+
index,
|
|
1210
|
+
admin: resolveAdmin(collection, options.overrides)
|
|
1211
|
+
}));
|
|
1212
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1213
|
+
const appearance = [];
|
|
1214
|
+
for (const { collection, admin } of ranked) {
|
|
1215
|
+
if (admin.hidden) continue;
|
|
1216
|
+
const title = admin.group ?? "Content";
|
|
1217
|
+
if (!groups.has(title)) {
|
|
1218
|
+
groups.set(title, []);
|
|
1219
|
+
appearance.push(title);
|
|
1220
|
+
}
|
|
1221
|
+
groups.get(title).push({
|
|
1222
|
+
slug: collection.slug,
|
|
1223
|
+
label: admin.label ?? capitalize(collection.slug),
|
|
1224
|
+
href: `${basePath}/${collection.slug}`,
|
|
1225
|
+
readOnly: admin.readOnly ?? false,
|
|
1226
|
+
singleton: admin.singleton ?? false,
|
|
1227
|
+
...admin.icon ? { icon: admin.icon } : {}
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
const meta = new Map(ranked.map(({ collection, index, admin }) => [collection.slug, {
|
|
1231
|
+
order: admin.order ?? Number.POSITIVE_INFINITY,
|
|
1232
|
+
index
|
|
1233
|
+
}]));
|
|
1234
|
+
for (const items of groups.values()) items.sort((a, b) => {
|
|
1235
|
+
const ma = meta.get(a.slug);
|
|
1236
|
+
const mb = meta.get(b.slug);
|
|
1237
|
+
return ma.order - mb.order || ma.index - mb.index;
|
|
1238
|
+
});
|
|
1239
|
+
return [...groupOrder.filter((title) => groups.has(title)), ...appearance.filter((title) => !groupOrder.includes(title))].map((title) => ({
|
|
1240
|
+
title,
|
|
1241
|
+
items: groups.get(title)
|
|
1242
|
+
}));
|
|
1243
|
+
}
|
|
1244
|
+
//#endregion
|
|
1245
|
+
//#region src/cms/visual-editing.ts
|
|
1246
|
+
/** The data attribute editable regions are tagged with. */
|
|
1247
|
+
const EDIT_ATTR = "data-cadmus-edit";
|
|
1248
|
+
/** `postMessage` payload type for a click-to-edit selection. */
|
|
1249
|
+
const VISUAL_EDIT_MESSAGE = "cadmus:visual-edit";
|
|
1250
|
+
function encodeEditRef(ref) {
|
|
1251
|
+
return `${ref.collection}:${ref.id}:${ref.field}`;
|
|
1252
|
+
}
|
|
1253
|
+
/** Parse an {@link EditRef} string, or null if malformed. */
|
|
1254
|
+
function decodeEditRef(value) {
|
|
1255
|
+
const parts = value.split(":");
|
|
1256
|
+
if (parts.length !== 3) return null;
|
|
1257
|
+
const [collection, idRaw, field] = parts;
|
|
1258
|
+
const id = Number.parseInt(idRaw, 10);
|
|
1259
|
+
if (!collection || !field || !Number.isFinite(id)) return null;
|
|
1260
|
+
return {
|
|
1261
|
+
collection,
|
|
1262
|
+
id,
|
|
1263
|
+
field
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
/**
|
|
1267
|
+
* Attribute object to spread onto a rendered element so the overlay can map
|
|
1268
|
+
* it back to its source field, e.g. `<h1 {...editAttr({collection:'pages',
|
|
1269
|
+
* id, field:'title'})}>`.
|
|
1270
|
+
*/
|
|
1271
|
+
function editAttr(ref) {
|
|
1272
|
+
return { [EDIT_ATTR]: encodeEditRef(ref) };
|
|
1273
|
+
}
|
|
1274
|
+
/**
|
|
1275
|
+
* Mount the click-to-edit overlay. Browser-only — call from a preview page's
|
|
1276
|
+
* client script. Highlights `[data-cadmus-edit]` elements on hover and, on
|
|
1277
|
+
* click, calls `onSelect` and posts a {@link VisualEditingMessage} to the
|
|
1278
|
+
* parent window. Returns a cleanup function that removes the listeners.
|
|
1279
|
+
*/
|
|
1280
|
+
function mountVisualEditing(options = {}) {
|
|
1281
|
+
const { onSelect, targetOrigin = "*", highlightColor = "#56c6be" } = options;
|
|
1282
|
+
const closest = (target) => {
|
|
1283
|
+
if (!(target instanceof Element)) return null;
|
|
1284
|
+
const el = target.closest(`[${EDIT_ATTR}]`);
|
|
1285
|
+
return el instanceof HTMLElement ? el : null;
|
|
1286
|
+
};
|
|
1287
|
+
let previous = null;
|
|
1288
|
+
const clearHighlight = () => {
|
|
1289
|
+
if (previous) {
|
|
1290
|
+
previous.el.style.outline = previous.outline;
|
|
1291
|
+
previous = null;
|
|
1292
|
+
}
|
|
1293
|
+
};
|
|
1294
|
+
const onOver = (event) => {
|
|
1295
|
+
const el = closest(event.target);
|
|
1296
|
+
if (!el || el === previous?.el) return;
|
|
1297
|
+
clearHighlight();
|
|
1298
|
+
previous = {
|
|
1299
|
+
el,
|
|
1300
|
+
outline: el.style.outline
|
|
1301
|
+
};
|
|
1302
|
+
el.style.outline = `2px solid ${highlightColor}`;
|
|
1303
|
+
el.style.outlineOffset = "2px";
|
|
1304
|
+
el.style.cursor = "pointer";
|
|
1305
|
+
};
|
|
1306
|
+
const onClick = (event) => {
|
|
1307
|
+
const el = closest(event.target);
|
|
1308
|
+
if (!el) return;
|
|
1309
|
+
const ref = decodeEditRef(el.getAttribute("data-cadmus-edit") ?? "");
|
|
1310
|
+
if (!ref) return;
|
|
1311
|
+
event.preventDefault();
|
|
1312
|
+
event.stopPropagation();
|
|
1313
|
+
onSelect?.(ref, el);
|
|
1314
|
+
const message = {
|
|
1315
|
+
type: VISUAL_EDIT_MESSAGE,
|
|
1316
|
+
ref
|
|
1317
|
+
};
|
|
1318
|
+
window.parent?.postMessage(message, targetOrigin);
|
|
1319
|
+
};
|
|
1320
|
+
document.addEventListener("mouseover", onOver, true);
|
|
1321
|
+
document.addEventListener("click", onClick, true);
|
|
1322
|
+
return () => {
|
|
1323
|
+
clearHighlight();
|
|
1324
|
+
document.removeEventListener("mouseover", onOver, true);
|
|
1325
|
+
document.removeEventListener("click", onClick, true);
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
//#endregion
|
|
663
1329
|
//#region src/cms/webhooks.ts
|
|
664
1330
|
/**
|
|
665
1331
|
* Builds an `afterChange` hook that enqueues a `WebhookMessage` for every
|
|
@@ -739,25 +1405,45 @@ async function deliverWebhookMessage(message) {
|
|
|
739
1405
|
if (!response.ok) throw new require_errors.CadmusQueueError(`Webhook delivery to "${message.url}" returned status ${response.status}`);
|
|
740
1406
|
}
|
|
741
1407
|
//#endregion
|
|
1408
|
+
exports.DEFAULT_STUDIO_GROUP = DEFAULT_STUDIO_GROUP;
|
|
1409
|
+
exports.EDIT_ATTR = EDIT_ATTR;
|
|
1410
|
+
exports.Rule = Rule;
|
|
1411
|
+
exports.VISUAL_EDIT_MESSAGE = VISUAL_EDIT_MESSAGE;
|
|
1412
|
+
exports.applyPatch = applyPatch;
|
|
1413
|
+
exports.assertValid = assertValid;
|
|
1414
|
+
exports.buildStudioStructure = buildStudioStructure;
|
|
742
1415
|
exports.can = can;
|
|
743
1416
|
exports.cmsConfigToSchema = cmsConfigToSchema;
|
|
744
1417
|
exports.collectionSearchTableName = collectionSearchTableName;
|
|
745
1418
|
exports.collectionSearchTableSQL = collectionSearchTableSQL;
|
|
746
1419
|
exports.collectionToTable = collectionToTable;
|
|
747
1420
|
exports.collectionVersionsTable = collectionVersionsTable;
|
|
1421
|
+
exports.computePatch = computePatch;
|
|
1422
|
+
exports.createBlockRegistry = createBlockRegistry;
|
|
748
1423
|
exports.createLocalApi = createLocalApi;
|
|
749
1424
|
exports.createVersionedLocalApi = createVersionedLocalApi;
|
|
750
1425
|
exports.createWebhookHook = createWebhookHook;
|
|
1426
|
+
exports.decodeEditRef = decodeEditRef;
|
|
751
1427
|
exports.defineCmsConfig = defineCmsConfig;
|
|
752
1428
|
exports.defineCollection = defineCollection;
|
|
1429
|
+
exports.defineField = defineField;
|
|
1430
|
+
exports.defineMigration = defineMigration;
|
|
753
1431
|
exports.deliverWebhookMessage = deliverWebhookMessage;
|
|
1432
|
+
exports.diffDocuments = diffDocuments;
|
|
1433
|
+
exports.editAttr = editAttr;
|
|
1434
|
+
exports.encodeEditRef = encodeEditRef;
|
|
754
1435
|
exports.extractSearchText = extractSearchText;
|
|
755
1436
|
exports.flattenDoc = flattenDoc;
|
|
756
1437
|
exports.flattenFields = flattenFields;
|
|
757
1438
|
exports.generateSchemaSource = generateSchemaSource;
|
|
758
1439
|
exports.getCollectionsMeta = getCollectionsMeta;
|
|
759
1440
|
exports.getRegisteredApi = getRegisteredApi;
|
|
1441
|
+
exports.mountVisualEditing = mountVisualEditing;
|
|
760
1442
|
exports.nestDoc = nestDoc;
|
|
761
1443
|
exports.relationshipJoinTables = relationshipJoinTables;
|
|
1444
|
+
exports.renderBlocksToString = renderBlocksToString;
|
|
1445
|
+
exports.rule = rule;
|
|
1446
|
+
exports.runMigration = runMigration;
|
|
1447
|
+
exports.validateDocument = validateDocument;
|
|
762
1448
|
|
|
763
1449
|
//# sourceMappingURL=index.cjs.map
|