@pramen/cms 0.0.50 → 0.0.52

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
@@ -467,6 +467,61 @@ index is what turns a concurrent duplicate into a visible failure instead of a s
467
467
  ambiguous history. For the same reason the delete-and-purge pair is atomic on the DO but not
468
468
  on D1.
469
469
 
470
+ ### Locales
471
+
472
+ A deployment declares the locales it publishes in, and the editor renders its i18n surface
473
+ off that — the Translations panel, the Locale field, the per-row locale column:
474
+
475
+ ```ts
476
+ const handlers = { ...createCmsHandlers({ locales: ["cs", "en"] }), ... };
477
+ ```
478
+
479
+ One locale (or none declared) means monolingual: no i18n chrome anywhere, and the editor
480
+ stops sending `locale` on a page save at all, so nothing can blind-overwrite a field it no
481
+ longer shows. The FIRST entry is the locale a page is stamped with when created without
482
+ one — derived, not a second `defaultLocale` option, because two settings that can disagree
483
+ about the same fact is how a Czech-only site ends up publishing `en`.
484
+
485
+ Declared, not inferred from the data: the only way to create a second locale is
486
+ `createTranslation`, which the editor exposes from inside the Translations panel — so a rule
487
+ like "show i18n once a second locale exists" would mean a monolingual site could never
488
+ become multilingual. `listCmsCapabilities` is what the editor reads; `listLocales` remains a
489
+ DATA query (which locales are actually authored), and the two differ while a locale is
490
+ declared but not yet used.
491
+
492
+ ### Which store: D1 or the Durable Object
493
+
494
+ For a CMS behind a site, **D1 is usually the right store** — and it is the only one that
495
+ lets the CMS live inside an Astro site's own Worker, because D1 is a binding while a
496
+ Durable Object has to be exported from the worker entry.
497
+
498
+ What you give up, and it is worth knowing before you pick:
499
+
500
+ - **No atomic multi-statement mutations.** D1 has no interactive transactions, so
501
+ `transaction(fn)` runs `fn` as-is: a mutation that throws midway keeps what it already
502
+ wrote. Single-statement writes are still atomic. The Worker logs a specific error naming
503
+ the handler and how many statements had committed, so a partial write is diagnosable
504
+ rather than looking like an ordinary 500. Where the CMS does several writes in one
505
+ mutation — a revision plus the row it belongs to, a delete plus its revision purge — the
506
+ order is chosen so a failure leaves a benign state, but it is not a rollback.
507
+ - **No live queries.** Those need the Durable Object's socket host. The editor does not use
508
+ them, so this only matters if your own frontend subscribes.
509
+ - **Scheduled publish needs a Cron trigger.** There is no DO alarm to self-drain the outbox,
510
+ so a delayed task runs only when a Cron trigger calls `createPramen().scheduled`:
511
+
512
+ ```jsonc
513
+ "triggers": { "crons": ["* * * * *"] }
514
+ ```
515
+
516
+ Forget it and a scheduled publish simply never fires. The Worker now notices — when a
517
+ request-tail drain leaves a task queued for the future and no Cron drain has ever run, it
518
+ logs once naming the fix, and stops as soon as a Cron drain is seen.
519
+ - **One shared database, no tenant column.** A non-`main` tenant on D1 is refused unless you
520
+ set `PRAMEN_D1_ALLOW_MULTITENANT=true`, because the rows would commingle.
521
+
522
+ Pick the Durable Object store when a mutation must be atomic, when you want live queries, or
523
+ when tenants must be isolated by construction.
524
+
470
525
  ## Limitations
471
526
 
472
527
  - **Block `fields` are opaque JSON**, so pramen's row/cell-level ACL and relational queries
