@pramen/cms 0.0.48 → 0.0.50

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
@@ -124,6 +124,130 @@ Editor flow: `createBlockType` → `createContentType` → `createPage` → `add
124
124
  `publishPage`. Public: `getPage({ slug })` returns the published snapshot; editors pass
125
125
  `{ slug, preview: true }` to assemble the live draft.
126
126
 
127
+ ### Preview links
128
+
129
+ `{ preview: true }` is a **role** check, so it only serves people who have an editor
130
+ account. The person preview actually exists for — the stakeholder reviewing copy before it
131
+ ships — usually has no account at all. For them, mint a signed link:
132
+
133
+ ```ts
134
+ const { url, expiresAt } = await client.call("signPagePreview", { pageId, expiresIn: 3600 });
135
+ // -> { url: "/cms/preview?token=…", expiresAt }
136
+ ```
137
+
138
+ Minting is editor-gated; **redeeming needs no session** — the signature is the
139
+ authorization. Spread `cmsRoutes()` into `app.routes` to serve `GET /cms/preview`, which
140
+ verifies the token in the Worker before any read and returns the live draft with
141
+ `isPreview: true` and `Cache-Control: private, no-store`.
142
+
143
+ If you pass custom roles to `createCmsHandlers`, hand `cmsRoutes` the **same options
144
+ object** — it derives the route's identity from them, so the two cannot drift:
145
+
146
+ ```ts
147
+ const cms = { editorRoles: ["editor"], reviewerRoles: ["reviewer"] };
148
+ const handlers = { ...createCmsHandlers(cms) };
149
+ const routes = [...cmsRoutes({ handlers: cms })];
150
+ ```
151
+
152
+ That identity must also be granted by your ACL. Preview is **DO-only**: redemption reaches
153
+ the Durable Object, so `signPagePreview` refuses on the D1 store rather than mint a link
154
+ that could never be redeemed.
155
+
156
+ The grant is scoped to **one page** and carries its own expiry (default 1 hour, clamped to
157
+ 30 days), so a leaked link is not "see all drafts" and stops working on its own. Signing
158
+ uses `PREVIEW_SECRET`, falling back to `FILES_SECRET` then `AUTH_SECRET` — the same
159
+ machinery as signed file urls. With no usable secret (≥16 chars) minting and verification
160
+ both **fail closed** rather than hand out forgeable links.
161
+
162
+ From an Astro site, `createCmsClient(...).getPreview(token)` redeems one.
163
+
164
+ ### Typed block fields
165
+
166
+ A developer-authored block type gets compile-time typing with no build step —
167
+ `defineBlockType(slug, fields as const)` plus `BlockFieldsOf<typeof def>`.
168
+
169
+ Webmaster-authored types are **data** (rows in `cms_block_types`, added with no deploy), so
170
+ they can't be typed at compile time. Read them back out of a running instance instead:
171
+
172
+ ```bash
173
+ bunx pramen-cms types --url https://cms.example.workers.dev --tenant acme --out src/cms.gen.ts
174
+ ```
175
+
176
+ Run it with **bun** (`bunx`), like the `pramen` bin — both ship extensionless ESM imports
177
+ that plain Node won't resolve. Its own bin rather than a `pramen` subcommand, because
178
+ `@pramen/cms` is optional and the runtime CLI shouldn't carry a command named after it.
179
+
180
+ That writes an interface per block-type slug plus a `BlockFieldsBySlug` registry. With no
181
+ `--out` it prints, so it composes with a pipe. Re-run it after a webmaster adds or changes
182
+ a block type.
183
+
184
+ ### Trash (soft delete)
185
+
186
+ `deletePage` and `deleteMedia` are **soft**: the row stays and `deletedAt` is stamped.
187
+ Filtering lives in the ACL, not in each handler — a read scope is AND-merged into every
188
+ `ctx.db` read, so one policy hides a trashed row from the public content API, the editor,
189
+ `listPublishedPages`/the sitemap, relation traversals and eager-loads at once.
190
+
191
+ | Handler | Role | Effect |
192
+ | --- | --- | --- |
193
+ | `deletePage` / `deleteMedia` | editor | stamp `deletedAt` — reversible |
194
+ | `listTrash` | editor / reviewer | what is currently trashed — `{ pages, media }` |
195
+ | `restorePage` / `restoreMedia` | editor | clear `deletedAt` |
196
+ | `purgePage` / `purgeMedia` | reviewer | permanent — row, placements, revisions, audit, R2 object |
197
+
198
+ Two caveats about doing it in the ACL:
199
+
200
+ - **Policies are OR-unioned.** If your app adds its own `allow()` policy on `cms_pages` or
201
+ `cms_media` alongside `cmsPolicies().editor`, that grant unions the trash filter away and
202
+ trashed rows become visible again. Scope your own grants with `deletedAt: { isNull: true }`.
203
+ - **The task context bypasses the ACL entirely** (it runs SYSTEM-scoped), so scheduled
204
+ publish/unpublish are not protected by the read scope. `deletePage` clears `scheduledAt`
205
+ and `unpublishAt` for exactly this reason — without that, a page trashed before its
206
+ scheduled time came back publicly live.
207
+
208
+ Two things worth knowing:
209
+
210
+ - **A trashed page keeps its slug.** `(slug, locale)` is a DB unique index, so the
211
+ alternative was mangling the stored slug on delete. Creating a page over a trashed slug
212
+ fails with a message saying so; purging frees it.
213
+ - **Trashing media keeps the R2 object.** Dropping the bytes would make `restoreMedia` a
214
+ lie, and a block still referencing the id would render a dead url. `purgeMedia` removes
215
+ both.
216
+
217
+ ### Concurrent edits
218
+
219
+ `cms_pages` and `cms_blocks` carry a `version` that bumps on every edit. Pass the version
220
+ you read back as `expectedVersion` and a stale write is refused with **409 conflict**
221
+ instead of silently overwriting whoever saved first:
222
+
223
+ ```ts
224
+ const { page: current } = await client.call("getPage", { slug, preview: true });
225
+ const { page: saved } = await client.call("updatePage", {
226
+ pageId: current.id,
227
+ title,
228
+ expectedVersion: current.version,
229
+ });
230
+ // 409: "this page was changed by someone else (you have version 3, current is 4)"
231
+ ```
232
+
233
+ The DO is a single writer, so writes already serialize — but *editors* don't. Without this,
234
+ two people on the same page meant last-save-wins with no signal to the loser.
235
+
236
+ `expectedVersion` is **optional**: omit it and you get the previous last-write-wins
237
+ behaviour. `updatePage` and `updatePageSeo` share the page's version line, so a body edit
238
+ and an SEO edit conflict with each other; blocks version independently.
239
+
240
+ Structural operations (`addBlock`, `removeBlock`, `reorderRegion`) are not guarded — they
241
+ are additive and already ordered by the single writer.
242
+
243
+ `version` is returned on `AssembledPage.page` and on every `RenderedBlock`, so the value to
244
+ echo back comes from the same read that loaded the content — including the public
245
+ (snapshot) path, where it is backfilled from the live row rather than the baked snapshot.
246
+
247
+ On the **D1 store** (`x-pramen-store: d1`) there is no interactive transaction, so two
248
+ requests in the same millisecond can both read the same version and both write. The guard
249
+ still catches the editor race it exists for; it is not a hard mutex there.
250
+
127
251
  ### Rendering (headless)
128
252
 
129
253
  The backend never dictates markup. `@pramen/cms/react` maps a block's `block_type` slug to
@@ -133,7 +257,7 @@ a component you provide:
133
257
  import { RegionRenderer } from "@pramen/cms/react";
134
258
  import { useLiveQuery } from "@pramen/react";
135
259
 
136
- const components = { hero: Hero, rich_text: RichText };
260
+ const components = { hero: Hero, rich_text: RichTextBlock };
137
261
  function Page({ slug }: { slug: string }) {
138
262
  const { data } = useLiveQuery(client, "getPage", { slug });
139
263
  if (!data) return null;
@@ -141,6 +265,208 @@ function Page({ slug }: { slug: string }) {
141
265
  }
142
266
  ```
