@pramen/cms 0.0.55 → 0.0.57
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 +100 -16
- package/dist/index.js +132 -26
- package/package.json +2 -2
- package/src/index.ts +125 -26
package/dist/index.d.ts
CHANGED
|
@@ -327,6 +327,8 @@ export declare const cmsSchema: {
|
|
|
327
327
|
readonly type: "uuid";
|
|
328
328
|
} & {
|
|
329
329
|
readonly notNull: true;
|
|
330
|
+
} & {
|
|
331
|
+
readonly index: true;
|
|
330
332
|
};
|
|
331
333
|
title: {
|
|
332
334
|
readonly type: "text";
|
|
@@ -785,6 +787,12 @@ export declare const PREVIEW_PATH = "/cms/preview";
|
|
|
785
787
|
/** Default preview-link lifetime: 1 hour. Long enough to share and open, short enough that
|
|
786
788
|
* a link pasted into a public channel stops working the same afternoon. */
|
|
787
789
|
export declare const DEFAULT_PREVIEW_TTL_SECONDS = 3600;
|
|
790
|
+
/** `listPages` page size when the caller names none — the historical cap, kept so a client
|
|
791
|
+
* that never learned to paginate sees exactly what it always did. */
|
|
792
|
+
export declare const PAGE_LIST_LIMIT = 100;
|
|
793
|
+
/** …and the ceiling on what a caller may ask for. A list screen pages; nobody needs the
|
|
794
|
+
* whole table in one full-row response. */
|
|
795
|
+
export declare const PAGE_LIST_MAX_LIMIT = 500;
|
|
788
796
|
export interface CmsHandlerOpts {
|
|
789
797
|
/** Roles permitted to call the editor mutations (also enforced by the ACL). Default
|
|
790
798
|
* `["editor", "admin"]`. */
|
|
@@ -925,18 +933,49 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
|
|
|
925
933
|
getContentType: import("@pramen/server").Handler<{
|
|
926
934
|
id: string;
|
|
927
935
|
}, Record<string, unknown>>;
|
|
928
|
-
/** List pages, newest first.
|
|
929
|
-
*
|
|
930
|
-
*
|
|
931
|
-
*
|
|
932
|
-
*
|
|
933
|
-
*
|
|
936
|
+
/** List pages, newest first. Viewer-gated: the rows are FULL page records (schedule
|
|
937
|
+
* timestamps, revision pointer, the whole `fields` bag, every SEO column), which is the
|
|
938
|
+
* editing surface, not the published one — `listPublishedPages` is this file's deliberate
|
|
939
|
+
* public projection and stays narrow.
|
|
940
|
+
*
|
|
941
|
+
* `contentType` (a content-type SLUG) narrows the list to one type — what an editor that
|
|
942
|
+
* gives each type its own tab needs, and the only way to stay correct once a deployment
|
|
943
|
+
* has more than `limit` entries in total: filtering the full list client-side would
|
|
944
|
+
* silently drop the tail of every type. Omitted ⇒ all types, the historical behaviour. An
|
|
945
|
+
* unknown slug returns nothing rather than everything, so a typo can never read as "here
|
|
946
|
+
* is the whole CMS" — and neither can an EMPTY one, which is why the check below is
|
|
947
|
+
* `=== undefined` and not a falsy test.
|
|
948
|
+
*
|
|
949
|
+
* `limit`/`offset` page the list. Without them the caller cannot tell a full first page
|
|
950
|
+
* from the whole table, and the editor's header reports the cap as if it were the total.
|
|
951
|
+
* `select` narrows the projection: a list screen needs five columns, not the widest row
|
|
952
|
+
* in the CMS, and on the D1 store every unasked-for column crosses RPC. */
|
|
934
953
|
listPages: import("@pramen/server").Handler<{
|
|
935
954
|
contentType?: string;
|
|
955
|
+
limit?: number;
|
|
956
|
+
offset?: number;
|
|
957
|
+
select?: string[];
|
|
936
958
|
}, Record<string, unknown>[]>;
|
|
959
|
+
/** One page by id, for the editor opening `/pages/:id` directly. Resolving that id
|
|
960
|
+
* against `listPages` instead means a deep link (or a row clicked in a type's own tab)
|
|
961
|
+
* can miss: that list is capped and, since the editor lists per type, is not even the
|
|
962
|
+
* list the row came from. Viewer-gated + row-ACL'd like every other read. */
|
|
963
|
+
getPageById: import("@pramen/server").Handler<{
|
|
964
|
+
pageId: string;
|
|
965
|
+
}, Record<string, unknown>>;
|
|
937
966
|
/** Public: list published pages (slug, locale, updatedAt) for sitemap generation. The
|
|
938
|
-
* anonymous ACL scopes cms_pages reads to status=published, so this is safe to expose.
|
|
939
|
-
|
|
967
|
+
* anonymous ACL scopes cms_pages reads to status=published, so this is safe to expose.
|
|
968
|
+
*
|
|
969
|
+
* `contentType` / `locale` narrow the list HERE, for the same reason `listPages` does:
|
|
970
|
+
* the result is capped, so a caller filtering it afterwards is filtering an already
|
|
971
|
+
* truncated list and loses the tail of every type. `@pramen/cms-astro`'s `collections:
|
|
972
|
+
* "auto"` builds one collection per content type, each calling this — un-narrowed, all
|
|
973
|
+
* of them fetch the same 5000 rows and everything past the cap vanishes from the built
|
|
974
|
+
* site with a green build. An unknown slug returns nothing, never everything. */
|
|
975
|
+
listPublishedPages: import("@pramen/server").Handler<{
|
|
976
|
+
contentType?: string;
|
|
977
|
+
locale?: string;
|
|
978
|
+
}, {
|
|
940
979
|
slug: string;
|
|
941
980
|
locale: string;
|
|
942
981
|
contentType: string | null;
|
|
@@ -1000,10 +1039,17 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
|
|
|
1000
1039
|
* a client flag can hide a control but cannot make the data right, and the two drift
|
|
1001
1040
|
* the moment someone adds a locale. `multilingual` is the derived answer to the only
|
|
1002
1041
|
* question the UI actually asks, so each surface doesn't re-derive it from the list. */
|
|
1042
|
+
/** What this deployment supports. `pagesByType` is the editor's licence to give each
|
|
1043
|
+
* content type its own tab and its own list: an OLDER server ignores the `contentType`
|
|
1044
|
+
* argument entirely and answers with the pooled list, so an editor that assumed the
|
|
1045
|
+
* feature would render N tabs all showing every type's pages under a heading claiming
|
|
1046
|
+
* otherwise — and "New page" from any of them would stamp that tab's type. Declared, not
|
|
1047
|
+
* inferred: fail closed on the pooled list rather than open on N lying ones. */
|
|
1003
1048
|
listCmsCapabilities: import("@pramen/server").Handler<unknown, {
|
|
1004
1049
|
locales: string[];
|
|
1005
1050
|
defaultLocale: string;
|
|
1006
1051
|
multilingual: boolean;
|
|
1052
|
+
pagesByType: true;
|
|
1007
1053
|
}>;
|
|
1008
1054
|
/** Distinct locales present across all pages. NOTE: a DATA query — what is in the
|
|
1009
1055
|
* store — not configuration. `listCmsCapabilities().locales` is what the deployment
|
|
@@ -1301,18 +1347,49 @@ export declare const cmsHandlers: {
|
|
|
1301
1347
|
getContentType: import("@pramen/server").Handler<{
|
|
1302
1348
|
id: string;
|
|
1303
1349
|
}, Record<string, unknown>>;
|
|
1304
|
-
/** List pages, newest first.
|
|
1305
|
-
*
|
|
1306
|
-
*
|
|
1307
|
-
*
|
|
1308
|
-
*
|
|
1309
|
-
*
|
|
1350
|
+
/** List pages, newest first. Viewer-gated: the rows are FULL page records (schedule
|
|
1351
|
+
* timestamps, revision pointer, the whole `fields` bag, every SEO column), which is the
|
|
1352
|
+
* editing surface, not the published one — `listPublishedPages` is this file's deliberate
|
|
1353
|
+
* public projection and stays narrow.
|
|
1354
|
+
*
|
|
1355
|
+
* `contentType` (a content-type SLUG) narrows the list to one type — what an editor that
|
|
1356
|
+
* gives each type its own tab needs, and the only way to stay correct once a deployment
|
|
1357
|
+
* has more than `limit` entries in total: filtering the full list client-side would
|
|
1358
|
+
* silently drop the tail of every type. Omitted ⇒ all types, the historical behaviour. An
|
|
1359
|
+
* unknown slug returns nothing rather than everything, so a typo can never read as "here
|
|
1360
|
+
* is the whole CMS" — and neither can an EMPTY one, which is why the check below is
|
|
1361
|
+
* `=== undefined` and not a falsy test.
|
|
1362
|
+
*
|
|
1363
|
+
* `limit`/`offset` page the list. Without them the caller cannot tell a full first page
|
|
1364
|
+
* from the whole table, and the editor's header reports the cap as if it were the total.
|
|
1365
|
+
* `select` narrows the projection: a list screen needs five columns, not the widest row
|
|
1366
|
+
* in the CMS, and on the D1 store every unasked-for column crosses RPC. */
|
|
1310
1367
|
listPages: import("@pramen/server").Handler<{
|
|
1311
1368
|
contentType?: string;
|
|
1369
|
+
limit?: number;
|
|
1370
|
+
offset?: number;
|
|
1371
|
+
select?: string[];
|
|
1312
1372
|
}, Record<string, unknown>[]>;
|
|
1373
|
+
/** One page by id, for the editor opening `/pages/:id` directly. Resolving that id
|
|
1374
|
+
* against `listPages` instead means a deep link (or a row clicked in a type's own tab)
|
|
1375
|
+
* can miss: that list is capped and, since the editor lists per type, is not even the
|
|
1376
|
+
* list the row came from. Viewer-gated + row-ACL'd like every other read. */
|
|
1377
|
+
getPageById: import("@pramen/server").Handler<{
|
|
1378
|
+
pageId: string;
|
|
1379
|
+
}, Record<string, unknown>>;
|
|
1313
1380
|
/** Public: list published pages (slug, locale, updatedAt) for sitemap generation. The
|
|
1314
|
-
* anonymous ACL scopes cms_pages reads to status=published, so this is safe to expose.
|
|
1315
|
-
|
|
1381
|
+
* anonymous ACL scopes cms_pages reads to status=published, so this is safe to expose.
|
|
1382
|
+
*
|
|
1383
|
+
* `contentType` / `locale` narrow the list HERE, for the same reason `listPages` does:
|
|
1384
|
+
* the result is capped, so a caller filtering it afterwards is filtering an already
|
|
1385
|
+
* truncated list and loses the tail of every type. `@pramen/cms-astro`'s `collections:
|
|
1386
|
+
* "auto"` builds one collection per content type, each calling this — un-narrowed, all
|
|
1387
|
+
* of them fetch the same 5000 rows and everything past the cap vanishes from the built
|
|
1388
|
+
* site with a green build. An unknown slug returns nothing, never everything. */
|
|
1389
|
+
listPublishedPages: import("@pramen/server").Handler<{
|
|
1390
|
+
contentType?: string;
|
|
1391
|
+
locale?: string;
|
|
1392
|
+
}, {
|
|
1316
1393
|
slug: string;
|
|
1317
1394
|
locale: string;
|
|
1318
1395
|
contentType: string | null;
|
|
@@ -1376,10 +1453,17 @@ export declare const cmsHandlers: {
|
|
|
1376
1453
|
* a client flag can hide a control but cannot make the data right, and the two drift
|
|
1377
1454
|
* the moment someone adds a locale. `multilingual` is the derived answer to the only
|
|
1378
1455
|
* question the UI actually asks, so each surface doesn't re-derive it from the list. */
|
|
1456
|
+
/** What this deployment supports. `pagesByType` is the editor's licence to give each
|
|
1457
|
+
* content type its own tab and its own list: an OLDER server ignores the `contentType`
|
|
1458
|
+
* argument entirely and answers with the pooled list, so an editor that assumed the
|
|
1459
|
+
* feature would render N tabs all showing every type's pages under a heading claiming
|
|
1460
|
+
* otherwise — and "New page" from any of them would stamp that tab's type. Declared, not
|
|
1461
|
+
* inferred: fail closed on the pooled list rather than open on N lying ones. */
|
|
1379
1462
|
listCmsCapabilities: import("@pramen/server").Handler<unknown, {
|
|
1380
1463
|
locales: string[];
|
|
1381
1464
|
defaultLocale: string;
|
|
1382
1465
|
multilingual: boolean;
|
|
1466
|
+
pagesByType: true;
|
|
1383
1467
|
}>;
|
|
1384
1468
|
/** Distinct locales present across all pages. NOTE: a DATA query — what is in the
|
|
1385
1469
|
* store — not configuration. `listCmsCapabilities().locales` is what the deployment
|
package/dist/index.js
CHANGED
|
@@ -237,7 +237,13 @@ export const cmsSchema = {
|
|
|
237
237
|
}), (r) => ({ type: r.belongsTo("cms_block_types", "typeId") })),
|
|
238
238
|
cms_pages: Entity((t) => ({
|
|
239
239
|
id: primaryKey(generated(t.uuid())),
|
|
240
|
-
|
|
240
|
+
// Indexed because the editor lists pages ONE TYPE AT A TIME (`listPages({ contentType })`,
|
|
241
|
+
// a tab per type): without it every tab load scans all of cms_pages before sorting, and
|
|
242
|
+
// on the D1 store that scan is paid over RPC. Relation columns are never auto-indexed
|
|
243
|
+
// (index DDL comes only from `unique()`/`indexed()` and composite uniques), and the
|
|
244
|
+
// `["slug","locale"]` composite is leftmost-`slug` so it cannot serve this predicate.
|
|
245
|
+
// The `createdAt` sort of the narrowed set remains — single-column indexes only.
|
|
246
|
+
typeId: indexed(notNull(t.uuid())),
|
|
241
247
|
title: notNull(t.text()),
|
|
242
248
|
// A slug is unique PER LOCALE (`/en/about` + `/cs/about`) — enforced by the entity's
|
|
243
249
|
// composite `unique: [["slug","locale"]]` (below). createPage/updatePage/createTranslation
|
|
@@ -953,6 +959,12 @@ export const PREVIEW_PATH = "/cms/preview";
|
|
|
953
959
|
/** Default preview-link lifetime: 1 hour. Long enough to share and open, short enough that
|
|
954
960
|
* a link pasted into a public channel stops working the same afternoon. */
|
|
955
961
|
export const DEFAULT_PREVIEW_TTL_SECONDS = 3600;
|
|
962
|
+
/** `listPages` page size when the caller names none — the historical cap, kept so a client
|
|
963
|
+
* that never learned to paginate sees exactly what it always did. */
|
|
964
|
+
export const PAGE_LIST_LIMIT = 100;
|
|
965
|
+
/** …and the ceiling on what a caller may ask for. A list screen pages; nobody needs the
|
|
966
|
+
* whole table in one full-row response. */
|
|
967
|
+
export const PAGE_LIST_MAX_LIMIT = 500;
|
|
956
968
|
/** Build the CMS handler map. Spread into your app's handlers. Editor mutations are
|
|
957
969
|
* gated both by `auth` (fast 403 before the body) and by the row ACL (cmsPolicies). */
|
|
958
970
|
export function createCmsHandlers(opts = {}) {
|
|
@@ -1039,16 +1051,24 @@ export function createCmsHandlers(opts = {}) {
|
|
|
1039
1051
|
},
|
|
1040
1052
|
};
|
|
1041
1053
|
// (slug, locale) uniqueness is enforced here because pramen's unique() is single-column.
|
|
1054
|
+
//
|
|
1055
|
+
// Slugs are global across content types — the constraint is (slug, locale), NOT
|
|
1056
|
+
// (slug, locale, typeId) — so the colliding page is very often one the caller cannot see:
|
|
1057
|
+
// the editor lists ONE type per tab, and "already exists" naming only slug + locale leaves
|
|
1058
|
+
// them staring at a list that visibly contains no such row. Both messages name the owning
|
|
1059
|
+
// type, the way the trash variant already named the trash.
|
|
1042
1060
|
const assertSlugFree = async (db, slug, locale, exceptId) => {
|
|
1043
|
-
const rows = await db.exec("SELECT id, deletedAt FROM cms_pages WHERE slug = ? AND locale = ? LIMIT 1", slug, locale);
|
|
1061
|
+
const rows = await db.exec("SELECT id, deletedAt, typeId FROM cms_pages WHERE slug = ? AND locale = ? LIMIT 1", slug, locale);
|
|
1044
1062
|
if (rows[0] && String(rows[0].id) !== exceptId) {
|
|
1063
|
+
const typeSlug = await contentTypeSlug(db, rows[0].typeId).catch(() => null);
|
|
1064
|
+
const under = typeSlug ? ` under content type '${typeSlug}'` : "";
|
|
1045
1065
|
// A trashed page keeps its slug until purged (the (slug, locale) unique index is a
|
|
1046
1066
|
// DB constraint, not advisory). Say so, rather than leave the caller hunting for a
|
|
1047
1067
|
// page they cannot see.
|
|
1048
1068
|
if (rows[0].deletedAt != null) {
|
|
1049
|
-
throw new BadRequest(`slug '${slug}' is held by a page in the trash for locale '${locale}' — restore or purge it first`);
|
|
1069
|
+
throw new BadRequest(`slug '${slug}' is held by a page in the trash for locale '${locale}'${under} — restore or purge it first`);
|
|
1050
1070
|
}
|
|
1051
|
-
throw new BadRequest(`slug '${slug}' already exists for locale '${locale}'`);
|
|
1071
|
+
throw new BadRequest(`slug '${slug}' already exists for locale '${locale}'${under} — slugs are unique across all content types`);
|
|
1052
1072
|
}
|
|
1053
1073
|
};
|
|
1054
1074
|
const TASK_PUBLISH = "cms:publish";
|
|
@@ -1088,6 +1108,12 @@ export function createCmsHandlers(opts = {}) {
|
|
|
1088
1108
|
const o = asObj(raw);
|
|
1089
1109
|
if (typeof o.name !== "string" || typeof o.slug !== "string")
|
|
1090
1110
|
throw new BadRequest("name and slug are required");
|
|
1111
|
+
// Non-EMPTY, not merely a string: a content type's slug is a URL segment in the
|
|
1112
|
+
// editor (`/types/:slug`) and the key `listPages({ contentType })` resolves. An
|
|
1113
|
+
// empty one builds `/types/` — a path the router drops the empty segment from, so
|
|
1114
|
+
// the type gets a tab that cannot be reached and a list that cannot be addressed.
|
|
1115
|
+
if (o.name.trim() === "" || o.slug.trim() === "")
|
|
1116
|
+
throw new BadRequest("name and slug must not be empty");
|
|
1091
1117
|
if (!Array.isArray(o.regions) || o.regions.length === 0)
|
|
1092
1118
|
throw new BadRequest("at least one region is required");
|
|
1093
1119
|
return o;
|
|
@@ -1139,6 +1165,9 @@ export function createCmsHandlers(opts = {}) {
|
|
|
1139
1165
|
const o = asObj(raw);
|
|
1140
1166
|
if (typeof o.id !== "string" && typeof o.slug !== "string")
|
|
1141
1167
|
throw new BadRequest("id or slug is required");
|
|
1168
|
+
// `name` is the editor's tab label; blanking it leaves an unlabelled tab.
|
|
1169
|
+
if (typeof o.name === "string" && o.name.trim() === "")
|
|
1170
|
+
throw new BadRequest("name must not be empty");
|
|
1142
1171
|
return o;
|
|
1143
1172
|
},
|
|
1144
1173
|
}),
|
|
@@ -1300,43 +1329,114 @@ export function createCmsHandlers(opts = {}) {
|
|
|
1300
1329
|
},
|
|
1301
1330
|
}),
|
|
1302
1331
|
// ---- pages ----
|
|
1303
|
-
/** List pages, newest first.
|
|
1304
|
-
*
|
|
1305
|
-
*
|
|
1306
|
-
*
|
|
1307
|
-
*
|
|
1308
|
-
*
|
|
1332
|
+
/** List pages, newest first. Viewer-gated: the rows are FULL page records (schedule
|
|
1333
|
+
* timestamps, revision pointer, the whole `fields` bag, every SEO column), which is the
|
|
1334
|
+
* editing surface, not the published one — `listPublishedPages` is this file's deliberate
|
|
1335
|
+
* public projection and stays narrow.
|
|
1336
|
+
*
|
|
1337
|
+
* `contentType` (a content-type SLUG) narrows the list to one type — what an editor that
|
|
1338
|
+
* gives each type its own tab needs, and the only way to stay correct once a deployment
|
|
1339
|
+
* has more than `limit` entries in total: filtering the full list client-side would
|
|
1340
|
+
* silently drop the tail of every type. Omitted ⇒ all types, the historical behaviour. An
|
|
1341
|
+
* unknown slug returns nothing rather than everything, so a typo can never read as "here
|
|
1342
|
+
* is the whole CMS" — and neither can an EMPTY one, which is why the check below is
|
|
1343
|
+
* `=== undefined` and not a falsy test.
|
|
1344
|
+
*
|
|
1345
|
+
* `limit`/`offset` page the list. Without them the caller cannot tell a full first page
|
|
1346
|
+
* from the whole table, and the editor's header reports the cap as if it were the total.
|
|
1347
|
+
* `select` narrows the projection: a list screen needs five columns, not the widest row
|
|
1348
|
+
* in the CMS, and on the D1 store every unasked-for column crosses RPC. */
|
|
1309
1349
|
listPages: query(async (ctx, input) => {
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
const
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1350
|
+
// ONE query, not two: `where` traverses the `type` belongsTo the schema already
|
|
1351
|
+
// declares, so the slug is resolved by a subquery. That also gives the unknown-slug
|
|
1352
|
+
// invariant for free (an empty subquery matches nothing) and, unlike a hand-rolled
|
|
1353
|
+
// lookup in cms_content_types, does not THROW for a policy set that grants cms_pages
|
|
1354
|
+
// but not the types table.
|
|
1355
|
+
const where = input?.contentType === undefined ? undefined : { type: { slug: input.contentType } };
|
|
1356
|
+
return cdb(ctx).find({
|
|
1357
|
+
from: "cms_pages",
|
|
1358
|
+
where,
|
|
1359
|
+
orderBy: { column: "createdAt", dir: "desc" },
|
|
1360
|
+
limit: Math.min(input?.limit ?? PAGE_LIST_LIMIT, PAGE_LIST_MAX_LIMIT),
|
|
1361
|
+
offset: input?.offset ?? 0,
|
|
1362
|
+
select: input?.select,
|
|
1363
|
+
});
|
|
1319
1364
|
}, {
|
|
1365
|
+
...viewer,
|
|
1366
|
+
// The parsed value is what the ACL sees (`dispatch` hands it to `Db` for `$input()`
|
|
1367
|
+
// resolution), so this SPREADS the raw object rather than rebuilding one from the
|
|
1368
|
+
// keys it knows: a host scoping cms_pages with `$input("someKey")` had its marker
|
|
1369
|
+
// resolving against `{}` — the scope collapsing to nothing — the moment this handler
|
|
1370
|
+
// grew a parser.
|
|
1320
1371
|
input: (raw) => {
|
|
1321
1372
|
const o = asObj(raw);
|
|
1373
|
+
const out = { ...o };
|
|
1322
1374
|
if (o.contentType === undefined || o.contentType === null)
|
|
1323
|
-
|
|
1324
|
-
if (typeof o.contentType !== "string")
|
|
1375
|
+
delete out.contentType;
|
|
1376
|
+
else if (typeof o.contentType !== "string")
|
|
1325
1377
|
throw new BadRequest("contentType must be a string");
|
|
1326
|
-
|
|
1378
|
+
for (const k of ["limit", "offset"]) {
|
|
1379
|
+
if (o[k] === undefined || o[k] === null) {
|
|
1380
|
+
delete out[k];
|
|
1381
|
+
continue;
|
|
1382
|
+
}
|
|
1383
|
+
if (typeof o[k] !== "number" || !Number.isInteger(o[k]) || o[k] < 0)
|
|
1384
|
+
throw new BadRequest(`${k} must be a non-negative integer`);
|
|
1385
|
+
}
|
|
1386
|
+
if (o.select === undefined || o.select === null)
|
|
1387
|
+
delete out.select;
|
|
1388
|
+
else if (!Array.isArray(o.select) || o.select.some((c) => typeof c !== "string"))
|
|
1389
|
+
throw new BadRequest("select must be an array of column names");
|
|
1390
|
+
return out;
|
|
1327
1391
|
},
|
|
1328
1392
|
}),
|
|
1393
|
+
/** One page by id, for the editor opening `/pages/:id` directly. Resolving that id
|
|
1394
|
+
* against `listPages` instead means a deep link (or a row clicked in a type's own tab)
|
|
1395
|
+
* can miss: that list is capped and, since the editor lists per type, is not even the
|
|
1396
|
+
* list the row came from. Viewer-gated + row-ACL'd like every other read. */
|
|
1397
|
+
getPageById: query(async (ctx, input) => {
|
|
1398
|
+
const rows = await cdb(ctx).find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
|
|
1399
|
+
return rows[0] ?? null;
|
|
1400
|
+
}, { ...viewer, ...pageIdInput }),
|
|
1329
1401
|
/** Public: list published pages (slug, locale, updatedAt) for sitemap generation. The
|
|
1330
|
-
* anonymous ACL scopes cms_pages reads to status=published, so this is safe to expose.
|
|
1331
|
-
|
|
1402
|
+
* anonymous ACL scopes cms_pages reads to status=published, so this is safe to expose.
|
|
1403
|
+
*
|
|
1404
|
+
* `contentType` / `locale` narrow the list HERE, for the same reason `listPages` does:
|
|
1405
|
+
* the result is capped, so a caller filtering it afterwards is filtering an already
|
|
1406
|
+
* truncated list and loses the tail of every type. `@pramen/cms-astro`'s `collections:
|
|
1407
|
+
* "auto"` builds one collection per content type, each calling this — un-narrowed, all
|
|
1408
|
+
* of them fetch the same 5000 rows and everything past the cap vanishes from the built
|
|
1409
|
+
* site with a green build. An unknown slug returns nothing, never everything. */
|
|
1410
|
+
listPublishedPages: query(async (ctx, input) => {
|
|
1332
1411
|
const db = cdb(ctx);
|
|
1333
|
-
const
|
|
1412
|
+
const where = { status: "published" };
|
|
1413
|
+
if (input?.contentType !== undefined)
|
|
1414
|
+
where.type = { slug: input.contentType };
|
|
1415
|
+
if (input?.locale !== undefined)
|
|
1416
|
+
where.locale = input.locale;
|
|
1417
|
+
const rows = await db.find({ from: "cms_pages", where, orderBy: { column: "updatedAt", dir: "desc" }, limit: 5000 });
|
|
1334
1418
|
// Join typeId → content-type slug so a frontend can route/filter by type (e.g. articles
|
|
1335
1419
|
// vs pages) without a second round-trip.
|
|
1336
1420
|
const typeIds = [...new Set(rows.map((r) => r.typeId).filter((v) => typeof v === "string"))];
|
|
1337
1421
|
const types = typeIds.length ? await db.find({ from: "cms_content_types", where: { id: { in: typeIds } } }) : [];
|
|
1338
1422
|
const slugById = new Map(types.map((t) => [String(t.id), String(t.slug)]));
|
|
1339
1423
|
return rows.map((r) => ({ slug: String(r.slug), locale: String(r.locale ?? "en"), contentType: slugById.get(String(r.typeId)) ?? null, updatedAt: String(r.updatedAt ?? r.createdAt ?? "") }));
|
|
1424
|
+
}, {
|
|
1425
|
+
// Spreads the raw object for the same reason `listPages` does — the parsed value is
|
|
1426
|
+
// what `$input()` policy markers resolve against.
|
|
1427
|
+
input: (raw) => {
|
|
1428
|
+
const o = asObj(raw);
|
|
1429
|
+
const out = { ...o };
|
|
1430
|
+
for (const k of ["contentType", "locale"]) {
|
|
1431
|
+
if (o[k] === undefined || o[k] === null) {
|
|
1432
|
+
delete out[k];
|
|
1433
|
+
continue;
|
|
1434
|
+
}
|
|
1435
|
+
if (typeof o[k] !== "string")
|
|
1436
|
+
throw new BadRequest(`${k} must be a string`);
|
|
1437
|
+
}
|
|
1438
|
+
return out;
|
|
1439
|
+
},
|
|
1340
1440
|
}),
|
|
1341
1441
|
/** Update a page's SEO fields (meta/canonical/robots/OpenGraph/JSON-LD). Editor-gated. */
|
|
1342
1442
|
updatePageSeo: mutation(async (ctx, input) => {
|
|
@@ -1546,7 +1646,13 @@ export function createCmsHandlers(opts = {}) {
|
|
|
1546
1646
|
* a client flag can hide a control but cannot make the data right, and the two drift
|
|
1547
1647
|
* the moment someone adds a locale. `multilingual` is the derived answer to the only
|
|
1548
1648
|
* question the UI actually asks, so each surface doesn't re-derive it from the list. */
|
|
1549
|
-
|
|
1649
|
+
/** What this deployment supports. `pagesByType` is the editor's licence to give each
|
|
1650
|
+
* content type its own tab and its own list: an OLDER server ignores the `contentType`
|
|
1651
|
+
* argument entirely and answers with the pooled list, so an editor that assumed the
|
|
1652
|
+
* feature would render N tabs all showing every type's pages under a heading claiming
|
|
1653
|
+
* otherwise — and "New page" from any of them would stamp that tab's type. Declared, not
|
|
1654
|
+
* inferred: fail closed on the pooled list rather than open on N lying ones. */
|
|
1655
|
+
listCmsCapabilities: query(() => ({ locales, defaultLocale, multilingual: locales.length > 1, pagesByType: true }), viewer),
|
|
1550
1656
|
/** Distinct locales present across all pages. NOTE: a DATA query — what is in the
|
|
1551
1657
|
* store — not configuration. `listCmsCapabilities().locales` is what the deployment
|
|
1552
1658
|
* declares; these two differ while a locale is declared but not yet authored. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/cms",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.57",
|
|
4
4
|
"description": "Optional block/page builder for pramen — Drupal-Paragraphs-style typed blocks in named regions, reusable blocks, scheduled publishing, built entirely from pramen primitives.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"access": "public"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@pramen/server": "0.0.
|
|
44
|
+
"@pramen/server": "0.0.57"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
47
|
"react": ">=18"
|
package/src/index.ts
CHANGED
|
@@ -484,7 +484,13 @@ export const cmsSchema = {
|
|
|
484
484
|
cms_pages: Entity(
|
|
485
485
|
(t) => ({
|
|
486
486
|
id: primaryKey(generated(t.uuid())),
|
|
487
|
-
|
|
487
|
+
// Indexed because the editor lists pages ONE TYPE AT A TIME (`listPages({ contentType })`,
|
|
488
|
+
// a tab per type): without it every tab load scans all of cms_pages before sorting, and
|
|
489
|
+
// on the D1 store that scan is paid over RPC. Relation columns are never auto-indexed
|
|
490
|
+
// (index DDL comes only from `unique()`/`indexed()` and composite uniques), and the
|
|
491
|
+
// `["slug","locale"]` composite is leftmost-`slug` so it cannot serve this predicate.
|
|
492
|
+
// The `createdAt` sort of the narrowed set remains — single-column indexes only.
|
|
493
|
+
typeId: indexed(notNull(t.uuid())),
|
|
488
494
|
title: notNull(t.text()),
|
|
489
495
|
// A slug is unique PER LOCALE (`/en/about` + `/cs/about`) — enforced by the entity's
|
|
490
496
|
// composite `unique: [["slug","locale"]]` (below). createPage/updatePage/createTranslation
|
|
@@ -1136,6 +1142,9 @@ interface CmsDb {
|
|
|
1136
1142
|
limit?: number;
|
|
1137
1143
|
offset?: number;
|
|
1138
1144
|
with?: Record<string, unknown>;
|
|
1145
|
+
/** Column projection. Each name is ACL-checked by `Db` (an unreadable or `hidden()`
|
|
1146
|
+
* column is a 403), so it is safe to build one from caller input. */
|
|
1147
|
+
select?: readonly string[];
|
|
1139
1148
|
}): Promise<Array<Record<string, unknown>>>;
|
|
1140
1149
|
insert(table: string, values: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
1141
1150
|
update(table: string, id: string, patch: Record<string, unknown>): Promise<Record<string, unknown> | undefined>;
|
|
@@ -1373,6 +1382,13 @@ export const PREVIEW_PATH = "/cms/preview";
|
|
|
1373
1382
|
* a link pasted into a public channel stops working the same afternoon. */
|
|
1374
1383
|
export const DEFAULT_PREVIEW_TTL_SECONDS = 3600;
|
|
1375
1384
|
|
|
1385
|
+
/** `listPages` page size when the caller names none — the historical cap, kept so a client
|
|
1386
|
+
* that never learned to paginate sees exactly what it always did. */
|
|
1387
|
+
export const PAGE_LIST_LIMIT = 100;
|
|
1388
|
+
/** …and the ceiling on what a caller may ask for. A list screen pages; nobody needs the
|
|
1389
|
+
* whole table in one full-row response. */
|
|
1390
|
+
export const PAGE_LIST_MAX_LIMIT = 500;
|
|
1391
|
+
|
|
1376
1392
|
export interface CmsHandlerOpts {
|
|
1377
1393
|
/** Roles permitted to call the editor mutations (also enforced by the ACL). Default
|
|
1378
1394
|
* `["editor", "admin"]`. */
|
|
@@ -1491,20 +1507,28 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
|
|
|
1491
1507
|
};
|
|
1492
1508
|
|
|
1493
1509
|
// (slug, locale) uniqueness is enforced here because pramen's unique() is single-column.
|
|
1510
|
+
//
|
|
1511
|
+
// Slugs are global across content types — the constraint is (slug, locale), NOT
|
|
1512
|
+
// (slug, locale, typeId) — so the colliding page is very often one the caller cannot see:
|
|
1513
|
+
// the editor lists ONE type per tab, and "already exists" naming only slug + locale leaves
|
|
1514
|
+
// them staring at a list that visibly contains no such row. Both messages name the owning
|
|
1515
|
+
// type, the way the trash variant already named the trash.
|
|
1494
1516
|
const assertSlugFree = async (db: CmsDb, slug: string, locale: string, exceptId?: string): Promise<void> => {
|
|
1495
1517
|
const rows = await db.exec(
|
|
1496
|
-
"SELECT id, deletedAt FROM cms_pages WHERE slug = ? AND locale = ? LIMIT 1",
|
|
1518
|
+
"SELECT id, deletedAt, typeId FROM cms_pages WHERE slug = ? AND locale = ? LIMIT 1",
|
|
1497
1519
|
slug,
|
|
1498
1520
|
locale,
|
|
1499
1521
|
);
|
|
1500
1522
|
if (rows[0] && String(rows[0].id) !== exceptId) {
|
|
1523
|
+
const typeSlug = await contentTypeSlug(db, rows[0].typeId).catch(() => null);
|
|
1524
|
+
const under = typeSlug ? ` under content type '${typeSlug}'` : "";
|
|
1501
1525
|
// A trashed page keeps its slug until purged (the (slug, locale) unique index is a
|
|
1502
1526
|
// DB constraint, not advisory). Say so, rather than leave the caller hunting for a
|
|
1503
1527
|
// page they cannot see.
|
|
1504
1528
|
if (rows[0].deletedAt != null) {
|
|
1505
|
-
throw new BadRequest(`slug '${slug}' is held by a page in the trash for locale '${locale}' — restore or purge it first`);
|
|
1529
|
+
throw new BadRequest(`slug '${slug}' is held by a page in the trash for locale '${locale}'${under} — restore or purge it first`);
|
|
1506
1530
|
}
|
|
1507
|
-
throw new BadRequest(`slug '${slug}' already exists for locale '${locale}'`);
|
|
1531
|
+
throw new BadRequest(`slug '${slug}' already exists for locale '${locale}'${under} — slugs are unique across all content types`);
|
|
1508
1532
|
}
|
|
1509
1533
|
};
|
|
1510
1534
|
|
|
@@ -1546,6 +1570,11 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
|
|
|
1546
1570
|
input: (raw): { name: string; slug: string; regions: RegionDefinition[]; fieldsSchema?: FieldDefinition[]; defaultBlocks?: DefaultBlockDefinition[] } => {
|
|
1547
1571
|
const o = asObj(raw);
|
|
1548
1572
|
if (typeof o.name !== "string" || typeof o.slug !== "string") throw new BadRequest("name and slug are required");
|
|
1573
|
+
// Non-EMPTY, not merely a string: a content type's slug is a URL segment in the
|
|
1574
|
+
// editor (`/types/:slug`) and the key `listPages({ contentType })` resolves. An
|
|
1575
|
+
// empty one builds `/types/` — a path the router drops the empty segment from, so
|
|
1576
|
+
// the type gets a tab that cannot be reached and a list that cannot be addressed.
|
|
1577
|
+
if (o.name.trim() === "" || o.slug.trim() === "") throw new BadRequest("name and slug must not be empty");
|
|
1549
1578
|
if (!Array.isArray(o.regions) || o.regions.length === 0) throw new BadRequest("at least one region is required");
|
|
1550
1579
|
return o as never;
|
|
1551
1580
|
},
|
|
@@ -1591,6 +1620,8 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
|
|
|
1591
1620
|
input: (raw): { id?: string; slug?: string; name?: string; regions?: RegionDefinition[]; fieldsSchema?: FieldDefinition[]; defaultBlocks?: DefaultBlockDefinition[] } => {
|
|
1592
1621
|
const o = asObj(raw);
|
|
1593
1622
|
if (typeof o.id !== "string" && typeof o.slug !== "string") throw new BadRequest("id or slug is required");
|
|
1623
|
+
// `name` is the editor's tab label; blanking it leaves an unlabelled tab.
|
|
1624
|
+
if (typeof o.name === "string" && o.name.trim() === "") throw new BadRequest("name must not be empty");
|
|
1594
1625
|
return o as never;
|
|
1595
1626
|
},
|
|
1596
1627
|
}),
|
|
@@ -1752,43 +1783,105 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
|
|
|
1752
1783
|
}),
|
|
1753
1784
|
|
|
1754
1785
|
// ---- pages ----
|
|
1755
|
-
/** List pages, newest first.
|
|
1756
|
-
*
|
|
1757
|
-
*
|
|
1758
|
-
*
|
|
1759
|
-
*
|
|
1760
|
-
*
|
|
1786
|
+
/** List pages, newest first. Viewer-gated: the rows are FULL page records (schedule
|
|
1787
|
+
* timestamps, revision pointer, the whole `fields` bag, every SEO column), which is the
|
|
1788
|
+
* editing surface, not the published one — `listPublishedPages` is this file's deliberate
|
|
1789
|
+
* public projection and stays narrow.
|
|
1790
|
+
*
|
|
1791
|
+
* `contentType` (a content-type SLUG) narrows the list to one type — what an editor that
|
|
1792
|
+
* gives each type its own tab needs, and the only way to stay correct once a deployment
|
|
1793
|
+
* has more than `limit` entries in total: filtering the full list client-side would
|
|
1794
|
+
* silently drop the tail of every type. Omitted ⇒ all types, the historical behaviour. An
|
|
1795
|
+
* unknown slug returns nothing rather than everything, so a typo can never read as "here
|
|
1796
|
+
* is the whole CMS" — and neither can an EMPTY one, which is why the check below is
|
|
1797
|
+
* `=== undefined` and not a falsy test.
|
|
1798
|
+
*
|
|
1799
|
+
* `limit`/`offset` page the list. Without them the caller cannot tell a full first page
|
|
1800
|
+
* from the whole table, and the editor's header reports the cap as if it were the total.
|
|
1801
|
+
* `select` narrows the projection: a list screen needs five columns, not the widest row
|
|
1802
|
+
* in the CMS, and on the D1 store every unasked-for column crosses RPC. */
|
|
1761
1803
|
listPages: query(
|
|
1762
|
-
async (ctx, input: { contentType?: string }) => {
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
return
|
|
1804
|
+
async (ctx, input: { contentType?: string; limit?: number; offset?: number; select?: string[] }) => {
|
|
1805
|
+
// ONE query, not two: `where` traverses the `type` belongsTo the schema already
|
|
1806
|
+
// declares, so the slug is resolved by a subquery. That also gives the unknown-slug
|
|
1807
|
+
// invariant for free (an empty subquery matches nothing) and, unlike a hand-rolled
|
|
1808
|
+
// lookup in cms_content_types, does not THROW for a policy set that grants cms_pages
|
|
1809
|
+
// but not the types table.
|
|
1810
|
+
const where = input?.contentType === undefined ? undefined : { type: { slug: input.contentType } };
|
|
1811
|
+
return cdb(ctx).find({
|
|
1812
|
+
from: "cms_pages",
|
|
1813
|
+
where,
|
|
1814
|
+
orderBy: { column: "createdAt", dir: "desc" },
|
|
1815
|
+
limit: Math.min(input?.limit ?? PAGE_LIST_LIMIT, PAGE_LIST_MAX_LIMIT),
|
|
1816
|
+
offset: input?.offset ?? 0,
|
|
1817
|
+
select: input?.select,
|
|
1818
|
+
});
|
|
1770
1819
|
},
|
|
1771
1820
|
{
|
|
1772
|
-
|
|
1821
|
+
...viewer,
|
|
1822
|
+
// The parsed value is what the ACL sees (`dispatch` hands it to `Db` for `$input()`
|
|
1823
|
+
// resolution), so this SPREADS the raw object rather than rebuilding one from the
|
|
1824
|
+
// keys it knows: a host scoping cms_pages with `$input("someKey")` had its marker
|
|
1825
|
+
// resolving against `{}` — the scope collapsing to nothing — the moment this handler
|
|
1826
|
+
// grew a parser.
|
|
1827
|
+
input: (raw): { contentType?: string; limit?: number; offset?: number; select?: string[] } => {
|
|
1773
1828
|
const o = asObj(raw);
|
|
1774
|
-
|
|
1775
|
-
if (
|
|
1776
|
-
|
|
1829
|
+
const out: Record<string, unknown> = { ...o };
|
|
1830
|
+
if (o.contentType === undefined || o.contentType === null) delete out.contentType;
|
|
1831
|
+
else if (typeof o.contentType !== "string") throw new BadRequest("contentType must be a string");
|
|
1832
|
+
for (const k of ["limit", "offset"] as const) {
|
|
1833
|
+
if (o[k] === undefined || o[k] === null) { delete out[k]; continue; }
|
|
1834
|
+
if (typeof o[k] !== "number" || !Number.isInteger(o[k]) || (o[k] as number) < 0) throw new BadRequest(`${k} must be a non-negative integer`);
|
|
1835
|
+
}
|
|
1836
|
+
if (o.select === undefined || o.select === null) delete out.select;
|
|
1837
|
+
else if (!Array.isArray(o.select) || o.select.some((c) => typeof c !== "string")) throw new BadRequest("select must be an array of column names");
|
|
1838
|
+
return out as never;
|
|
1777
1839
|
},
|
|
1778
1840
|
},
|
|
1779
1841
|
),
|
|
1780
1842
|
|
|
1843
|
+
/** One page by id, for the editor opening `/pages/:id` directly. Resolving that id
|
|
1844
|
+
* against `listPages` instead means a deep link (or a row clicked in a type's own tab)
|
|
1845
|
+
* can miss: that list is capped and, since the editor lists per type, is not even the
|
|
1846
|
+
* list the row came from. Viewer-gated + row-ACL'd like every other read. */
|
|
1847
|
+
getPageById: query(async (ctx, input: { pageId: string }) => {
|
|
1848
|
+
const rows = await cdb(ctx).find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
|
|
1849
|
+
return rows[0] ?? null;
|
|
1850
|
+
}, { ...viewer, ...pageIdInput }),
|
|
1851
|
+
|
|
1781
1852
|
/** Public: list published pages (slug, locale, updatedAt) for sitemap generation. The
|
|
1782
|
-
* anonymous ACL scopes cms_pages reads to status=published, so this is safe to expose.
|
|
1783
|
-
|
|
1853
|
+
* anonymous ACL scopes cms_pages reads to status=published, so this is safe to expose.
|
|
1854
|
+
*
|
|
1855
|
+
* `contentType` / `locale` narrow the list HERE, for the same reason `listPages` does:
|
|
1856
|
+
* the result is capped, so a caller filtering it afterwards is filtering an already
|
|
1857
|
+
* truncated list and loses the tail of every type. `@pramen/cms-astro`'s `collections:
|
|
1858
|
+
* "auto"` builds one collection per content type, each calling this — un-narrowed, all
|
|
1859
|
+
* of them fetch the same 5000 rows and everything past the cap vanishes from the built
|
|
1860
|
+
* site with a green build. An unknown slug returns nothing, never everything. */
|
|
1861
|
+
listPublishedPages: query(async (ctx, input: { contentType?: string; locale?: string }) => {
|
|
1784
1862
|
const db = cdb(ctx);
|
|
1785
|
-
const
|
|
1863
|
+
const where: Record<string, unknown> = { status: "published" };
|
|
1864
|
+
if (input?.contentType !== undefined) where.type = { slug: input.contentType };
|
|
1865
|
+
if (input?.locale !== undefined) where.locale = input.locale;
|
|
1866
|
+
const rows = await db.find({ from: "cms_pages", where, orderBy: { column: "updatedAt", dir: "desc" }, limit: 5000 });
|
|
1786
1867
|
// Join typeId → content-type slug so a frontend can route/filter by type (e.g. articles
|
|
1787
1868
|
// vs pages) without a second round-trip.
|
|
1788
1869
|
const typeIds = [...new Set(rows.map((r) => r.typeId).filter((v): v is string => typeof v === "string"))];
|
|
1789
1870
|
const types = typeIds.length ? await db.find({ from: "cms_content_types", where: { id: { in: typeIds } } }) : [];
|
|
1790
1871
|
const slugById = new Map(types.map((t) => [String(t.id), String(t.slug)]));
|
|
1791
1872
|
return rows.map((r) => ({ slug: String(r.slug), locale: String(r.locale ?? "en"), contentType: slugById.get(String(r.typeId)) ?? null, updatedAt: String(r.updatedAt ?? r.createdAt ?? "") }));
|
|
1873
|
+
}, {
|
|
1874
|
+
// Spreads the raw object for the same reason `listPages` does — the parsed value is
|
|
1875
|
+
// what `$input()` policy markers resolve against.
|
|
1876
|
+
input: (raw): { contentType?: string; locale?: string } => {
|
|
1877
|
+
const o = asObj(raw);
|
|
1878
|
+
const out: Record<string, unknown> = { ...o };
|
|
1879
|
+
for (const k of ["contentType", "locale"] as const) {
|
|
1880
|
+
if (o[k] === undefined || o[k] === null) { delete out[k]; continue; }
|
|
1881
|
+
if (typeof o[k] !== "string") throw new BadRequest(`${k} must be a string`);
|
|
1882
|
+
}
|
|
1883
|
+
return out as never;
|
|
1884
|
+
},
|
|
1792
1885
|
}),
|
|
1793
1886
|
|
|
1794
1887
|
/** Update a page's SEO fields (meta/canonical/robots/OpenGraph/JSON-LD). Editor-gated. */
|
|
@@ -1990,7 +2083,13 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
|
|
|
1990
2083
|
* a client flag can hide a control but cannot make the data right, and the two drift
|
|
1991
2084
|
* the moment someone adds a locale. `multilingual` is the derived answer to the only
|
|
1992
2085
|
* question the UI actually asks, so each surface doesn't re-derive it from the list. */
|
|
1993
|
-
|
|
2086
|
+
/** What this deployment supports. `pagesByType` is the editor's licence to give each
|
|
2087
|
+
* content type its own tab and its own list: an OLDER server ignores the `contentType`
|
|
2088
|
+
* argument entirely and answers with the pooled list, so an editor that assumed the
|
|
2089
|
+
* feature would render N tabs all showing every type's pages under a heading claiming
|
|
2090
|
+
* otherwise — and "New page" from any of them would stamp that tab's type. Declared, not
|
|
2091
|
+
* inferred: fail closed on the pooled list rather than open on N lying ones. */
|
|
2092
|
+
listCmsCapabilities: query(() => ({ locales, defaultLocale, multilingual: locales.length > 1, pagesByType: true as const }), viewer),
|
|
1994
2093
|
|
|
1995
2094
|
/** Distinct locales present across all pages. NOTE: a DATA query — what is in the
|
|
1996
2095
|
* store — not configuration. `listCmsCapabilities().locales` is what the deployment
|