package/dist/index.d.ts CHANGED
@@ -791,8 +791,19 @@ export interface CmsHandlerOpts {
791
791
  editorRoles?: readonly string[];
792
792
  /** Max accepted media upload size in bytes (enforced at the Worker). Default 25 MB. */
793
793
  mediaMaxSize?: number;
794
- /** Default locale used when `getPage`/`createPage` omit one. Default `"en"`. */
795
- defaultLocale?: string;
794
+ /** The locales this deployment publishes in, most-preferred first. Default `["en"]`.
795
+ *
796
+ * DECLARED, not inferred. The editor renders its i18n surface — the Translations panel,
797
+ * the Locale field, the per-row locale column — only when there is more than one, and
798
+ * `listCmsCapabilities` is how it finds out. Inferring "is this site multilingual?" from
799
+ * the locales PRESENT IN DATA cannot work: the only way to create a second locale is
800
+ * `createTranslation`, which the editor exposes from inside the very panel that would
801
+ * stay hidden, so a monolingual site could never become multilingual.
802
+ *
803
+ * The first entry is the default stamped on a page created without one, which is why
804
+ * `defaultLocale` is derived from this rather than configured beside it — two options
805
+ * that can disagree about the same fact is how a Czech-only site ends up stamping "en". */
806
+ locales?: readonly string[];
796
807
  /** Roles permitted to approve/reject a page in review and publish (the editorial gate).
797
808
  * Default `["reviewer", "admin"]`. */
798
809
  reviewerRoles?: readonly string[];
@@ -897,6 +908,20 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
897
908
  ok: true;
898
909
  }>;
899
910
  listContentTypes: import("@pramen/server").Handler<unknown, Record<string, unknown>[]>;
911
+ /** Content-type slugs + names, PUBLIC. The editor-facing `listContentTypes` above is
912
+ * viewer-gated, which a BUILD cannot satisfy: `@pramen/cms-astro`'s `collections: "auto"`
913
+ * runs in `astro:config:setup`, where there is no editor session and shipping a token to
914
+ * CI just to list type names would be the wrong trade.
915
+ *
916
+ * Nothing new is exposed. `cmsPolicies().public` already grants anonymous read of
917
+ * `cms_content_types` ("slugs/names are structural, not sensitive"), and
918
+ * `listPublishedPages` already returns the content-type slug of every published page.
919
+ * The projection is deliberately narrow — slug and name only, never the regions or
920
+ * field schema, which describe the editing surface rather than the published site. */
921
+ listPublicContentTypes: import("@pramen/server").Handler<unknown, {
922
+ slug: string;
923
+ name: string;
924
+ }[]>;
900
925
  getContentType: import("@pramen/server").Handler<{
901
926
  id: string;
902
927
  }, Record<string, unknown>>;
@@ -960,7 +985,21 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
960
985
  title: string;
961
986
  status: string;
962
987
  }[]>;
963
- /** Distinct locales present across all pages. */
988
+ /** What this deployment supports, for an editor to render against — the pages-side
989
+ * counterpart to `listCollections`' `supports: [...]`.
990
+ *
991
+ * The editor asks the SERVER what exists rather than being told by its own /config.js:
992
+ * a client flag can hide a control but cannot make the data right, and the two drift
993
+ * the moment someone adds a locale. `multilingual` is the derived answer to the only
994
+ * question the UI actually asks, so each surface doesn't re-derive it from the list. */
995
+ listCmsCapabilities: import("@pramen/server").Handler<unknown, {
996
+ locales: string[];
997
+ defaultLocale: string;
998
+ multilingual: boolean;
999
+ }>;
1000
+ /** Distinct locales present across all pages. NOTE: a DATA query — what is in the
1001
+ * store — not configuration. `listCmsCapabilities().locales` is what the deployment
1002
+ * declares; these two differ while a locale is declared but not yet authored. */
964
1003
  listLocales: import("@pramen/server").Handler<unknown, string[]>;