143
267
 
268
+ A `richtext` field is a **document tree**, not an HTML string — render it with
269
+ `RichTextRenderer`, which walks the tree into real elements (no `dangerouslySetInnerHTML`,
270
+ nothing to sanitize at render time):
271
+
272
+ ```tsx
273
+ import { RichTextRenderer } from "@pramen/cms/react";
274
+
275
+ const RichTextBlock = ({ fields }) => <RichTextRenderer value={fields.body} />;
276
+ ```
277
+
278
+ Pass `components` to override any node type (`paragraph`, `heading`, `link`, …) with your
279
+ own element. `richTextToPlainText(doc)` flattens a document for excerpts and meta
280
+ descriptions.
281
+
282
+ Writes are checked against a structural allow-list (`normalizeRichText`): an unknown node
283
+ or mark type is dropped, only declared attributes survive, and a `link` href must pass a
284
+ scheme check — so a hand-crafted payload can't smuggle markup past the editor. Widen or
285
+ narrow the vocabulary with `createCmsHandlers({ richTextSchema })` (also accepted by
286
+ `createCollectionHandlers`) when your editor adds TipTap extensions.
287
+
288
+ Opening a page in the editor **migrates** any legacy HTML rich text on it: each field
289
+ converts to a document on mount and the ordinary autosave persists it. That conversion is
290
+ lossy for anything the editor's extension set doesn't model (an `h4` clamps to `h3`;
291
+ `sub`/`sup`/`ins` flatten), so convert deliberately if that matters.
292
+
293
+ Heading levels are 1–3 by default, matching the shipped editor's StarterKit config — raise
294
+ `richTextSchema.maxHeadingLevel` if your editor is configured for more. Out-of-range levels
295
+ are **clamped**, not dropped: a level-less heading would render as `h1` in the editor and
296
+ `h2` on the site — TipTap silently
297
+ demotes an unknown level on parse, so permitting more meant an imported `h4` opened as `h1`
298
+ and the next autosave persisted that.
299
+
300
+ Both renderers re-check a link's href rather than trusting the stored document: the write
301
+ path normalizes, but a row written by your own mutation, a bootstrap seed or an import
302
+ script never passed through it, and the renderer is what puts it on a page.
303
+
304
+ ### Collection workflow (`supports`)
305
+
306
+ A **collection** points the editor at one of your own pramen entities. By default it is
307
+ plain CRUD — no notion of published. `supports` opts it into the page-style workflow:
308
+
309
+ ```ts
310
+ const talks = collection("talks", {
311
+ entity: "talks",
312
+ label: "Talk",
313
+ supports: ["drafts", "scheduling", "revisions", "preview"],
314
+ fields: [
315
+ { name: "title", type: "text", required: true },
316
+ { name: "speaker", type: "text" },
317
+ ],
318
+ });
319
+ ```
320
+
321
+ Each feature is backed by **managed columns on your entity** — you declare the columns, the
322
+ CMS owns their values:
323
+
324
+ | Feature | Columns you add | Handlers you get |
325
+ | --- | --- | --- |
326
+ | `drafts` | `status` | `collectionPublish` / `collectionUnpublish` |
327
+ | `scheduling` | `publishedAt`, `scheduledAt`, `unpublishAt` | `collectionSchedule` (needs `drafts`) |
328
+ | `revisions` | — (uses `cms_collection_revisions`) | `collectionListRevisions` / `collectionRestoreRevision` |
329
+ | `preview` | — | `signCollectionPreview` (needs `drafts`) |
330
+
331
+ The editor renders the matching controls on a collection row — Publish / Unpublish, a
332
+ schedule picker, a preview link, and a restorable revision list — driven entirely by
333
+ `supports`, so a plain CRUD collection shows none of them.
334
+
335
+ ```ts
336
+ talks: Entity((t) => ({
337
+ id: primaryKey(generated(t.uuid())),
338
+ title: t.text(),
339
+ status: defaultTo(t.text(), "draft"), // managed
340
+ publishedAt: t.text(), // managed
341
+ scheduledAt: t.text(), // managed
342
+ unpublishAt: t.text(), // managed
343
+ createdAt: defaultTo(t.text(), expr.now()),
344
+ })),
345
+ ```
346
+
347
+ Wire all three pieces — the handlers need your `schema`, and **scheduling silently never
348
+ fires without the tasks**:
349
+
350
+ ```ts
351
+ const handlers = { ...cmsHandlers, ...createCollectionHandlers(collections, { schema }) };
352
+ const tasks = { ...cmsTasks, ...createCollectionTasks(collections) };
353
+ const acl = [
354
+ role("anonymous", [...cmsPolicies().public, ...collectionPublicPolicies(collections)]),
355
+ role("editor", [...cmsPolicies().editor, ...collectionPolicies(collections)]),
356
+ // a reviewer previews collection rows, so it needs the collection grants too —
357
+ // `getCollectionPreview` is gated with editorRoles ∪ reviewerRoles:
358
+ role("reviewer", [...cmsPolicies({ prefix: "cms-rev" }).editor,
359
+ ...collectionPolicies(collections, { prefix: "cms-rev" })]),
360
+ // …and EVERY other role that should see published content:
361
+ role("user", [...cmsPolicies({ prefix: "cms-user" }).public,
362
+ ...collectionPublicPolicies(collections, { prefix: "cms-user" })]),
363
+ ];
364
+ ```
365
+
366
+ > **`anonymous` is not "everyone".** pramen assigns that role only to callers with **no
367
+ > verified token**, so granting public reads there alone means a **logged-in** user is
368
+ > denied content a logged-**out** visitor can read — a public list handler returns rows to a
369
+ > guest and 403s for a member. There is no implicit everyone-role: spread the public grants
370
+ > into each role that should have them (distinct `prefix` per role keeps the policy names
371
+ > unique).
372
+
373
+ **A managed column is never in the write whitelist.** `fields` is the whitelist, so a
374
+ `status` entry there would let any editor send `values: { status: "published" }` through
375
+ `collectionUpdate` and skip the publish gate entirely. Declaring one is a **boot error**.
376
+
377
+ `createCollectionHandlers` **requires your `schema`** and validates the whole registry
378
+ against it at startup, naming the collection and the column, rather than failing on the
379
+ first call months later. It refuses:
380
+
381
+ - a managed column that is missing, `notNull()`, `hidden()`, not `t.text()`, or also
382
+ declared as an editable field — a name check alone left "the CMS owns these values"
383
+ resting on a convention, and every wrong declaration failed *silently* (a `t.json()`
384
+ `status` stores `"\"published\""`, so the row is invisible forever while Publish reports
385
+ success);
386
+ - a declared field that is not a column on the entity, or whose type cannot live in that
387
+ column (`richtext`/`group`/`repeater` need `t.json()`) — otherwise the first write fails
388
+ with a raw driver message and no HTTP status;
389
+ - an `idField` that is not the entity's primary key, an `orderBy` over a missing column
390
+ (SQLite resolves the quoted name to a *constant* and sorts every row equal — no error),
391
+ an entity outside the default partition (every handler dispatches there), an unknown
392
+ feature, and `scheduling`/`preview` without `drafts`;
393
+ - **two collections over the same entity.** The ACL keys policies by `(role, entity,
394
+ action)` and OR-merges them, so a second collection does not add a second view — it
395
+ *widens* the first one's read scope.
396
+
397
+ All of this now applies to a collection with no `supports` too: it is column-mapped just
398
+ the same.
399
+
400
+ `collectionPublicPolicies` is the **actual access boundary**, not a UI filter — it is
401
+ AND-merged into every `ctx.db` read of the entity, so an unpublished row is invisible to
402
+ your public queries, relation traversals and eager-loads alike. With `scheduling` it scopes
403
+ to:
404
+
405
+ ```
406
+ status = 'published'
407
+ AND (publishedAt IS NULL OR publishedAt <= $now())
408
+ AND (unpublishAt IS NULL OR unpublishAt > $now())
409
+ ```
410
+
411
+ Both time clauses matter. `{ publishedAt: { isNull: false } }` matches a *future* timestamp,
412
+ so a row scheduled for next week would be public the moment it was saved. And the
413
+ `unpublishAt` clause means a scheduled **takedown** is enforced by the read itself, not only
414
+ by the task — if `createCollectionTasks` was never wired or the outbox drain is stuck, the
415
+ row still stops being readable at its instant. That is the direction where failing open is
416
+ worst.
417
+
418
+ `publishedAt IS NULL` counts as published: a row seeded by `cmsBootstrap`, imported, or
419
+ published while the collection was still `supports: ["drafts"]` has no stamp, and
420
+ `NULL <= '2026-…'` is NULL — so requiring the comparison alone emptied a whole public site
421
+ the moment `scheduling` was added to an existing collection. NULL cannot mean "scheduled for
422
+ later": `publishedAt` is managed (never client-writable) and only ever takes *now* or null,
423
+ while a row awaiting a scheduled publish is `status: 'draft'` with the instant in
424
+ `scheduledAt`.
425
+
426
+ The grant is restricted to **the declared fields, the id, `status`, and (with `scheduling`)
427
+ `publishedAt`**. An entity column that is not in `fields` — an `internalNote`, a
428
+ `reviewerEmail` — stays private even on a published row, so adding one later doesn't quietly
429
+ publish it. `publishedAt` is in because a caller may not `orderBy` a column it cannot read,
430
+ and "newest published first" is the public query: excluding it 403'd anonymous while working
431
+ for an editor. The forward-looking `scheduledAt` / `unpublishAt` stay out — "this comes down
432
+ on Friday" is not public.
433
+
434
+ Your public read then stays an ordinary list — see `publicLectures` in `example/app.ts`.
435
+
436
+ Managed timestamps are minted as ISO-8601 UTC (`2026-08-20T12:00:00.000Z`) in exactly one
437
+ place, because the scope compares against `$now()` **lexicographically**. This is what
438
+ closes the trap the old `publish` field type carried, where `publish` and `datetime` wrote
439
+ different formats into the same TEXT column and sorted against each other as if hours apart.
440
+
441
+ Revisions snapshot the row's state **before** each content write — an edit, and a restore
442
+ itself — so a restore is a plain reversal that replays through the same whitelist rather
443
+ than resurrecting a column the collection no longer owns.
444
+
445
+ The snapshot is taken through the raw path, so **history does not depend on who made the
446
+ edit**: an editor whose read policy withholds a column would otherwise have silently dropped
447
+ it from the snapshot, and every later "restore to before that edit" would restore an
448
+ incomplete row. Reading history is projected the other way — `collectionListRevisions`
449
+ narrows each snapshot to the fields *that* caller may read on the entity, and
450
+ `collectionRestoreRevision` writes back exactly that set. Field-level read policies hold
451
+ through history; what you can see is what you can put back.
452
+ Publish and unpublish write **no** revision: they change no content, so the entry would be
453
+ identical to the edit before it and restoring it would do nothing visible.
454
+
455
+ Ordering is by a monotonic per-row `revision` counter, never a timestamp — a revision is
456
+ written on every edit, and two writes land in the same millisecond often enough that
457
+ "restore the previous version" would otherwise be a coin flip.
458
+
459
+ `collectionDelete` **purges** the row's revisions. A collection PK can be a caller-chosen
460
+ `textId`, so an id can come back; inherited history would let an editor restore a deleted
461
+ row's content over the new one.
462
+
463
+ Ordering is backed by a composite `unique` on `(collection, rowId, revision)`. The
464
+ read-then-increment is serialized by the DO's single writer, but **on the D1 store it is
465
+ not** — `D1Driver.transaction` is a no-op, since D1 has no interactive transactions — so the
466
+ index is what turns a concurrent duplicate into a visible failure instead of a silently
467
+ ambiguous history. For the same reason the delete-and-purge pair is atomic on the DO but not
468
+ on D1.
469
+
144
470
  ## Limitations
