@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 +364 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +138 -0
- package/dist/href.d.ts +14 -0
- package/dist/href.js +22 -0
- package/dist/index.d.ts +542 -33
- package/dist/index.js +1729 -84
- package/dist/react.d.ts +15 -2
- package/dist/react.js +96 -1
- package/package.json +8 -3
- package/src/cli.ts +148 -0
- package/src/href.ts +24 -0
- package/src/index.ts +1990 -110
- package/src/react.ts +132 -3
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:
|
|
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
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
|
+
}
|