965
1004
  /** Create a block instance and place it into a page region in one call (the common
966
1005
  * editor action). Validates the fields against the block type's schema and the region
@@ -1237,6 +1276,20 @@ export declare const cmsHandlers: {
1237
1276
  ok: true;
1238
1277
  }>;
1239
1278
  listContentTypes: import("@pramen/server").Handler<unknown, Record<string, unknown>[]>;
1279
+ /** Content-type slugs + names, PUBLIC. The editor-facing `listContentTypes` above is
1280
+ * viewer-gated, which a BUILD cannot satisfy: `@pramen/cms-astro`'s `collections: "auto"`
1281
+ * runs in `astro:config:setup`, where there is no editor session and shipping a token to
1282
+ * CI just to list type names would be the wrong trade.
1283
+ *
1284
+ * Nothing new is exposed. `cmsPolicies().public` already grants anonymous read of
1285
+ * `cms_content_types` ("slugs/names are structural, not sensitive"), and
1286
+ * `listPublishedPages` already returns the content-type slug of every published page.
1287
+ * The projection is deliberately narrow — slug and name only, never the regions or
1288
+ * field schema, which describe the editing surface rather than the published site. */
1289
+ listPublicContentTypes: import("@pramen/server").Handler<unknown, {
1290
+ slug: string;
1291
+ name: string;
1292
+ }[]>;
1240
1293
  getContentType: import("@pramen/server").Handler<{
1241
1294
  id: string;
1242
1295
  }, Record<string, unknown>>;
@@ -1300,7 +1353,21 @@ export declare const cmsHandlers: {
1300
1353
  title: string;
1301
1354
  status: string;
1302
1355
  }[]>;
1303
- /** Distinct locales present across all pages. */
1356
+ /** What this deployment supports, for an editor to render against — the pages-side
1357
+ * counterpart to `listCollections`' `supports: [...]`.
1358
+ *
1359
+ * The editor asks the SERVER what exists rather than being told by its own /config.js:
1360
+ * a client flag can hide a control but cannot make the data right, and the two drift
1361
+ * the moment someone adds a locale. `multilingual` is the derived answer to the only
1362
+ * question the UI actually asks, so each surface doesn't re-derive it from the list. */
1363
+ listCmsCapabilities: import("@pramen/server").Handler<unknown, {
1364
+ locales: string[];
1365
+ defaultLocale: string;
1366
+ multilingual: boolean;
1367
+ }>;
1368
+ /** Distinct locales present across all pages. NOTE: a DATA query — what is in the
1369
+ * store — not configuration. `listCmsCapabilities().locales` is what the deployment
1370
+ * declares; these two differ while a locale is declared but not yet authored. */
1304
1371
  listLocales: import("@pramen/server").Handler<unknown, string[]>;