145
471
 
146
472
  - **Block `fields` are opaque JSON**, so pramen's row/cell-level ACL and relational queries
@@ -165,4 +491,41 @@ function Page({ slug }: { slug: string }) {
165
491
  matches (so a reschedule, a manual publish/unpublish, or a duplicate delivery makes a
166
492
  stale task a no-op). The `cms:publish` task is not transactional (the interactive
167
493
  `publishPage` is), so a crash mid-task self-heals on the next at-least-once redelivery.
494
+ - **Collections have no trash.** `supports` covers drafts/scheduling/revisions/preview;
495
+ `collectionDelete` is a hard delete and also purges the row's revisions. Soft delete is
496
+ `cms_pages`-only.
497
+ - **Listing revisions is gated by the row's read scope**, so a *deleted* row's history is
498
+ unreachable through `collectionListRevisions` (it is purged on delete anyway).
499
+ - **`drafts` gates VISIBILITY, not content.** A collection is column-mapped: the public
500
+ reads the entity's own columns, so there is nowhere to stage an unpublished *version* of a
501
+ live row. An edit to a published row — and a `collectionRestoreRevision` on one — goes
502
+ live immediately, and `preview` on a published row shows what the public already sees.
503
+ This is the one place collections do NOT reach page parity: `getPage` serves a baked
504
+ revision snapshot, which is what lets a page hold unreviewed edits back. Unpublish first
505
+ if an edit needs review. (Staging content would mean the public read stopped being a query
506
+ over your entity, which is the entire point of a collection.)
507
+ - **`collectionSchedule` patches the takedown, it doesn't reset it.** Omitting `unpublishAt`
508
+ leaves an existing one standing (so moving a publish date doesn't silently revoke a
509
+ scheduled removal); pass `unpublishAt: null` to cancel one deliberately. The new publish
510
+ instant is checked against the takedown *already stored*, not just one sent in the same
511
+ call — a reschedule cannot slide the publish past a pending takedown (which would fire
512
+ first, cancel itself, and leave the row public forever).
513
+ - **A schedule converges, in any drain order.** The tasks are at-least-once and can drain
514
+ arbitrarily late. The publish task therefore resolves to the state the schedule implies at
515
+ drain time: if the takedown instant has also passed by then, the row lands **down**, with
516
+ both tokens spent — it does not publish and discard the takedown. The takedown task
517
+ likewise clears the pending publish token, so a retried or late publish cannot resurrect
518
+ the row.
519
+ - **An interactive publish is different from the scheduled one**: `collectionPublish` clears
520
+ a takedown instant that has already passed and puts the row live, because a person with
521
+ publish rights is saying "live, now" about a takedown that has already been served. A
522
+ *future* takedown always stands.
523
+ - **Scheduling a future publish does not take a live row down.** `collectionSchedule` sets
524
+ `scheduledAt` and leaves `status`/`publishedAt` alone (parity with `schedulePage`), so
525
+ scheduling a *published* row means it stays public until the task re-stamps it. Unpublish
526
+ first if you meant "not live until then". Doing this implicitly would be a takedown the
527
+ editor never asked for, which is why it isn't automatic — but the editor UI should make
528
+ the current state obvious.
529
+ - **A collection's `supports` features are per-row, not per-locale** — there is no
530
+ translation-group equivalent for collections.
168
531
  - **Duplicate slugs surface as 500, not 409** (a framework-wide limitation, not CMS-specific).
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env bun
2
+ // @pramen/cms CLI — ships as the `pramen-cms` bin.
3
+ //
4
+ // pramen-cms help
5
+ // pramen-cms types [--tenant t] [--url u] [--token jwt] [--out path]
6
+ //
7
+ // A separate bin from `pramen` on purpose: @pramen/cms is optional, so the runtime CLI
8
+ // should not carry a command named after it. `pramen` knows about schemas, tokens and
9
+ // scaffolding; this knows about content types. Both mint their dev token with the same
10
+ // `signDevToken` from @pramen/server, so there is one signer, not two.
11
+ //
12
+ // Bun shebang, matching the `pramen` bin: the built dist/ uses extensionless ESM imports.
13
+ import { mkdirSync, writeFileSync } from "node:fs";
14
+ import { dirname, resolve } from "node:path";
15
+ import { signDevToken } from "@pramen/server/dev";
16
+ import { generateBlockTypes } from "./index";
17
+ const argv = process.argv.slice(2);
18
+ const KNOWN_FLAGS = ["url", "tenant", "token", "out"];
19
+ /** Read `--name value` or `--name=value`.
20
+ *
21
+ * Both forms, because `--out=path` silently printed to stdout and exited 0 — a green
22
+ * regenerate-and-diff CI with no file written. And a value is required, because `--out`
23
+ * with an empty $OUT did the same, while `--tenant --out x` set the tenant to "--out". */
24
+ function flag(name) {
25
+ const eq = argv.find((a) => a.startsWith(`--${name}=`));
26
+ if (eq !== undefined) {
27
+ const v = eq.slice(name.length + 3);
28
+ if (v === "")
29
+ fail(`--${name} needs a value`);
30
+ return v;
31
+ }
32
+ const i = argv.indexOf(`--${name}`);
33
+ if (i < 0)
34
+ return undefined;
35
+ const v = argv[i + 1];
36
+ if (v === undefined || v.startsWith("--"))
37
+ fail(`--${name} needs a value`);
38
+ return v;
39
+ }
40
+ /** Reject a misspelled flag rather than silently ignoring it and using the default. */
41
+ function assertKnownFlags() {
42
+ for (const a of argv) {
43
+ if (!a.startsWith("--"))
44
+ continue;
45
+ const name = a.slice(2).split("=")[0];
46
+ if (!KNOWN_FLAGS.includes(name)) {
47
+ fail(`unknown flag --${name} (expected ${KNOWN_FLAGS.map((f) => `--${f}`).join(", ")})`);
48
+ }
49
+ }
50
+ }
51
+ function fail(msg) {
52
+ console.error(`pramen-cms: ${msg}`);
53
+ process.exit(1);
54
+ throw new Error(msg); // unreachable — process.exit is typed as returning
55
+ }
56
+ const HELP = `pramen-cms — CLI for @pramen/cms
57
+
58
+ Usage: pramen-cms <command>
59
+
60
+ help show this help
61
+ types generate TS interfaces for a tenant's block types
62
+ [--tenant t] [--url u] [--token jwt] [--out path]
63
+
64
+ Block types are DATA — rows a webmaster adds with no deploy — so their shape is only
65
+ knowable from a running instance. \`types\` reads them from one and emits an interface per
66
+ slug plus a BlockFieldsBySlug registry. With no --out it prints.`;
67
+ async function typesCmd() {
68
+ assertKnownFlags();
69
+ // Read every flag BEFORE the network call, so a malformed invocation fails on the
70
+ // invocation rather than on whatever the fetch happens to do first.
71
+ const url = flag("url") ?? "http://localhost:8787";
72
+ const tenant = flag("tenant") ?? "main";
73
+ const dest = flag("out");
74
+ const token = flag("token") ?? (await signDevToken({ sub: "cli", roles: ["admin"] }));
75
+ let res;
76
+ try {
77
+ res = await fetch(`${url}/rpc/listBlockTypes`, {
78
+ method: "POST",
79
+ headers: { "content-type": "application/json", "x-pramen-tenant": tenant, authorization: `Bearer ${token}` },
80
+ body: "{}",
81
+ });
82
+ }
83
+ catch (e) {
84
+ // A wrong --url is the common mistake; surface it as a CLI error, not a stack trace.
85
+ fail(`types: cannot reach ${url} (${e instanceof Error ? e.message : String(e)})`);
86
+ }
87
+ const body = (await res.json().catch(() => ({})));
88
+ if (!res.ok || body.ok !== true || !Array.isArray(body.result)) {
89
+ fail(`types: listBlockTypes failed (${body.error ?? res.status})`);
90
+ }
91
+ const rows = body.result;
92
+ // Refuse rather than write an empty module. A --tenant typo routes to a fresh Durable
93
+ // Object whose cms_block_types is legitimately empty, so this is the likely cause — and
94
+ // exiting 0 after clobbering src/cms.gen.ts would sail through a regenerate-and-diff CI.
95
+ if (rows.length === 0) {
96
+ fail(`types: tenant '${tenant}' has no block types — refusing to write an empty module (check --tenant/--url)`);
97
+ }
98
+ let out;
99
+ try {
100
+ out = generateBlockTypes(rows);
101
+ }
102
+ catch (e) {
103
+ // It throws for a slug that is not a distinct valid identifier — a data problem the
104
+ // user must fix in the CMS, so name it rather than print a stack trace.
105
+ fail(`types: ${e instanceof Error ? e.message : String(e)}`);
106
+ }
107
+ if (!dest) {
108
+ process.stdout.write(out); // composes with a pipe, like `pramen schema sql`
109
+ return;
110
+ }
111
+ const path = resolve(process.cwd(), dest);
112
+ try {
113
+ mkdirSync(dirname(path), { recursive: true });
114
+ writeFileSync(path, out);
115
+ }
116
+ catch (e) {
117
+ // Same treatment as the fetch above — an unwritable --out is a CLI error, not a stack.
118
+ fail(`types: cannot write ${dest} (${e instanceof Error ? e.message : String(e)})`);
119
+ }
120
+ console.log(` + ${dest} (${rows.length} block type${rows.length === 1 ? "" : "s"} from tenant '${tenant}')`);
121
+ }
122
+ async function main() {
123
+ switch (argv[0]) {
124
+ case undefined:
125
+ case "help":
126
+ case "-h":
127
+ case "--help":
128
+ console.log(HELP);
129
+ return;
130
+ case "types":
131
+ return typesCmd();
132
+ default:
133
+ console.error(`pramen-cms: unknown command "${argv[0]}"\n`);
134
+ console.log(HELP);
135
+ process.exit(1);
136
+ }
137
+ }
138
+ await main();
package/dist/href.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ /** Strip the characters the WHATWG URL parser ignores before parsing (ASCII tab/CR/LF),
2
+ * then trim. Store and render THIS form, so what was validated is what a browser resolves. */
3
+ export declare function normalizeHref(raw: string): string;
4
+ /** Allow-list for a link href: http(s), mailto, tel, or a relative/anchor path.
5
+ *
6
+ * Whitespace is stripped FIRST, because the URL parser does the same — `/\r\n/evil.example/x`
7
+ * otherwise passes a prefix test and still resolves to `https://evil.example/x`. Then a
8
+ * single leading slash only, whose next character may be neither `/` nor `\` (the parser
9
+ * folds `\` to `/` at path-start for special schemes). The scheme allow-list inherently
10
+ * rejects `javascript:`, `data:` and `vbscript:`.
11
+ *
12
+ * This is the security boundary — the write-path normalizer and BOTH renderers call it —
13
+ * not the UI hint that @podoba/react's `safeLinkUrl` is. */
14
+ export declare function isSafeHref(raw: unknown): boolean;
package/dist/href.js ADDED
@@ -0,0 +1,22 @@
1
+ // Link-href safety. A LEAF module on purpose: `@pramen/cms/react` needs these at runtime,
2
+ // and importing them from `./index` pulled the whole server SDK into every browser bundle
3
+ // that renders rich text (`index.ts` evaluates `Entity(...)` calls at module scope for
4
+ // `cmsSchema`, which no bundler can tree-shake — measured 785 B -> 56 kB).
5
+ /** Strip the characters the WHATWG URL parser ignores before parsing (ASCII tab/CR/LF),
6
+ * then trim. Store and render THIS form, so what was validated is what a browser resolves. */
7
+ export function normalizeHref(raw) {
8
+ return raw.replace(/[\t\n\r]/g, "").trim();
9
+ }
10
+ /** Allow-list for a link href: http(s), mailto, tel, or a relative/anchor path.
11
+ *
12
+ * Whitespace is stripped FIRST, because the URL parser does the same — `/\r\n/evil.example/x`
13
+ * otherwise passes a prefix test and still resolves to `https://evil.example/x`. Then a
14
+ * single leading slash only, whose next character may be neither `/` nor `\` (the parser
15
+ * folds `\` to `/` at path-start for special schemes). The scheme allow-list inherently
16
+ * rejects `javascript:`, `data:` and `vbscript:`.
17
+ *
18
+ * This is the security boundary — the write-path normalizer and BOTH renderers call it —
19
+ * not the UI hint that @podoba/react's `safeLinkUrl` is. */
20
+ export function isSafeHref(raw) {
21
+ return typeof raw === "string" && /^(https?:\/\/|mailto:|tel:|\/(?![/\\])|#)/i.test(normalizeHref(raw));
22
+ }