@pramen/cms 0.0.59 → 0.0.61

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/index.d.ts CHANGED
@@ -43,7 +43,29 @@ export interface FieldDefinition {
43
43
  *
44
44
  * Stored as text. Uniqueness is the schema's job (`unique(t.text())`).
45
45
  */
46
- | "slug" | "media" | "select" | "repeater" | "group";
46
+ | "slug" | "media" | "select"
47
+ /**
48
+ * A pointer to a record that is NOT this row — another pramen row, or a record in a
49
+ * system the CMS does not own. Stored as an OPAQUE id (a string), so the same field
50
+ * links a `cms_pages` row, a collection row and an external CRM record alike.
51
+ *
52
+ * `optionsFrom` on a `select` is most of this already, and stays the ergonomic case for
53
+ * a short closed list: it fetches `{ value, label }[]` ONCE and renders a `<select>`.
54
+ * What it cannot do is scale — no search term, no paging, and no way to render the
55
+ * label of a value whose record is not in the first (only) page. Twenty campaigns are
56
+ * fine; a thousand records are a dropdown nobody can use and a stored id that renders
57
+ * as a uuid.
58
+ *
59
+ * So a reference declares `referenceFrom`, a query handler called with BOTH shapes:
60
+ *
61
+ * { search?: string; limit: number; offset: number } -> browse / search
62
+ * { ids: string[] } -> resolve stored values
63
+ *
64
+ * and returning `{ items: ReferenceOption[]; hasMore?: boolean }` either way. The
65
+ * second shape is what makes an already-stored value renderable without fetching the
66
+ * whole set — the reason this is a field type and not a wider `select`.
67
+ */
68
+ | "reference" | "repeater" | "group";
47
69
  required?: boolean;
48
70
  default?: unknown;
49
71
  /** repeater/group only — the nested fields. */
@@ -58,6 +80,28 @@ export interface FieldDefinition {
58
80
  optionsFrom?: string;
59
81
  /** slug only — the sibling field this one is derived from (e.g. `"title"`). */
60
82
  from?: string;
83
+ /** reference only — the query handler that resolves this reference. See the `reference`
84
+ * type above for the two request shapes it must answer. */
85
+ referenceFrom?: string;
86
+ /** reference only — store a LIST of ids rather than one. A multiple reference is a
87
+ * `t.json()` column (an array), a single one is `t.text()`; `validateCollections`
88
+ * enforces the difference, because storing an array in a TEXT column is a raw driver
89
+ * error on the first write and nothing earlier. */
90
+ multiple?: boolean;
91
+ }
92
+ /** One option a `reference` field's `referenceFrom` handler returns. `hint` is secondary
93
+ * text (a date, an owner, a status) shown under the label — a picker over a thousand
94
+ * records usually needs more than a name to tell two rows apart. */
95
+ export interface ReferenceOption {
96
+ value: string;
97
+ label: string;
98
+ hint?: string;
99
+ }
100
+ /** What a `reference` field's `referenceFrom` handler returns, for BOTH request shapes.
101
+ * `hasMore` drives the picker's "Load more"; omitting it means "this is everything". */
102
+ export interface ReferenceResult {
103
+ items: ReferenceOption[];
104
+ hasMore?: boolean;
61
105
  }
62
106
  /** A named region on a content type; `allowedTypes` (block-type slugs) restricts what
63
107
  * may be placed there — `null`/omitted means any. */
@@ -103,7 +147,9 @@ export interface RichTextMark {
103
147
  export type RichText = RichTextDoc;
104
148
  /** Map one FieldDefinition (as a const literal) to the TS type of its RENDERED value.
105
149
  * Media resolves to `ResolvedMedia` (the assemble-time shape a component receives). */
106
- export type FieldTsType<D extends FieldDefinition> = D["type"] extends "text" | "textarea" | "url" | "select" | "date" | "datetime" | "publish" | "slug" ? string : D["type"] extends "richtext" ? RichText : D["type"] extends "number" ? number : D["type"] extends "boolean" ? boolean : D["type"] extends "media" ? ResolvedMedia | null : D["type"] extends "group" ? InferBlockFields<NonNullable<D["fields"]>> : D["type"] extends "repeater" ? InferBlockFields<NonNullable<D["fields"]>>[] : unknown;
150
+ export type FieldTsType<D extends FieldDefinition> = D["type"] extends "text" | "textarea" | "url" | "select" | "date" | "datetime" | "publish" | "slug" ? string : D["type"] extends "richtext" ? RichText : D["type"] extends "number" ? number : D["type"] extends "boolean" ? boolean : D["type"] extends "media" ? ResolvedMedia | null : D["type"] extends "reference" ? (D extends {
151
+ multiple: true;
152
+ } ? string[] : string) : D["type"] extends "group" ? InferBlockFields<NonNullable<D["fields"]>> : D["type"] extends "repeater" ? InferBlockFields<NonNullable<D["fields"]>>[] : unknown;
107
153
  /** Infer the `fields` object type from a const `FieldDefinition[]`. Required fields are
108
154
  * present; optional ones are `| undefined`. */
109
155
  export type InferBlockFields<T extends readonly FieldDefinition[]> = {
@@ -165,6 +211,8 @@ export declare function defineContentType(slug: string, opts: {
165
211
  regions: readonly RegionDefinition[];
166
212
  defaultBlocks?: readonly DefaultBlockDefinition[];
167
213
  }): ContentTypeDef;
214
+ /** The default `owner` — see {@link cmsBootstrap}. */
215
+ export declare const CMS_BOOTSTRAP_OWNER = "cms";
168
216
  /** Build a pramen `bootstrap` reconciler that upserts code-defined block + content types by
169
217
  * `slug` on each boot. Idempotent: inserts a missing type, updates a drifted one, leaves an
170
218
  * identical one untouched. Register it on your app:
@@ -173,10 +221,21 @@ export declare function defineContentType(slug: string, opts: {
173
221
  * bootstrap: [ cmsBootstrap({ blockTypes: [...], contentTypes: [...] }) ] };
174
222
  *
175
223
  * Runs with a privileged system Db, so a fresh/reprovisioned database converges to the
176
- * code-declared types with no manual createContentType/createBlockType call. */
224
+ * code-declared types with no manual createContentType/createBlockType call.
225
+ *
226
+ * Every row it writes is stamped `managedBy: owner`, which makes the editor show it
227
+ * read-only — convergence and an editor pointed at the same rows are otherwise a silent
228
+ * data-loss pair (GitHub #48). A type that drops out of the declaration is released back to
229
+ * the editor; a row this owner did not write is never touched, so a second reconciler (a
230
+ * package shipping its own block types, say) composes as long as it passes its own `owner`.
231
+ *
232
+ * The definitions are validated HERE, when the app is constructed, and every problem is
233
+ * reported at once — see {@link validateCmsDefinitions}. */
177
234
  export declare function cmsBootstrap(defs: {
178
235
  blockTypes?: readonly BlockTypeDef[];
179
236
  contentTypes?: readonly ContentTypeDef[];
237
+ }, opts?: {
238
+ owner?: string;
180
239
  }): BootstrapFn;
181
240
  /** Emit a `.ts` module of per-slug field interfaces + a `BlockFieldsBySlug` registry from
182
241
  * DB-stored block types (`{ slug, fieldsSchema }` rows). The runtime counterpart to the
@@ -187,6 +246,104 @@ export declare function generateBlockTypes(blockTypes: Array<{
187
246
  }>): string;
188
247
  /** The block/page builder tables. All in the default partition (relations can't cross
189
248
  * partitions). Prefixed `cms_` to avoid colliding with your own entities. */
249
+ /** What a menu item points at.
250
+ *
251
+ * `custom` is a literal href. The rest are REFERENCES resolved at read time, which is the
252
+ * whole reason the kind exists: a menu that stored `/about` verbatim breaks silently the
253
+ * day the page's slug changes, and the redirect that covers it is a second thing to
254
+ * remember. A `page` item follows the page. */
255
+ export type MenuItemKind = "custom" | "page" | "term" | "collection";
256
+ /** One entry in a menu tree. */
257
+ export interface MenuItem {
258
+ /** Stable within the menu — the editor's list key and the only handle a reorder has. */
259
+ id: string;
260
+ label: string;
261
+ /** Defaults to `"custom"` (a literal `url`). */
262
+ kind?: MenuItemKind;
263
+ /** `page`: a `cms_pages` id · `term`: a `cms_terms` id · `collection`: a collection slug. */
264
+ ref?: string | null;
265
+ /** `custom`: the href, stored verbatim (and `isSafeHref`-checked on write). For the
266
+ * resolved kinds this is FILLED IN by `getMenu` and ignored on write. */
267
+ url?: string;
268
+ target?: string;
269
+ titleAttr?: string;
270
+ cssClasses?: string;
271
+ children?: MenuItem[];
272
+ }
273
+ /** A named navigation menu. Read whole with `getMenu(name)`. */
274
+ export interface Menu {
275
+ id: string;
276
+ name: string;
277
+ label: string;
278
+ items: MenuItem[];
279
+ }
280
+ /** How deep a menu tree may nest. Menus are stored as one document, so without a cap a
281
+ * client could post a tree deep enough to blow the stack in the resolver — and no real
282
+ * navigation is more than three levels anyway. */
283
+ export declare const MAX_MENU_DEPTH = 5;
284
+ /** How many items one menu may hold, at every level combined.
285
+ *
286
+ * Depth alone is not a bound: a FLAT list of 800 `page` items is legal under
287
+ * `MAX_MENU_DEPTH` and turns every anonymous `getMenu` — the read on every page render of
288
+ * the site — into a single `WHERE id IN (?×800)`, which the read engine emits with no
289
+ * chunking. Every other read added alongside this one is bounded (`MAX_TERMS`,
290
+ * `clampLimit`); this one was not, and it is the one on the hot path. */
291
+ export declare const MAX_MENU_ITEMS = 200;
292
+ /** A classification vocabulary — `category`, `tag`, `region`, whatever the site sorts by. */
293
+ export interface Taxonomy {
294
+ id: string;
295
+ slug: string;
296
+ label: string;
297
+ pluralLabel?: string | null;
298
+ description?: string | null;
299
+ /** Terms may declare a `parentId`. A flat vocabulary REJECTS one on write, rather than
300
+ * accepting it and rendering it nowhere. */
301
+ hierarchical: boolean;
302
+ }
303
+ /** One term in a vocabulary. `children` is present only on the tree read (`getTermTree`). */
304
+ export interface Term {
305
+ id: string;
306
+ taxonomyId: string;
307
+ slug: string;
308
+ label: string;
309
+ description?: string | null;
310
+ parentId?: string | null;
311
+ position: number;
312
+ children?: Term[];
313
+ }
314
+ /** How deep a term hierarchy may nest — the same argument as {@link MAX_MENU_DEPTH}, except
315
+ * here the tree is rows and the risk is a parent CYCLE, which `assertTermParent` refuses. */
316
+ export declare const MAX_TERM_DEPTH = 5;
317
+ /** What a widget renders. `component` is the escape hatch: the CMS stores an id + props and
318
+ * the front end maps the id to one of its own components, exactly as `BlockRenderer` maps a
319
+ * block type slug — so a widget area can hold something the CMS has no idea how to draw. */
320
+ export type WidgetType = "content" | "menu" | "component";
321
+ /** One widget in a widget area. */
322
+ export interface Widget {
323
+ id: string;
324
+ type: WidgetType;
325
+ title?: string | null;
326
+ /** `content`: a rich-text document (normalized on write like any other richtext value). */
327
+ content?: RichTextDoc;
328
+ /** `menu`: a `cms_menus.name`. `getWidgetArea` resolves it to `menu` alongside. */
329
+ menuName?: string;
330
+ /** `component`: the front end's own component id + its props. */
331
+ componentId?: string;
332
+ componentProps?: FieldValues;
333
+ }
334
+ /** A widget as READ back: a `menu` widget carries its resolved menu, so a layout renders a
335
+ * whole sidebar from one call rather than one call per widget. */
336
+ export interface ResolvedWidget extends Widget {
337
+ menu?: Menu | null;
338
+ }
339
+ /** A named template region an admin fills without touching code. */
340
+ export interface WidgetArea {
341
+ id: string;
342
+ name: string;
343
+ label: string;
344
+ description?: string | null;
345
+ widgets: ResolvedWidget[];
346
+ }
190
347
  export declare const cmsSchema: {
191
348
  cms_content_types: import("@pramen/server").EntityDef<{
192
349
  id: {
@@ -221,6 +378,9 @@ export declare const cmsSchema: {
221
378
  defaultBlocks: {
222
379
  readonly type: "json";
223
380
  };
381
+ managedBy: {
382
+ readonly type: "text";
383
+ };
224
384
  createdAt: {
225
385
  readonly type: "text";
226
386
  } & {
@@ -260,6 +420,9 @@ export declare const cmsSchema: {
260
420
  category: {
261
421
  readonly type: "text";
262
422
  };
423
+ managedBy: {
424
+ readonly type: "text";
425
+ };
263
426
  createdAt: {
264
427
  readonly type: "text";
265
428
  } & {
@@ -428,6 +591,13 @@ export declare const cmsSchema: {
428
591
  readonly target: "cms_page_blocks";
429
592
  readonly column: string;
430
593
  };
594
+ terms: {
595
+ readonly kind: "manyToMany";
596
+ readonly target: "cms_terms";
597
+ readonly through: string;
598
+ readonly sourceColumn: string;
599
+ readonly targetColumn: string;
600
+ };
431
601
  }>;
432
602
  cms_page_blocks: import("@pramen/server").EntityDef<{
433
603
  id: {
@@ -600,6 +770,270 @@ export declare const cmsSchema: {
600
770
  readonly type: "text";
601
771
  };
602
772
  }, Record<string, never>>;
773
+ cms_menus: import("@pramen/server").EntityDef<{
774
+ id: {
775
+ readonly type: "uuid";
776
+ } & {
777
+ readonly generated: true;
778
+ } & {
779
+ readonly primaryKey: true;
780
+ readonly notNull: true;
781
+ };
782
+ name: {
783
+ readonly type: "text";
784
+ } & {
785
+ readonly notNull: true;
786
+ } & {
787
+ readonly unique: true;
788
+ };
789
+ label: {
790
+ readonly type: "text";
791
+ } & {
792
+ readonly notNull: true;
793
+ };
794
+ items: {
795
+ readonly type: "json";
796
+ };
797
+ version: {
798
+ readonly type: "integer";
799
+ } & {
800
+ readonly default: 1;
801
+ };
802
+ createdAt: {
803
+ readonly type: "text";
804
+ } & {
805
+ readonly defaultExpr: string;
806
+ };
807
+ updatedAt: {
808
+ readonly type: "text";
809
+ } & {
810
+ readonly defaultExpr: string;
811
+ };
812
+ }, Record<string, never>>;
813
+ cms_redirects: import("@pramen/server").EntityDef<{
814
+ id: {
815
+ readonly type: "uuid";
816
+ } & {
817
+ readonly generated: true;
818
+ } & {
819
+ readonly primaryKey: true;
820
+ readonly notNull: true;
821
+ };
822
+ fromPath: {
823
+ readonly type: "text";
824
+ } & {
825
+ readonly notNull: true;
826
+ } & {
827
+ readonly unique: true;
828
+ };
829
+ toPath: {
830
+ readonly type: "text";
831
+ } & {
832
+ readonly notNull: true;
833
+ };
834
+ status: {
835
+ readonly type: "integer";
836
+ } & {
837
+ readonly default: 301;
838
+ };
839
+ enabled: {
840
+ readonly type: "boolean";
841
+ } & {
842
+ readonly default: true;
843
+ };
844
+ note: {
845
+ readonly type: "text";
846
+ };
847
+ createdAt: {
848
+ readonly type: "text";
849
+ } & {
850
+ readonly defaultExpr: string;
851
+ };
852
+ updatedAt: {
853
+ readonly type: "text";
854
+ } & {
855
+ readonly defaultExpr: string;
856
+ };
857
+ }, Record<string, never>>;
858
+ cms_taxonomies: import("@pramen/server").EntityDef<{
859
+ id: {
860
+ readonly type: "uuid";
861
+ } & {
862
+ readonly generated: true;
863
+ } & {
864
+ readonly primaryKey: true;
865
+ readonly notNull: true;
866
+ };
867
+ slug: {
868
+ readonly type: "text";
869
+ } & {
870
+ readonly notNull: true;
871
+ } & {
872
+ readonly unique: true;
873
+ };
874
+ label: {
875
+ readonly type: "text";
876
+ } & {
877
+ readonly notNull: true;
878
+ };
879
+ pluralLabel: {
880
+ readonly type: "text";
881
+ };
882
+ description: {
883
+ readonly type: "text";
884
+ };
885
+ hierarchical: {
886
+ readonly type: "boolean";
887
+ } & {
888
+ readonly default: false;
889
+ };
890
+ createdAt: {
891
+ readonly type: "text";
892
+ } & {
893
+ readonly defaultExpr: string;
894
+ };
895
+ }, Record<string, never>>;
896
+ cms_terms: import("@pramen/server").EntityDef<{
897
+ id: {
898
+ readonly type: "uuid";
899
+ } & {
900
+ readonly generated: true;
901
+ } & {
902
+ readonly primaryKey: true;
903
+ readonly notNull: true;
904
+ };
905
+ taxonomyId: {
906
+ readonly type: "uuid";
907
+ } & {
908
+ readonly notNull: true;
909
+ } & {
910
+ readonly index: true;
911
+ };
912
+ slug: {
913
+ readonly type: "text";
914
+ } & {
915
+ readonly notNull: true;
916
+ };
917
+ label: {
918
+ readonly type: "text";
919
+ } & {
920
+ readonly notNull: true;
921
+ };
922
+ description: {
923
+ readonly type: "text";
924
+ };
925
+ parentId: {
926
+ readonly type: "uuid";
927
+ };
928
+ position: {
929
+ readonly type: "integer";
930
+ } & {
931
+ readonly default: 0;
932
+ };
933
+ createdAt: {
934
+ readonly type: "text";
935
+ } & {
936
+ readonly defaultExpr: string;
937
+ };
938
+ }, {
939
+ taxonomy: {
940
+ readonly kind: "belongsTo";
941
+ readonly target: "cms_taxonomies";
942
+ readonly column: string;
943
+ readonly onDelete: import("@pramen/server").OnDelete | undefined;
944
+ };
945
+ parent: {
946
+ readonly kind: "belongsTo";
947
+ readonly target: "cms_terms";
948
+ readonly column: string;
949
+ readonly onDelete: import("@pramen/server").OnDelete | undefined;
950
+ };
951
+ pages: {
952
+ readonly kind: "hasMany";
953
+ readonly target: "cms_page_terms";
954
+ readonly column: string;
955
+ };
956
+ }>;
957
+ cms_page_terms: import("@pramen/server").EntityDef<{
958
+ id: {
959
+ readonly type: "uuid";
960
+ } & {
961
+ readonly generated: true;
962
+ } & {
963
+ readonly primaryKey: true;
964
+ readonly notNull: true;
965
+ };
966
+ pageId: {
967
+ readonly type: "uuid";
968
+ } & {
969
+ readonly notNull: true;
970
+ } & {
971
+ readonly index: true;
972
+ };
973
+ termId: {
974
+ readonly type: "uuid";
975
+ } & {
976
+ readonly notNull: true;
977
+ } & {
978
+ readonly index: true;
979
+ };
980
+ }, {
981
+ page: {
982
+ readonly kind: "belongsTo";
983
+ readonly target: "cms_pages";
984
+ readonly column: string;
985
+ readonly onDelete: import("@pramen/server").OnDelete | undefined;
986
+ };
987
+ term: {
988
+ readonly kind: "belongsTo";
989
+ readonly target: "cms_terms";
990
+ readonly column: string;
991
+ readonly onDelete: import("@pramen/server").OnDelete | undefined;
992
+ };
993
+ }>;
994
+ cms_widget_areas: import("@pramen/server").EntityDef<{
995
+ id: {
996
+ readonly type: "uuid";
997
+ } & {
998
+ readonly generated: true;
999
+ } & {
1000
+ readonly primaryKey: true;
1001
+ readonly notNull: true;
1002
+ };
1003
+ name: {
1004
+ readonly type: "text";
1005
+ } & {
1006
+ readonly notNull: true;
1007
+ } & {
1008
+ readonly unique: true;
1009
+ };
1010
+ label: {
1011
+ readonly type: "text";
1012
+ } & {
1013
+ readonly notNull: true;
1014
+ };
1015
+ description: {
1016
+ readonly type: "text";
1017
+ };
1018
+ widgets: {
1019
+ readonly type: "json";
1020
+ };
1021
+ version: {
1022
+ readonly type: "integer";
1023
+ } & {
1024
+ readonly default: 1;
1025
+ };
1026
+ createdAt: {
1027
+ readonly type: "text";
1028
+ } & {
1029
+ readonly defaultExpr: string;
1030
+ };
1031
+ updatedAt: {
1032
+ readonly type: "text";
1033
+ } & {
1034
+ readonly defaultExpr: string;
1035
+ };
1036
+ }, Record<string, never>>;
603
1037
  cms_media: import("@pramen/server").EntityDef<{
604
1038
  id: {
605
1039
  readonly type: "uuid";
@@ -627,6 +1061,28 @@ export declare const cmsSchema: {
627
1061
  };
628
1062
  }, Record<string, never>>;
629
1063
  };
1064
+ /** Block Kit — custom admin pages, described as JSON and rendered by the editor. See
1065
+ * `./blockkit`. Re-exported so a host imports `adminPage` beside `collection`. */
1066
+ export { adminPage, createAdminPageHandlers, normalizeAdminResponse, validateAdminPages, MAX_ADMIN_BLOCK_DEPTH, } from "./blockkit";
1067
+ export type { AdminBlock, AdminButton, AdminElement, AdminInput, AdminInteractionType, AdminPageDef, AdminPageHandlerOpts, AdminPageInteraction, AdminPageMeta, AdminPageResponse, AdminText, } from "./blockkit";
1068
+ /**
1069
+ * Columns this package wrote in the pre-ISO space form that the SCHEMA cannot identify.
1070
+ *
1071
+ * `isoTimestampBackfill()` finds every column whose DEFAULT is `expr.now()` on its own.
1072
+ * `cms_pages.publishedAt` has no default at all — `doPublish` stamped it from handler code,
1073
+ * in the same space form, to stay comparable with the `updatedAt` written beside it. There
1074
+ * is nothing on that column to find, so it is named here.
1075
+ *
1076
+ * Spread it into the migration if your store was ever written by a build older than this
1077
+ * one:
1078
+ *
1079
+ * ```ts
1080
+ * migrations: [isoTimestampBackfill({ extraColumns: CMS_LEGACY_TIMESTAMP_COLUMNS })]
1081
+ * ```
1082
+ *
1083
+ * Costs nothing on a store that was not — the UPDATE matches no rows.
1084
+ */
1085
+ export declare const CMS_LEGACY_TIMESTAMP_COLUMNS: Readonly<Record<string, readonly string[]>>;
630
1086
  export interface ValidateOpts {
631
1087
  /** Enforce `required` fields (reject when missing). Default true. Editor-facing draft
632
1088
  * writes (addBlock/updateBlock/createPage) pass `false` — a DRAFT block may be incomplete;
@@ -644,6 +1100,43 @@ export interface ValidateOpts {
644
1100
  legacyBaseline?: FieldValues;
645
1101
  }
646
1102
  export declare function validateFields(schema: FieldDefinition[] | undefined | null, values: unknown, path?: string, opts?: ValidateOpts): void;
1103
+ /** Every field type the runtime knows. Exported because the editor's type-builder offers
1104
+ * exactly this list — one definition, so a type added here appears there without a second
1105
+ * edit, and a type removed here cannot be authored. */
1106
+ export declare const FIELD_TYPES: readonly FieldDefinition["type"][];
1107
+ /** How deep an authored field schema may nest. A schema is rendered by a recursive
1108
+ * component and validated by a recursive function, so the cap is what keeps both bounded
1109
+ * against a hand-posted document; nothing real nests past two or three. */
1110
+ export declare const MAX_FIELD_DEPTH = 5;
1111
+ /**
1112
+ * Validate and canonicalize an authored `FieldDefinition[]`, throwing a 400 on the first
1113
+ * problem. Returns the CLEANED schema — each field rebuilt from the keys its type actually
1114
+ * uses, so a `select`'s stale `options` cannot ride along on a field someone switched to
1115
+ * `text` and reappear if they switch back.
1116
+ */
1117
+ export declare function validateFieldSchema(raw: unknown, path?: string, depth?: number): FieldDefinition[];
1118
+ /** Resolve `slug` cross-references inside one schema, now that every sibling is known: a
1119
+ * `slug` field's `from` must name a field that exists AT THE SAME LEVEL (the editor reads
1120
+ * it out of the sibling bag) and holds text. Separate pass because forward references are
1121
+ * legitimate — a slug may precede the title it follows. */
1122
+ export declare function checkSlugSources(schema: readonly FieldDefinition[], path?: string): void;
1123
+ /** Validate + canonicalize an authored field schema end to end. */
1124
+ export declare function normalizeFieldSchema(raw: unknown, path?: string): FieldDefinition[];
1125
+ /**
1126
+ * Validate a content type's `regions`.
1127
+ *
1128
+ * A region NAME is the key `addBlock({ region })` resolves and the key of the assembled
1129
+ * `regions` object a front end reads, so it is held to the same shape as a field name. An
1130
+ * `allowedTypes` entry is a block-type SLUG; it is not checked against the block types that
1131
+ * exist, on purpose — a content type declaring a region for a block type that has not been
1132
+ * created yet is an ordinary order of work, and `assertRegionAllows` is what enforces the
1133
+ * list at placement time.
1134
+ */
1135
+ export declare function normalizeRegions(raw: unknown): RegionDefinition[];
1136
+ /** Validate a content type's `defaultBlocks` against its own regions. A default block that
1137
+ * names a region the type does not declare is created into nowhere — `createPage` would
1138
+ * place it under a key no renderer reads. */
1139
+ export declare function normalizeDefaultBlocks(raw: unknown, regions: readonly RegionDefinition[]): DefaultBlockDefinition[];
647
1140
  /** The node/mark vocabulary a `richtext` value may use. A node entry maps a node type to
648
1141
  * the attribute names kept on it; a mark entry does the same for a mark type. */
649
1142
  export interface RichTextSchema {
@@ -760,6 +1253,58 @@ export declare function imageUrl(key: string, opts?: {
760
1253
  quality?: number;
761
1254
  format?: "auto" | "webp" | "avif";
762
1255
  }): string;
1256
+ /** Refuse an editor write to a CODE-DEFINED type.
1257
+ *
1258
+ * `cmsBootstrap` reconciles these rows on every boot, so a save here would return 200 and
1259
+ * then be reverted at the next cold start, taking any content authored against the added
1260
+ * field with it. A 409 is the honest answer: the row exists, the edit is well-formed, and
1261
+ * the conflict is with a definition that lives somewhere this request cannot reach.
1262
+ *
1263
+ * The editor renders a managed type read-only, so this is the curl / stale-tab half.
1264
+ *
1265
+ * The caller MUST have read `managedBy` explicitly (`select`), not taken it off a wide read.
1266
+ * Reads are column-projected against the caller's policy, so under a read policy with a
1267
+ * `fields` list the column is simply absent — and a guard written as "absent means editable"
1268
+ * disarms itself for exactly the deployments that restrict fields. `select` fails CLOSED
1269
+ * instead: an unreadable column is a 403 before this runs. */
1270
+ export declare function assertNotManaged(row: Record<string, unknown>, what: string, defineFn: string): void;
1271
+ /** Where a resolved menu item points. Passed to `menuHref` so a deployment can route its
1272
+ * own way without the CMS guessing. */
1273
+ export type MenuHrefTarget = {
1274
+ kind: "page";
1275
+ slug: string;
1276
+ locale: string;
1277
+ } | {
1278
+ kind: "term";
1279
+ taxonomy: string;
1280
+ slug: string;
1281
+ } | {
1282
+ kind: "collection";
1283
+ slug: string;
1284
+ };
1285
+ /** A URL redirect's status. 301/308 are permanent (cached by browsers, and by search
1286
+ * engines as a canonical move); 302/307 are not. Anything else is not a redirect. */
1287
+ export declare const REDIRECT_STATUSES: readonly number[];
1288
+ /**
1289
+ * The stored form of a redirect's `fromPath`: a rooted, query-less, fragment-less path.
1290
+ *
1291
+ * Canonicalized rather than merely validated, because matching is an exact string lookup
1292
+ * against a UNIQUE column. `"/old"` and `"/old/"` are the same URL to a visitor and two
1293
+ * rows here, so the second one is dead the moment the first exists — and which one wins is
1294
+ * whichever the editor happened to type. Trailing slash off (except the root), fragment
1295
+ * and query dropped, percent-encoding left exactly as written (the parser's, and the
1296
+ * request's, canonical form).
1297
+ */
1298
+ export declare function normalizeRedirectPath(raw: unknown): string;
1299
+ /** A redirect's editable fields, as posted. `requireEnds` is the CREATE case, where both
1300
+ * ends must be present; an update patches whichever keys it sends. */
1301
+ interface RedirectPatch {
1302
+ fromPath?: string;
1303
+ toPath?: string;
1304
+ status?: number;
1305
+ enabled?: boolean;
1306
+ note?: string | null;
1307
+ }
763
1308
  /** What a preview link authorizes: one page, in one tenant, until `exp`. */
764
1309
  export interface PreviewToken {
765
1310
  /** tenant */ t: string;
@@ -821,6 +1366,14 @@ export interface CmsHandlerOpts {
821
1366
  * (what the shipped editor produces). Widen it if your editor adds TipTap extensions —
822
1367
  * a node type absent from the schema is DROPPED on write, not rejected. */
823
1368
  richTextSchema?: RichTextSchema;
1369
+ /** Map a resolved menu reference to a site path. The CMS is headless, so it cannot know
1370
+ * how a deployment routes — this is the same seam `sitemapXml`'s `pageUrl` is.
1371
+ *
1372
+ * Defaults: a page is `/${slug}` on a monolingual deployment and `/${locale}/${slug}`
1373
+ * once `locales` declares more than one; a term is `/${taxonomy}/${term}`; a collection
1374
+ * is `/${slug}/`. Override it and `getMenu` follows — which is the point of storing a
1375
+ * REFERENCE rather than the href an editor typed: change the routing, not the menu. */
1376
+ menuHref?: (target: MenuHrefTarget) => string;
824
1377
  }
825
1378
  /** Build the CMS handler map. Spread into your app's handlers. Editor mutations are
826
1379
  * gated both by `auth` (fast 403 before the body) and by the row ACL (cmsPolicies). */
@@ -863,6 +1416,188 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
863
1416
  fieldsSchema?: FieldDefinition[];
864
1417
  defaultBlocks?: DefaultBlockDefinition[];
865
1418
  }, Record<string, unknown> | undefined>;
1419
+ /** One menu, references resolved to hrefs. PUBLIC — a menu is site chrome.
1420
+ *
1421
+ * Returns `null` for an unknown name rather than 404ing, because a layout asking for a
1422
+ * menu it has not created yet is the normal state of a site being built, and a thrown
1423
+ * error there takes down every page instead of rendering no nav. */
1424
+ getMenu: import("@pramen/server").Handler<{
1425
+ name: string;
1426
+ }, Menu | null>;
1427
+ /** Every menu, RAW (references unresolved) — the editor's list.
1428
+ *
1429
+ * Capped like every other list here. A site-furniture table is small by nature, which is
1430
+ * an argument for the cap being generous, not for its absence: an unbounded SELECT of a
1431
+ * wide row over RPC is the D1 failure shape from GitHub #22, and "small by nature" is
1432
+ * not a property the query planner knows about. */
1433
+ listMenus: import("@pramen/server").Handler<unknown, Record<string, unknown>[]>;
1434
+ createMenu: import("@pramen/server").Handler<{
1435
+ name: string;
1436
+ label: string;
1437
+ items?: MenuItem[];
1438
+ }, Record<string, unknown>>;
1439
+ /** Patch a menu found by `id` or `name`. `name` is the key layout code resolves and is
1440
+ * NOT mutable — retitling is what `label` is for. */
1441
+ updateMenu: import("@pramen/server").Handler<{
1442
+ id?: string;
1443
+ name?: string;
1444
+ label?: string;
1445
+ items?: MenuItem[];
1446
+ expectedVersion?: number;
1447
+ }, Record<string, unknown> | undefined>;
1448
+ deleteMenu: import("@pramen/server").Handler<{
1449
+ id: string;
1450
+ }, {
1451
+ ok: true;
1452
+ }>;
1453
+ /** Resolve a request path to its redirect, or `null`. PUBLIC and READ-ONLY: this is
1454
+ * what a front end calls on a 404, so it is anonymous traffic on the hot path.
1455
+ *
1456
+ * `enabled` is enforced by the public read POLICY, not by a `where` here, so a disabled
1457
+ * redirect is invisible to every anonymous read (this handler, a future listing, a
1458
+ * relation traversal) rather than to the one call that remembered to filter. */
1459
+ resolveRedirect: import("@pramen/server").Handler<{
1460
+ path: string;
1461
+ }, {
1462
+ to: string;
1463
+ status: number;
1464
+ } | null>;
1465
+ listRedirects: import("@pramen/server").Handler<{
1466
+ limit?: number;
1467
+ offset?: number;
1468
+ }, Record<string, unknown>[]>;
1469
+ createRedirect: import("@pramen/server").Handler<RedirectPatch & {
1470
+ fromPath: string;
1471
+ toPath: string;
1472
+ }, Record<string, unknown>>;
1473
+ updateRedirect: import("@pramen/server").Handler<RedirectPatch & {
1474
+ id: string;
1475
+ }, Record<string, unknown> | undefined>;
1476
+ deleteRedirect: import("@pramen/server").Handler<{
1477
+ id: string;
1478
+ }, {
1479
+ ok: true;
1480
+ }>;
1481
+ /** Every vocabulary. PUBLIC — like content types, a taxonomy's slug is structural (it
1482
+ * is a URL segment) and a front end routes on it. */
1483
+ listTaxonomies: import("@pramen/server").Handler<unknown, Record<string, unknown>[]>;
1484
+ createTaxonomy: import("@pramen/server").Handler<{
1485
+ slug: string;
1486
+ label: string;
1487
+ pluralLabel?: string;
1488
+ description?: string;
1489
+ hierarchical?: boolean;
1490
+ }, Record<string, unknown>>;
1491
+ /** Patch a vocabulary. `slug` is a URL segment and the key `listTerms` resolves, so it
1492
+ * is not mutable — the same rule content types and block types already follow.
1493
+ *
1494
+ * Turning `hierarchical` OFF is refused while any term still has a parent. Allowing it
1495
+ * would leave a stored hierarchy that no reader renders and no writer can clear, and
1496
+ * flattening the terms silently is a destructive edit behind a checkbox. */
1497
+ updateTaxonomy: import("@pramen/server").Handler<{
1498
+ id: string;
1499
+ label?: string;
1500
+ pluralLabel?: string | null;
1501
+ description?: string | null;
1502
+ hierarchical?: boolean;
1503
+ }, Record<string, unknown> | undefined>;
1504
+ /** Delete a vocabulary. Its terms go with it, and their page assignments with those —
1505
+ * both by real `ON DELETE CASCADE`, so the cleanup is the DB's and cannot be half-done
1506
+ * by a handler that threw between two writes. */
1507
+ deleteTaxonomy: import("@pramen/server").Handler<{
1508
+ id: string;
1509
+ }, {
1510
+ ok: true;
1511
+ }>;
1512
+ /** One vocabulary's terms, flat, ordered. PUBLIC. */
1513
+ listTerms: import("@pramen/server").Handler<{
1514
+ taxonomy: string;
1515
+ }, Term[]>;
1516
+ /** One vocabulary's terms as a TREE. PUBLIC. Assembled here rather than by the caller
1517
+ * because a hierarchy is what the nav renders and every consumer would otherwise write
1518
+ * the same fold. */
1519
+ getTermTree: import("@pramen/server").Handler<{
1520
+ taxonomy: string;
1521
+ }, Term[]>;
1522
+ createTerm: import("@pramen/server").Handler<{
1523
+ taxonomy: string;
1524
+ slug: string;
1525
+ label: string;
1526
+ description?: string;
1527
+ parentId?: string | null;
1528
+ position?: number;
1529
+ }, Record<string, unknown>>;
1530
+ updateTerm: import("@pramen/server").Handler<{
1531
+ id: string;
1532
+ slug?: string;
1533
+ label?: string;
1534
+ description?: string | null;
1535
+ parentId?: string | null;
1536
+ position?: number;
1537
+ }, Record<string, unknown> | undefined>;
1538
+ /** Delete a term. Children are promoted to the top level (`ON DELETE SET NULL`) and
1539
+ * page assignments are removed (`ON DELETE CASCADE`) — see `cms_terms.parentId`. */
1540
+ deleteTerm: import("@pramen/server").Handler<{
1541
+ id: string;
1542
+ }, {
1543
+ ok: true;
1544
+ }>;
1545
+ /** A page's assigned terms. PUBLIC (a published page's classification is public). */
1546
+ listPageTerms: import("@pramen/server").Handler<{
1547
+ pageId: string;
1548
+ }, Term[]>;
1549
+ /** Replace a page's term assignments wholesale.
1550
+ *
1551
+ * Set semantics, not add/remove: the editor's panel holds the whole selection, and two
1552
+ * calls that each patch one end of it race into a state neither asked for. Existing
1553
+ * links that survive are LEFT ALONE rather than deleted and reinserted, so the junction
1554
+ * rows (and any future column on them) are stable across a save that changed nothing. */
1555
+ setPageTerms: import("@pramen/server").Handler<{
1556
+ pageId: string;
1557
+ termIds: string[];
1558
+ }, {
1559
+ ok: true;
1560
+ count: number;
1561
+ }>;
1562
+ /** Published pages carrying a term. PUBLIC.
1563
+ *
1564
+ * A relation traversal (`where: { terms: { id } }`), so the page read scope is
1565
+ * AND-merged as it is anywhere else — traversal cannot widen access, and an anonymous
1566
+ * caller sees published pages only. */
1567
+ listPagesByTerm: import("@pramen/server").Handler<{
1568
+ taxonomy: string;
1569
+ term: string;
1570
+ limit?: number;
1571
+ offset?: number;
1572
+ }, Record<string, unknown>[]>;
1573
+ /** One widget area, with `menu` widgets resolved to their menus. PUBLIC.
1574
+ *
1575
+ * `null` for an unknown name, for the same reason `getMenu` returns null: a layout
1576
+ * asking for a sidebar nobody has filled in yet is the normal state of a site under
1577
+ * construction, and throwing there takes down the page. */
1578
+ getWidgetArea: import("@pramen/server").Handler<{
1579
+ name: string;
1580
+ }, WidgetArea | null>;
1581
+ listWidgetAreas: import("@pramen/server").Handler<unknown, Record<string, unknown>[]>;
1582
+ createWidgetArea: import("@pramen/server").Handler<{
1583
+ name: string;
1584
+ label: string;
1585
+ description?: string;
1586
+ widgets?: Widget[];
1587
+ }, Record<string, unknown>>;
1588
+ updateWidgetArea: import("@pramen/server").Handler<{
1589
+ id?: string;
1590
+ name?: string;
1591
+ label?: string;
1592
+ description?: string | null;
1593
+ widgets?: Widget[];
1594
+ expectedVersion?: number;
1595
+ }, Record<string, unknown> | undefined>;
1596
+ deleteWidgetArea: import("@pramen/server").Handler<{
1597
+ id: string;
1598
+ }, {
1599
+ ok: true;
1600
+ }>;
866
1601
  /** Mint a signed upload URL for a media blob (keyed under the tenant's `media/`
867
1602
  * prefix so the public /media route can serve it). The client PUTs the bytes to
868
1603
  * `url`, then calls `createMedia` with the returned `ref`. */
@@ -1050,6 +1785,9 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
1050
1785
  defaultLocale: string;
1051
1786
  multilingual: boolean;
1052
1787
  pagesByType: true;
1788
+ siteFurniture: true;
1789
+ codeDefinedTypes: true;
1790
+ canEdit: boolean;
1053
1791
  }>;
1054
1792
  /** Distinct locales present across all pages. NOTE: a DATA query — what is in the
1055
1793
  * store — not configuration. `listCmsCapabilities().locales` is what the deployment
@@ -1277,6 +2015,188 @@ export declare const cmsHandlers: {
1277
2015
  fieldsSchema?: FieldDefinition[];
1278
2016
  defaultBlocks?: DefaultBlockDefinition[];
1279
2017
  }, Record<string, unknown> | undefined>;
2018
+ /** One menu, references resolved to hrefs. PUBLIC — a menu is site chrome.
2019
+ *
2020
+ * Returns `null` for an unknown name rather than 404ing, because a layout asking for a
2021
+ * menu it has not created yet is the normal state of a site being built, and a thrown
2022
+ * error there takes down every page instead of rendering no nav. */
2023
+ getMenu: import("@pramen/server").Handler<{
2024
+ name: string;
2025
+ }, Menu | null>;
2026
+ /** Every menu, RAW (references unresolved) — the editor's list.
2027
+ *
2028
+ * Capped like every other list here. A site-furniture table is small by nature, which is
2029
+ * an argument for the cap being generous, not for its absence: an unbounded SELECT of a
2030
+ * wide row over RPC is the D1 failure shape from GitHub #22, and "small by nature" is
2031
+ * not a property the query planner knows about. */
2032
+ listMenus: import("@pramen/server").Handler<unknown, Record<string, unknown>[]>;
2033
+ createMenu: import("@pramen/server").Handler<{
2034
+ name: string;
2035
+ label: string;
2036
+ items?: MenuItem[];
2037
+ }, Record<string, unknown>>;
2038
+ /** Patch a menu found by `id` or `name`. `name` is the key layout code resolves and is
2039
+ * NOT mutable — retitling is what `label` is for. */
2040
+ updateMenu: import("@pramen/server").Handler<{
2041
+ id?: string;
2042
+ name?: string;
2043
+ label?: string;
2044
+ items?: MenuItem[];
2045
+ expectedVersion?: number;
2046
+ }, Record<string, unknown> | undefined>;
2047
+ deleteMenu: import("@pramen/server").Handler<{
2048
+ id: string;
2049
+ }, {
2050
+ ok: true;
2051
+ }>;
2052
+ /** Resolve a request path to its redirect, or `null`. PUBLIC and READ-ONLY: this is
2053
+ * what a front end calls on a 404, so it is anonymous traffic on the hot path.
2054
+ *
2055
+ * `enabled` is enforced by the public read POLICY, not by a `where` here, so a disabled
2056
+ * redirect is invisible to every anonymous read (this handler, a future listing, a
2057
+ * relation traversal) rather than to the one call that remembered to filter. */
2058
+ resolveRedirect: import("@pramen/server").Handler<{
2059
+ path: string;
2060
+ }, {
2061
+ to: string;
2062
+ status: number;
2063
+ } | null>;
2064
+ listRedirects: import("@pramen/server").Handler<{
2065
+ limit?: number;
2066
+ offset?: number;
2067
+ }, Record<string, unknown>[]>;
2068
+ createRedirect: import("@pramen/server").Handler<RedirectPatch & {
2069
+ fromPath: string;
2070
+ toPath: string;
2071
+ }, Record<string, unknown>>;
2072
+ updateRedirect: import("@pramen/server").Handler<RedirectPatch & {
2073
+ id: string;
2074
+ }, Record<string, unknown> | undefined>;
2075
+ deleteRedirect: import("@pramen/server").Handler<{
2076
+ id: string;
2077
+ }, {
2078
+ ok: true;
2079
+ }>;
2080
+ /** Every vocabulary. PUBLIC — like content types, a taxonomy's slug is structural (it
2081
+ * is a URL segment) and a front end routes on it. */
2082
+ listTaxonomies: import("@pramen/server").Handler<unknown, Record<string, unknown>[]>;
2083
+ createTaxonomy: import("@pramen/server").Handler<{
2084
+ slug: string;
2085
+ label: string;
2086
+ pluralLabel?: string;
2087
+ description?: string;
2088
+ hierarchical?: boolean;
2089
+ }, Record<string, unknown>>;
2090
+ /** Patch a vocabulary. `slug` is a URL segment and the key `listTerms` resolves, so it
2091
+ * is not mutable — the same rule content types and block types already follow.
2092
+ *
2093
+ * Turning `hierarchical` OFF is refused while any term still has a parent. Allowing it
2094
+ * would leave a stored hierarchy that no reader renders and no writer can clear, and
2095
+ * flattening the terms silently is a destructive edit behind a checkbox. */
2096
+ updateTaxonomy: import("@pramen/server").Handler<{
2097
+ id: string;
2098
+ label?: string;
2099
+ pluralLabel?: string | null;
2100
+ description?: string | null;
2101
+ hierarchical?: boolean;
2102
+ }, Record<string, unknown> | undefined>;
2103
+ /** Delete a vocabulary. Its terms go with it, and their page assignments with those —
2104
+ * both by real `ON DELETE CASCADE`, so the cleanup is the DB's and cannot be half-done
2105
+ * by a handler that threw between two writes. */
2106
+ deleteTaxonomy: import("@pramen/server").Handler<{
2107
+ id: string;
2108
+ }, {
2109
+ ok: true;
2110
+ }>;
2111
+ /** One vocabulary's terms, flat, ordered. PUBLIC. */
2112
+ listTerms: import("@pramen/server").Handler<{
2113
+ taxonomy: string;
2114
+ }, Term[]>;
2115
+ /** One vocabulary's terms as a TREE. PUBLIC. Assembled here rather than by the caller
2116
+ * because a hierarchy is what the nav renders and every consumer would otherwise write
2117
+ * the same fold. */
2118
+ getTermTree: import("@pramen/server").Handler<{
2119
+ taxonomy: string;
2120
+ }, Term[]>;
2121
+ createTerm: import("@pramen/server").Handler<{
2122
+ taxonomy: string;
2123
+ slug: string;
2124
+ label: string;
2125
+ description?: string;
2126
+ parentId?: string | null;
2127
+ position?: number;
2128
+ }, Record<string, unknown>>;
2129
+ updateTerm: import("@pramen/server").Handler<{
2130
+ id: string;
2131
+ slug?: string;
2132
+ label?: string;
2133
+ description?: string | null;
2134
+ parentId?: string | null;
2135
+ position?: number;
2136
+ }, Record<string, unknown> | undefined>;
2137
+ /** Delete a term. Children are promoted to the top level (`ON DELETE SET NULL`) and
2138
+ * page assignments are removed (`ON DELETE CASCADE`) — see `cms_terms.parentId`. */
2139
+ deleteTerm: import("@pramen/server").Handler<{
2140
+ id: string;
2141
+ }, {
2142
+ ok: true;
2143
+ }>;
2144
+ /** A page's assigned terms. PUBLIC (a published page's classification is public). */
2145
+ listPageTerms: import("@pramen/server").Handler<{
2146
+ pageId: string;
2147
+ }, Term[]>;
2148
+ /** Replace a page's term assignments wholesale.
2149
+ *
2150
+ * Set semantics, not add/remove: the editor's panel holds the whole selection, and two
2151
+ * calls that each patch one end of it race into a state neither asked for. Existing
2152
+ * links that survive are LEFT ALONE rather than deleted and reinserted, so the junction
2153
+ * rows (and any future column on them) are stable across a save that changed nothing. */
2154
+ setPageTerms: import("@pramen/server").Handler<{
2155
+ pageId: string;
2156
+ termIds: string[];
2157
+ }, {
2158
+ ok: true;
2159
+ count: number;
2160
+ }>;
2161
+ /** Published pages carrying a term. PUBLIC.
2162
+ *
2163
+ * A relation traversal (`where: { terms: { id } }`), so the page read scope is
2164
+ * AND-merged as it is anywhere else — traversal cannot widen access, and an anonymous
2165
+ * caller sees published pages only. */
2166
+ listPagesByTerm: import("@pramen/server").Handler<{
2167
+ taxonomy: string;
2168
+ term: string;
2169
+ limit?: number;
2170
+ offset?: number;
2171
+ }, Record<string, unknown>[]>;
2172
+ /** One widget area, with `menu` widgets resolved to their menus. PUBLIC.
2173
+ *
2174
+ * `null` for an unknown name, for the same reason `getMenu` returns null: a layout
2175
+ * asking for a sidebar nobody has filled in yet is the normal state of a site under
2176
+ * construction, and throwing there takes down the page. */
2177
+ getWidgetArea: import("@pramen/server").Handler<{
2178
+ name: string;
2179
+ }, WidgetArea | null>;
2180
+ listWidgetAreas: import("@pramen/server").Handler<unknown, Record<string, unknown>[]>;
2181
+ createWidgetArea: import("@pramen/server").Handler<{
2182
+ name: string;
2183
+ label: string;
2184
+ description?: string;
2185
+ widgets?: Widget[];
2186
+ }, Record<string, unknown>>;
2187
+ updateWidgetArea: import("@pramen/server").Handler<{
2188
+ id?: string;
2189
+ name?: string;
2190
+ label?: string;
2191
+ description?: string | null;
2192
+ widgets?: Widget[];
2193
+ expectedVersion?: number;
2194
+ }, Record<string, unknown> | undefined>;
2195
+ deleteWidgetArea: import("@pramen/server").Handler<{
2196
+ id: string;
2197
+ }, {
2198
+ ok: true;
2199
+ }>;
1280
2200
  /** Mint a signed upload URL for a media blob (keyed under the tenant's `media/`
1281
2201
  * prefix so the public /media route can serve it). The client PUTs the bytes to
1282
2202
  * `url`, then calls `createMedia` with the returned `ref`. */
@@ -1464,6 +2384,9 @@ export declare const cmsHandlers: {
1464
2384
  defaultLocale: string;
1465
2385
  multilingual: boolean;
1466
2386
  pagesByType: true;
2387
+ siteFurniture: true;
2388
+ codeDefinedTypes: true;
2389
+ canEdit: boolean;
1467
2390
  }>;
1468
2391
  /** Distinct locales present across all pages. NOTE: a DATA query — what is in the
1469
2392
  * store — not configuration. `listCmsCapabilities().locales` is what the deployment
@@ -1668,6 +2591,10 @@ export declare function cmsPolicies(opts?: CmsPolicyOpts): {
1668
2591
  public: Policy[];
1669
2592
  editor: Policy[];
1670
2593
  };
2594
+ /** Nav positions for the editor's built-in sections — see `./nav`. Re-exported here so a
2595
+ * host writing `app.ts` imports it from the same place as `collection()`. It LIVES in a leaf
2596
+ * module because `blockkit.ts` needs it too and must not depend on this one. */
2597
+ export { NAV_ORDER } from "./nav";
1671
2598
  /** Declares that a pramen entity is editable as a collection in the CMS editor. Both
1672
2599
  * halves live here: the runtime facts (entity, idField, validation via `fields`) and the
1673
2600
  * UI facts (labels, list columns, ordering). Mirror of {@link ContentTypeDef}. */
@@ -1699,6 +2626,16 @@ export interface CollectionDef {
1699
2626
  column: string;
1700
2627
  dir?: "asc" | "desc";
1701
2628
  };
2629
+ /** Where this collection sits in the editor's primary nav. Lower comes first; ties keep
2630
+ * declaration order. Defaults to `NAV_ORDER.collections`, which is between Pages and
2631
+ * Media — where collections have always rendered.
2632
+ *
2633
+ * The point is not cosmetic. Before this the only extension seam was `extraNav`, which
2634
+ * renders dead LAST and (by default, and for good reason) opens a new tab — so a
2635
+ * project-specific section could only ever be a separate app bolted onto the end of the
2636
+ * admin. A section that belongs beside Pages can now say so. See {@link NAV_ORDER} for
2637
+ * the built-in positions to place against. */
2638
+ readonly navOrder?: number;
1702
2639
  /** Workflow features this collection opts into — see {@link CollectionFeature}. Each is
1703
2640
  * backed by MANAGED COLUMNS on `entity` that the CMS writes and `fields` may not declare.
1704
2641
  * Validated against your schema at `createCollectionHandlers` time (which is why that call
@@ -1728,6 +2665,10 @@ export interface CollectionMeta {
1728
2665
  label: string;
1729
2666
  pluralLabel: string;
1730
2667
  icon?: string;
2668
+ /** Nav position — see {@link CollectionDef.navOrder}. Always present in the meta (the
2669
+ * default is filled here) so the editor sorts one list of numbers rather than deciding
2670
+ * per entry whether a default applies. */
2671
+ navOrder: number;
1731
2672
  fields: readonly FieldDefinition[];
1732
2673
  list: readonly string[];
1733
2674
  titleField: string;
@@ -1896,7 +2837,7 @@ export declare function createCollectionHandlers(collections: readonly Collectio
1896
2837
  revisionId: string;
1897
2838
  }, Record<string, unknown>>;
1898
2839
  /** Mint a signed link that shows ONE row's unpublished state, to whoever holds it.
1899
- * Mirrors `signPagePreview` — same secret, same TTL clamp, same D1 refusal, and the
2840
+ * Mirrors `signPagePreview` — same secret, same TTL clamp, works on both stores, and the
1900
2841
  * same rule that the row is read through the ACL FIRST: minting a link is granting
1901
2842
  * access to the row, so a caller who cannot read it must not be able to mint one. */
1902
2843
  signCollectionPreview: import("@pramen/server").Handler<{