1305
1372
  /** Create a block instance and place it into a page region in one call (the common
1306
1373
  * editor action). Validates the fields against the block type's schema and the region
package/dist/index.js CHANGED
@@ -959,7 +959,8 @@ export function createCmsHandlers(opts = {}) {
959
959
  const editorRoles = opts.editorRoles ?? ["editor", "admin"];
960
960
  const editor = { auth: editorRoles };
961
961
  const mediaMaxSize = opts.mediaMaxSize ?? 25_000_000;
962
- const defaultLocale = opts.defaultLocale ?? "en";
962
+ const locales = opts.locales && opts.locales.length > 0 ? [...opts.locales] : ["en"];
963
+ const defaultLocale = locales[0];
963
964
  const reviewerRoles = opts.reviewerRoles ?? ["reviewer", "admin"];
964
965
  const reviewer = { auth: reviewerRoles };
965
966
  const previewTtl = opts.previewTtlSeconds ?? DEFAULT_PREVIEW_TTL_SECONDS;
@@ -1272,6 +1273,20 @@ export function createCmsHandlers(opts = {}) {
1272
1273
  return { ok: true };
1273
1274
  }, { ...reviewer, ...mediaIdInput }),
1274
1275
  listContentTypes: query((ctx) => cdb(ctx).find({ from: "cms_content_types", orderBy: { column: "name" } }), viewer),
1276
+ /** Content-type slugs + names, PUBLIC. The editor-facing `listContentTypes` above is
1277
+ * viewer-gated, which a BUILD cannot satisfy: `@pramen/cms-astro`'s `collections: "auto"`
1278
+ * runs in `astro:config:setup`, where there is no editor session and shipping a token to
1279
+ * CI just to list type names would be the wrong trade.
1280
+ *
1281
+ * Nothing new is exposed. `cmsPolicies().public` already grants anonymous read of
1282
+ * `cms_content_types` ("slugs/names are structural, not sensitive"), and
1283
+ * `listPublishedPages` already returns the content-type slug of every published page.
1284
+ * The projection is deliberately narrow — slug and name only, never the regions or
1285
+ * field schema, which describe the editing surface rather than the published site. */
1286
+ listPublicContentTypes: query(async (ctx) => {
1287
+ const rows = await cdb(ctx).find({ from: "cms_content_types", orderBy: { column: "name" } });
1288
+ return rows.map((r) => ({ slug: String(r.slug), name: String(r.name ?? r.slug) }));
1289
+ }),
1275
1290
  getContentType: query(async (ctx, input) => {
1276
1291
  const rows = await cdb(ctx).find({ from: "cms_content_types", where: { id: input.id }, limit: 1 });
1277
1292
  return rows[0] ?? null;
@@ -1499,7 +1514,17 @@ export function createCmsHandlers(opts = {}) {
1499
1514
  return o;
1500
1515
  },
1501
1516
  }),
