@pramen/cms 0.0.61 → 0.0.63

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/README.md CHANGED
@@ -126,6 +126,34 @@ entry, so doing it there made the returned array stop matching the `as const` li
126
126
  the tenant's `media/` prefix); the client PUTs the bytes, then `createMedia({ ref, alt? })`
127
127
  confirms the blob is in R2 and persists a `cms_media` row. `listMedia`/`getMedia`/`deleteMedia`
128
128
  (deleteMedia also removes the R2 blob) round it out. Editor-gated.
129
+ - **Browsing:** `listMedia({ limit, offset, q?, sort?, kind?, term? })`. `q` matches the filename **or**
130
+ the alt text (case-insensitive; a `%` or `_` in the needle is a literal), `sort` is one of
131
+ `newest`/`oldest`/`name`/`name_desc`/`largest`/`smallest`, and `kind` narrows to
132
+ `image`/`video`/`audio`/`document`/`other`. All three are applied in SQL rather than to the
133
+ page that arrived, so they mean what they say on a library larger than one page. `sort` and
134
+ `kind` are closed vocabularies — a caller never names a column — and an unrecognised value
135
+ falls back to the default instead of erroring.
136
+
137
+ This is what `cms_media.filename`/`contentType`/`size` are for: `file` is a `fileRef` (JSON in
138
+ a TEXT cell), which `orderBy` and `where` cannot see into, so the three fields the library
139
+ queries by are projected onto indexed columns when a row is created. **Spread `cmsMigrations`
140
+ into `app.migrations`** to backfill rows written before those columns existed — without it
141
+ they keep NULL, sort together under a name sort, and answer only the `other` filter.
142
+ - **Tagging:** files carry taxonomy terms from the same `cms_taxonomies`/`cms_terms` tables pages
143
+ use, through a `cms_media_terms` junction — one vocabulary, edited in one place, applied to
144
+ whichever of the two it declares. A vocabulary carries **`appliesTo`** — `["page"]`,
145
+ `["media"]`, both, or `null` for everything (which is what an un-narrowed one, and every row
146
+ written before the column existed, means). `listTaxonomies({ target })` narrows to it, and
147
+ `setPageTerms`/`setMediaTerms` REFUSE a term from a vocabulary that does not apply, so it is a
148
+ rule rather than a UI hint. Narrowing a vocabulary away from something it is still assigned to
149
+ is refused too — those assignments would stay stored and stop being reachable from the panel
150
+ that could remove them. `listMediaTerms({ mediaId })` reads a file's terms and `setMediaTerms({ mediaId, termIds })`
151
+ replaces them wholesale (set semantics, like `setPageTerms`); `listMedia({ term })` filters by
152
+ one, ANDed with `kind` and `q`. The filter is a relation traversal compiled to a subquery, so
153
+ it narrows in SQL like every other option here. Deleting a term takes its assignments with it
154
+ (a real `ON DELETE CASCADE`), and trashing a file does NOT — only `purgeMedia` does, so a
155
+ restored file keeps its tags. Declared to the editor as
156
+ `listCmsCapabilities().mediaTerms`.
129
157
  - **Reference from a block:** a `"media"` field stores a `cms_media` id. At assemble/publish time
130
158
  the id is resolved (recursively, through group/repeater nesting) to a `ResolvedMedia`
131
159
  `{ id, key, url, alt, contentType, filename }` in the snapshot — so the content API returns a