1502
- /** Distinct locales present across all pages. */
1517
+ /** What this deployment supports, for an editor to render against — the pages-side
1518
+ * counterpart to `listCollections`' `supports: [...]`.
1519
+ *
1520
+ * The editor asks the SERVER what exists rather than being told by its own /config.js:
1521
+ * a client flag can hide a control but cannot make the data right, and the two drift
1522
+ * the moment someone adds a locale. `multilingual` is the derived answer to the only
1523
+ * question the UI actually asks, so each surface doesn't re-derive it from the list. */
1524
+ listCmsCapabilities: query(() => ({ locales, defaultLocale, multilingual: locales.length > 1 }), viewer),
1525
+ /** Distinct locales present across all pages. NOTE: a DATA query — what is in the
1526
+ * store — not configuration. `listCmsCapabilities().locales` is what the deployment
1527
+ * declares; these two differ while a locale is declared but not yet authored. */
1503
1528
  listLocales: query(async (ctx) => {
1504
1529
  // Raw exec bypasses the ACL, so the trash filter has to be written out by hand —
1505
1530
  // otherwise the editor's locale switcher offers a locale with zero live pages.
@@ -1872,8 +1897,6 @@ export function createCmsHandlers(opts = {}) {
1872
1897
  // has no notion of `x-pramen-store`. Minting on the D1 store therefore produces a
1873
1898
  // link that 404s forever while the editor reports success — refuse instead of
1874
1899
  // handing out a token that cannot work.
1875
- if (ctx.store === "d1")
1876
- throw new PramenError("page preview is not available on the D1 store (redemption requires the Durable Object)", 503, "unavailable");
1877
1900
  const db = cdb(ctx);
1878
1901
  // Read the page through the ACL first: minting a link is granting access to it, so a
1879
1902
  // caller who cannot read the page must not be able to mint a link that can.
@@ -2948,12 +2971,6 @@ export function createCollectionHandlers(collections, opts = {}) {
2948
2971
  const secret = previewSecret(ctx.env);
2949
2972
  if (!secret)
2950
2973
  throw previewUnconfigured(); // fail closed — never mint a forgeable link
2951
- // Redemption always reaches a Durable Object (callPrivileged -> PRAMEN.get) and has no
2952
- // notion of `x-pramen-store`, so a link minted on D1 would 404 forever while the
2953
- // editor reported success.
2954
- if (ctx.store === "d1") {
2955
- throw new PramenError("collection preview is not available on the D1 store (redemption requires the Durable Object)", 503, "unavailable");
2956
- }
2957
2974
  const row = await loadRow(cdb(ctx), c, input.id);
2958
2975
  const ttl = Math.max(60, Math.min(input.expiresIn ?? previewTtl, 30 * 24 * 3600));
2959
2976
  const exp = Math.floor(Date.now() / 1000) + ttl;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/cms",
3
- "version": "0.0.50",
3
+ "version": "0.0.52",
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.50"
44
+ "@pramen/server": "0.0.52"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "react": ">=18"
package/src/index.ts CHANGED
@@ -1379,8 +1379,19 @@ export interface CmsHandlerOpts {
1379
1379
  editorRoles?: readonly string[];
1380
1380
  /** Max accepted media upload size in bytes (enforced at the Worker). Default 25 MB. */
1381
1381
  mediaMaxSize?: number;
1382
- /** Default locale used when `getPage`/`createPage` omit one. Default `"en"`. */
1383
- defaultLocale?: string;
1382
+ /** The locales this deployment publishes in, most-preferred first. Default `["en"]`.
1383
+ *
1384
+ * DECLARED, not inferred. The editor renders its i18n surface — the Translations panel,
1385
+ * the Locale field, the per-row locale column — only when there is more than one, and
1386
+ * `listCmsCapabilities` is how it finds out. Inferring "is this site multilingual?" from
1387
+ * the locales PRESENT IN DATA cannot work: the only way to create a second locale is
1388
+ * `createTranslation`, which the editor exposes from inside the very panel that would
1389
+ * stay hidden, so a monolingual site could never become multilingual.
1390
+ *
1391
+ * The first entry is the default stamped on a page created without one, which is why
1392
+ * `defaultLocale` is derived from this rather than configured beside it — two options
1393
+ * that can disagree about the same fact is how a Czech-only site ends up stamping "en". */
1394
+ locales?: readonly string[];
1384
1395
  /** Roles permitted to approve/reject a page in review and publish (the editorial gate).
1385
1396
  * Default `["reviewer", "admin"]`. */
1386
1397
  reviewerRoles?: readonly string[];
@@ -1398,7 +1409,8 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1398
1409
  const editorRoles = opts.editorRoles ?? ["editor", "admin"];
1399
1410
  const editor = { auth: editorRoles };
1400
1411
  const mediaMaxSize = opts.mediaMaxSize ?? 25_000_000;
1401
- const defaultLocale = opts.defaultLocale ?? "en";
1412
+ const locales = opts.locales && opts.locales.length > 0 ? [...opts.locales] : ["en"];
1413
+ const defaultLocale = locales[0]!;
1402
1414
  const reviewerRoles = opts.reviewerRoles ?? ["reviewer", "admin"];
1403
1415
  const reviewer = { auth: reviewerRoles };
1404
1416
  const previewTtl = opts.previewTtlSeconds ?? DEFAULT_PREVIEW_TTL_SECONDS;
@@ -1712,6 +1724,21 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1712
1724
 
1713
1725
  listContentTypes: query((ctx) => cdb(ctx).find({ from: "cms_content_types", orderBy: { column: "name" } }), viewer),
1714
1726
 
1727
+ /** Content-type slugs + names, PUBLIC. The editor-facing `listContentTypes` above is
1728
+ * viewer-gated, which a BUILD cannot satisfy: `@pramen/cms-astro`'s `collections: "auto"`
1729
+ * runs in `astro:config:setup`, where there is no editor session and shipping a token to
1730
+ * CI just to list type names would be the wrong trade.
1731
+ *
1732
+ * Nothing new is exposed. `cmsPolicies().public` already grants anonymous read of
1733
+ * `cms_content_types` ("slugs/names are structural, not sensitive"), and
1734
+ * `listPublishedPages` already returns the content-type slug of every published page.
1735
+ * The projection is deliberately narrow — slug and name only, never the regions or
1736
+ * field schema, which describe the editing surface rather than the published site. */
1737
+ listPublicContentTypes: query(async (ctx) => {
1738
+ const rows = await cdb(ctx).find({ from: "cms_content_types", orderBy: { column: "name" } });
1739
+ return rows.map((r) => ({ slug: String(r.slug), name: String(r.name ?? r.slug) }));
1740
+ }),
1741
+
1715
1742
  getContentType: query(async (ctx, input: { id: string }) => {
1716
1743
  const rows = await cdb(ctx).find({ from: "cms_content_types", where: { id: input.id }, limit: 1 });
1717
1744
  return rows[0] ?? null;
@@ -1932,7 +1959,18 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1932
1959
  },
1933
1960
  }),
1934
1961
 
1935
- /** Distinct locales present across all pages. */
1962
+ /** What this deployment supports, for an editor to render against — the pages-side
1963
+ * counterpart to `listCollections`' `supports: [...]`.
1964
+ *
1965
+ * The editor asks the SERVER what exists rather than being told by its own /config.js:
1966
+ * a client flag can hide a control but cannot make the data right, and the two drift
1967
+ * the moment someone adds a locale. `multilingual` is the derived answer to the only
1968
+ * question the UI actually asks, so each surface doesn't re-derive it from the list. */
1969
+ listCmsCapabilities: query(() => ({ locales, defaultLocale, multilingual: locales.length > 1 }), viewer),
1970
+
1971
+ /** Distinct locales present across all pages. NOTE: a DATA query — what is in the
1972
+ * store — not configuration. `listCmsCapabilities().locales` is what the deployment
1973
+ * declares; these two differ while a locale is declared but not yet authored. */
1936
1974
  listLocales: query(async (ctx) => {
1937
1975
  // Raw exec bypasses the ACL, so the trash filter has to be written out by hand —
1938
1976
  // otherwise the editor's locale switcher offers a locale with zero live pages.
@@ -2291,7 +2329,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
2291
2329
  // has no notion of `x-pramen-store`. Minting on the D1 store therefore produces a
2292
2330
  // link that 404s forever while the editor reports success — refuse instead of
2293
2331
  // handing out a token that cannot work.
2294
- if (ctx.store === "d1") throw new PramenError("page preview is not available on the D1 store (redemption requires the Durable Object)", 503, "unavailable");
2332
+
2295
2333
  const db = cdb(ctx);
2296
2334
  // Read the page through the ACL first: minting a link is granting access to it, so a
2297
2335
  // caller who cannot read the page must not be able to mint a link that can.
@@ -3538,12 +3576,6 @@ export function createCollectionHandlers(collections: readonly CollectionDef[],
3538
3576
  needs(c, "preview");
3539
3577
  const secret = previewSecret(ctx.env);
3540
3578
  if (!secret) throw previewUnconfigured(); // fail closed — never mint a forgeable link
3541
- // Redemption always reaches a Durable Object (callPrivileged -> PRAMEN.get) and has no
3542
- // notion of `x-pramen-store`, so a link minted on D1 would 404 forever while the
3543
- // editor reported success.
3544
- if (ctx.store === "d1") {
3545
- throw new PramenError("collection preview is not available on the D1 store (redemption requires the Durable Object)", 503, "unavailable");
3546
- }
3547
3579
  const row = await loadRow(cdb(ctx), c, input.id);
3548
3580
  const ttl = Math.max(60, Math.min(input.expiresIn ?? previewTtl, 30 * 24 * 3600));
3549
3581
  const exp = Math.floor(Date.now() / 1000) + ttl;