@@ -195,6 +223,12 @@ authorization. Spread `cmsRoutes()` into `app.routes` to serve `GET /cms/preview
195
223
  verifies the token in the Worker before any read and returns the live draft with
196
224
  `isPreview: true` and `Cache-Control: private, no-store`.
197
225
 
226
+ That route answers with **JSON** — this CMS is headless, so it has the draft and no idea what
227
+ it should look like. Your site renders it: redeem the same token with `client.getPreview(token)`
228
+ (`@pramen/cms-astro`) from a route of your own, through the same components the published page
229
+ uses, and point the editor's Preview link button at it with `admin.previewUrl`. A working one
230
+ is `example/site/src/pages/preview.astro`.
231
+
198
232
  If you pass custom roles to `createCmsHandlers`, hand `cmsRoutes` the **same options
199
233
  object** — it derives the route's identity from them, so the two cannot drift:
200
234
 
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { HandlerContext, Policy, FileRef, BootstrapFn, JsonValue, SchemaDef } from "@pramen/server";
1
+ import type { HandlerContext, Policy, FileRef, BootstrapFn, DataMigration, JsonValue, SchemaDef } from "@pramen/server";
2
2
  import type { EnvBag } from "@pramen/server";
3
3
  /** A field in a block type's (or content type's) field schema. Recursive: a `repeater`
4
4
  * or `group` nests `fields`. Mirrors WollyCMS's FieldDefinition. */
@@ -887,6 +887,9 @@ export declare const cmsSchema: {
887
887
  } & {
888
888
  readonly default: false;
889
889
  };
890
+ appliesTo: {
891
+ readonly type: "json";
892
+ };
890
893
  createdAt: {
891
894
  readonly type: "text";
892
895
  } & {
@@ -953,6 +956,11 @@ export declare const cmsSchema: {
953
956
  readonly target: "cms_page_terms";
954
957
  readonly column: string;
955
958
  };
959
+ media: {
960
+ readonly kind: "hasMany";
961
+ readonly target: "cms_media_terms";
962
+ readonly column: string;
963
+ };
956
964
  }>;
957
965
  cms_page_terms: import("@pramen/server").EntityDef<{
958
966
  id: {
@@ -991,6 +999,43 @@ export declare const cmsSchema: {
991
999
  readonly onDelete: import("@pramen/server").OnDelete | undefined;
992
1000
  };
993
1001
  }>;
1002
+ cms_media_terms: import("@pramen/server").EntityDef<{
1003
+ id: {
1004
+ readonly type: "uuid";
1005
+ } & {
1006
+ readonly generated: true;
1007
+ } & {
1008
+ readonly primaryKey: true;
1009
+ readonly notNull: true;
1010
+ };
1011
+ mediaId: {
1012
+ readonly type: "uuid";
1013
+ } & {
1014
+ readonly notNull: true;
1015
+ } & {
1016
+ readonly index: true;
1017
+ };
1018
+ termId: {
1019
+ readonly type: "uuid";
1020
+ } & {
1021
+ readonly notNull: true;
1022
+ } & {
1023
+ readonly index: true;
1024
+ };
1025
+ }, {
1026
+ media: {
1027
+ readonly kind: "belongsTo";
1028
+ readonly target: "cms_media";
1029
+ readonly column: string;
1030
+ readonly onDelete: import("@pramen/server").OnDelete | undefined;
1031
+ };
1032
+ term: {
1033
+ readonly kind: "belongsTo";
1034
+ readonly target: "cms_terms";
1035
+ readonly column: string;
1036
+ readonly onDelete: import("@pramen/server").OnDelete | undefined;
1037
+ };
1038
+ }>;
994
1039
  cms_widget_areas: import("@pramen/server").EntityDef<{
995
1040
  id: {
996
1041
  readonly type: "uuid";
@@ -1046,6 +1091,19 @@ export declare const cmsSchema: {
1046
1091
  file: {
1047
1092
  readonly type: "fileRef";
1048
1093
  };
1094
+ filename: {
1095
+ readonly type: "text";
1096
+ } & {
1097
+ readonly index: true;
1098
+ };
1099
+ contentType: {
1100
+ readonly type: "text";
1101
+ } & {
1102
+ readonly index: true;
1103
+ };
1104
+ size: {
1105
+ readonly type: "integer";
1106
+ };
1049
1107
  alt: {
1050
1108
  readonly type: "text";
1051
1109
  };
@@ -1059,8 +1117,36 @@ export declare const cmsSchema: {
1059
1117
  } & {
1060
1118
  readonly defaultExpr: string;
1061
1119
  };
1062
- }, Record<string, never>>;
1120
+ }, {
1121
+ terms: {
1122
+ readonly kind: "manyToMany";
1123
+ readonly target: "cms_terms";
1124
+ readonly through: string;
1125
+ readonly sourceColumn: string;
1126
+ readonly targetColumn: string;
1127
+ };
1128
+ }>;
1063
1129
  };
1130
+ /** How a media list may be ordered. */
1131
+ export type MediaSort = "newest" | "oldest" | "name" | "name_desc" | "largest" | "smallest";
1132
+ /** The coarse type buckets the library filters by — the first segment of a MIME type, which
1133
+ * is the distinction someone browsing a library actually makes ("show me the images"). */
1134
+ export declare const MEDIA_KINDS: readonly ["image", "video", "audio", "document", "other"];
1135
+ export type MediaKind = (typeof MEDIA_KINDS)[number];
1136
+ /** The object types a vocabulary can be applied to. A CLOSED vocabulary, like `MEDIA_KINDS`:
1137
+ * it decides which panels offer a taxonomy AND which assignments are accepted, so an unknown
1138
+ * value must not be storable. */
1139
+ export declare const TAXONOMY_TARGETS: readonly ["page", "media"];
1140
+ export type TaxonomyTarget = (typeof TAXONOMY_TARGETS)[number];
1141
+ /** Whether a vocabulary applies to `target`.
1142
+ *
1143
+ * The permissive readings all collapse to TRUE, deliberately: NULL (never narrowed, or written
1144
+ * before the column existed), a non-array, an array of things that are not targets. A stored
1145
+ * value nobody can interpret must not silently stop a vocabulary from working — the failure
1146
+ * would be a taxonomy that has quietly vanished from every panel, with the row still there. */
1147
+ export declare function taxonomyApplies(row: {
1148
+ appliesTo?: unknown;
1149
+ }, target: TaxonomyTarget): boolean;
1064
1150
  /** Block Kit — custom admin pages, described as JSON and rendered by the editor. See
1065
1151
  * `./blockkit`. Re-exported so a host imports `adminPage` beside `collection`. */
1066
1152
  export { adminPage, createAdminPageHandlers, normalizeAdminResponse, validateAdminPages, MAX_ADMIN_BLOCK_DEPTH, } from "./blockkit";
@@ -1478,28 +1564,48 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
1478
1564
  }, {
1479
1565
  ok: true;
1480
1566
  }>;
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>[]>;
1567
+ /** Every vocabulary, or just the ones that classify `target`. PUBLIC like content
1568
+ * types, a taxonomy's slug is structural (it is a URL segment) and a front end routes on
1569
+ * it.
1570
+ *
1571
+ * Narrowed HERE rather than in each caller, so the page panel, the media panel and the
1572
+ * write-side guard cannot disagree about what a vocabulary applies to. In memory, because
1573
+ * `appliesTo` is a `t.json()` column that `where` cannot see into — which costs nothing:
1574
+ * this handler already reads every taxonomy, and nothing pages by them.
1575
+ *
1576
+ * No `target` means EVERY vocabulary, which is what the Taxonomies screen needs: the one
1577
+ * place that edits `appliesTo` must be able to see a vocabulary it has narrowed away. */
1578
+ listTaxonomies: import("@pramen/server").Handler<{
1579
+ target?: TaxonomyTarget;
1580
+ }, Record<string, unknown>[]>;
1484
1581
  createTaxonomy: import("@pramen/server").Handler<{
1485
1582
  slug: string;
1486
1583
  label: string;
1487
1584
  pluralLabel?: string;
1488
1585
  description?: string;
1489
1586
  hierarchical?: boolean;
1587
+ appliesTo?: TaxonomyTarget[] | null;
1490
1588
  }, Record<string, unknown>>;
1491
1589
  /** Patch a vocabulary. `slug` is a URL segment and the key `listTerms` resolves, so it
1492
1590
  * is not mutable — the same rule content types and block types already follow.
1493
1591
  *
1494
1592
  * Turning `hierarchical` OFF is refused while any term still has a parent. Allowing it
1495
1593
  * 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. */
1594
+ * flattening the terms silently is a destructive edit behind a checkbox.
1595
+ *
1596
+ * NARROWING `appliesTo` is refused on exactly the same grounds, and it is the same bug:
1597
+ * dropping a target this vocabulary is already used for would strand those assignments —
1598
+ * still stored, still returned by `listPageTerms`/`listMediaTerms`, but invisible in the
1599
+ * panel that could remove them, because the panel only renders vocabularies that apply.
1600
+ * Unassign them first; then the narrowing is a settings change rather than a silent
1601
+ * orphaning. WIDENING is always fine — it strands nothing. */
1497
1602
  updateTaxonomy: import("@pramen/server").Handler<{
1498
1603
  id: string;
1499
1604
  label?: string;
1500
1605
  pluralLabel?: string | null;
1501
1606
  description?: string | null;
1502
1607
  hierarchical?: boolean;
1608
+ appliesTo?: TaxonomyTarget[] | null;
1503
1609
  }, Record<string, unknown> | undefined>;
1504
1610
  /** Delete a vocabulary. Its terms go with it, and their page assignments with those —
1505
1611
  * both by real `ON DELETE CASCADE`, so the cleanup is the DB's and cannot be half-done
@@ -1617,6 +1723,10 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
1617
1723
  listMedia: import("@pramen/server").Handler<{
1618
1724
  limit?: number;
1619
1725
  offset?: number;
1726
+ sort?: MediaSort;
1727
+ kind?: MediaKind;
1728
+ q?: string;
1729
+ term?: string;
1620
1730
  }, Record<string, unknown>[]>;
1621
1731
  getMedia: import("@pramen/server").Handler<{
1622
1732
  id: string;
@@ -1626,6 +1736,25 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
1626
1736
  id: string;
1627
1737
  alt: string | null;
1628
1738
  }, Record<string, unknown>>;
1739
+ /** A media asset's assigned terms.
1740
+ *
1741
+ * The media row is read FIRST, through `ctx.db`, so the caller's own scope decides
1742
+ * whether this answers — the junction and `cms_terms` are granted unscoped, so without
1743
+ * it anyone holding an id could read a TRASHED file's tags and the non-empty answer
1744
+ * would confirm the file exists. Same rule as `listPageTerms`, for the same reason. */
1745
+ listMediaTerms: import("@pramen/server").Handler<{
1746
+ mediaId: string;
1747
+ }, Term[]>;
1748
+ /** Replace a media asset's term assignments wholesale — set semantics, like
1749
+ * `setPageTerms`, and for the same reason: the panel holds the whole selection, and two
1750
+ * calls each patching one end of it race into a state neither asked for. */
1751
+ setMediaTerms: import("@pramen/server").Handler<{
1752
+ mediaId: string;
1753
+ termIds: string[];
1754
+ }, {
1755
+ ok: true;
1756
+ count: number;
1757
+ }>;
1629
1758
  /** Trash a media row. The R2 OBJECT IS KEPT — deleting the bytes here would make
1630
1759
  * `restoreMedia` a lie, and a block still referencing the id would render a dead url
1631
1760
  * with no way back. `purgeMedia` is what drops both — and `listTrash` is how you find
@@ -1787,6 +1916,7 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
1787
1916
  pagesByType: true;
1788
1917
  siteFurniture: true;
1789
1918
  codeDefinedTypes: true;
1919
+ mediaTerms: true;
1790
1920
  canEdit: boolean;
1791
1921
  }>;
1792
1922
  /** Distinct locales present across all pages. NOTE: a DATA query — what is in the
@@ -2077,28 +2207,48 @@ export declare const cmsHandlers: {
2077
2207
  }, {
2078
2208
  ok: true;
2079
2209
  }>;
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>[]>;
2210
+ /** Every vocabulary, or just the ones that classify `target`. PUBLIC like content
2211
+ * types, a taxonomy's slug is structural (it is a URL segment) and a front end routes on
2212
+ * it.
2213
+ *
2214
+ * Narrowed HERE rather than in each caller, so the page panel, the media panel and the
2215
+ * write-side guard cannot disagree about what a vocabulary applies to. In memory, because
2216
+ * `appliesTo` is a `t.json()` column that `where` cannot see into — which costs nothing:
2217
+ * this handler already reads every taxonomy, and nothing pages by them.
2218
+ *
2219
+ * No `target` means EVERY vocabulary, which is what the Taxonomies screen needs: the one
2220
+ * place that edits `appliesTo` must be able to see a vocabulary it has narrowed away. */
2221
+ listTaxonomies: import("@pramen/server").Handler<{
2222
+ target?: TaxonomyTarget;
2223
+ }, Record<string, unknown>[]>;
2083
2224
  createTaxonomy: import("@pramen/server").Handler<{
2084
2225
  slug: string;
2085
2226
  label: string;
2086
2227
  pluralLabel?: string;
2087
2228
  description?: string;
2088
2229
  hierarchical?: boolean;
2230
+ appliesTo?: TaxonomyTarget[] | null;
2089
2231
  }, Record<string, unknown>>;
2090
2232
  /** Patch a vocabulary. `slug` is a URL segment and the key `listTerms` resolves, so it
2091
2233
  * is not mutable — the same rule content types and block types already follow.
2092
2234
  *
2093
2235
  * Turning `hierarchical` OFF is refused while any term still has a parent. Allowing it
2094
2236
  * 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. */
2237
+ * flattening the terms silently is a destructive edit behind a checkbox.
2238
+ *
2239
+ * NARROWING `appliesTo` is refused on exactly the same grounds, and it is the same bug:
2240
+ * dropping a target this vocabulary is already used for would strand those assignments —
2241
+ * still stored, still returned by `listPageTerms`/`listMediaTerms`, but invisible in the
2242
+ * panel that could remove them, because the panel only renders vocabularies that apply.
2243
+ * Unassign them first; then the narrowing is a settings change rather than a silent
2244
+ * orphaning. WIDENING is always fine — it strands nothing. */
2096
2245
  updateTaxonomy: import("@pramen/server").Handler<{
2097
2246
  id: string;
2098
2247
  label?: string;
2099
2248
  pluralLabel?: string | null;
2100
2249
  description?: string | null;
2101
2250
  hierarchical?: boolean;
2251
+ appliesTo?: TaxonomyTarget[] | null;
2102
2252
  }, Record<string, unknown> | undefined>;
2103
2253
  /** Delete a vocabulary. Its terms go with it, and their page assignments with those —
2104
2254
  * both by real `ON DELETE CASCADE`, so the cleanup is the DB's and cannot be half-done
@@ -2216,6 +2366,10 @@ export declare const cmsHandlers: {
2216
2366
  listMedia: import("@pramen/server").Handler<{
2217
2367
  limit?: number;
2218
2368
  offset?: number;
2369
+ sort?: MediaSort;
2370
+ kind?: MediaKind;
2371
+ q?: string;
2372
+ term?: string;
2219
2373
  }, Record<string, unknown>[]>;
2220
2374
  getMedia: import("@pramen/server").Handler<{
2221
2375
  id: string;
@@ -2225,6 +2379,25 @@ export declare const cmsHandlers: {
2225
2379
  id: string;
2226
2380
  alt: string | null;
2227
2381
  }, Record<string, unknown>>;
2382
+ /** A media asset's assigned terms.
2383
+ *
2384
+ * The media row is read FIRST, through `ctx.db`, so the caller's own scope decides
2385
+ * whether this answers — the junction and `cms_terms` are granted unscoped, so without
2386
+ * it anyone holding an id could read a TRASHED file's tags and the non-empty answer
2387
+ * would confirm the file exists. Same rule as `listPageTerms`, for the same reason. */
2388
+ listMediaTerms: import("@pramen/server").Handler<{
2389
+ mediaId: string;
2390
+ }, Term[]>;
2391
+ /** Replace a media asset's term assignments wholesale — set semantics, like
2392
+ * `setPageTerms`, and for the same reason: the panel holds the whole selection, and two
2393
+ * calls each patching one end of it race into a state neither asked for. */
2394
+ setMediaTerms: import("@pramen/server").Handler<{
2395
+ mediaId: string;
2396
+ termIds: string[];
2397
+ }, {
2398
+ ok: true;
2399
+ count: number;
2400
+ }>;
2228
2401
  /** Trash a media row. The R2 OBJECT IS KEPT — deleting the bytes here would make
2229
2402
  * `restoreMedia` a lie, and a block still referencing the id would render a dead url
2230
2403
  * with no way back. `purgeMedia` is what drops both — and `listTrash` is how you find
@@ -2386,6 +2559,7 @@ export declare const cmsHandlers: {
2386
2559
  pagesByType: true;
2387
2560
  siteFurniture: true;
2388
2561
  codeDefinedTypes: true;
2562
+ mediaTerms: true;
2389
2563
  canEdit: boolean;
2390
2564
  }>;
2391
2565
  /** Distinct locales present across all pages. NOTE: a DATA query — what is in the
@@ -2916,6 +3090,18 @@ export declare function createCollectionTasks(collections: readonly CollectionDe
2916
3090
  * between the revision insert and the page update leaves the page unpublished with an orphan
2917
3091
  * revision until the next at-least-once redelivery re-runs (the token still matches, so it
2918
3092
  * completes). Acceptable for a scheduled job; the interactive path is atomic. */
3093
+ /**
3094
+ * The CMS's own data migrations — spread into `app.migrations`.
3095
+ *
3096
+ * migrations: [...cmsMigrations]
3097
+ *
3098
+ * Opt-in like every other fragment this package ships (`cmsHandlers`, `cmsPolicies`,
3099
+ * `cmsTasks`), and with the same consequence for forgetting it: nothing breaks loudly. Media
3100
+ * rows written before the projection columns existed keep NULL `filename`/`contentType`, so
3101
+ * they sort together under a name sort and answer only the `other` type filter. New uploads
3102
+ * are unaffected — `createMedia` writes the columns itself.
3103
+ */
3104
+ export declare const cmsMigrations: readonly DataMigration[];
2919
3105
  export declare const cmsTasks: {
2920
3106
  "cms:publish": (ctx: HandlerContext, payload: unknown) => Promise<void>;
2921
3107
  "cms:unpublish": (ctx: HandlerContext, payload: unknown) => Promise<void>;