@pramen/cms 0.0.14

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 ADDED
@@ -0,0 +1,168 @@
1
+ # @pramen/cms
2
+
3
+ A **block / page builder** for pramen — Drupal-Paragraphs-style typed content blocks
4
+ arranged in named regions, reusable blocks, scheduled publishing — built **entirely from
5
+ pramen primitives**. It is an ordinary app fragment (schema + handlers + ACL + tasks), not
6
+ a new runtime. Inspired by [WollyCMS](https://github.com/wollycms/wollycms) / Drupal
7
+ Paragraphs / Storyblok.
8
+
9
+ > **Status: spike → production (in progress).** Proven end-to-end against a real Durable
10
+ > Object by `test/suites/cms.ts`. Done: **media library**, **i18n**, **editorial workflow +
11
+ > audit**, **SEO + sitemap**, **typed blocks**, and a **visual editor** (`@pramen/cms-editor`,
12
+ > a standalone React SPA). Remaining: integration + QA against a real project. See *Limitations*.
13
+ >
14
+ > **Draft vs. publish validation:** editor-facing writes (addBlock/updateBlock/createPage)
15
+ > validate field *types* but treat `required` as advisory — a DRAFT block may be incomplete
16
+ > and filled in later. (Enforcing required *at publish* is a small follow-up.)
17
+
18
+ ## Typed blocks (hybrid)
19
+
20
+ Block types are data-driven (JSON schemas, no deploy to add one), but developers can get
21
+ compile-time field typing two ways:
22
+
23
+ - **Hand-authored, no build step:** `defineBlockType("hero", [...] as const)` +
24
+ `BlockFieldsOf<typeof hero>` infers the `fields` shape (media → `ResolvedMedia`, repeater →
25
+ array, group → nested), exactly like `typeof app.handlers` types the RPC client. Type a
26
+ component with `TypedBlockComponent<typeof hero>` (from `@pramen/cms/react`). Proven by
27
+ `example/cms-inference-check.ts`.
28
+ - **Codegen from DB-stored types:** `generateBlockTypes(blockTypes)` emits a `.ts` module of
29
+ per-slug field interfaces + a `BlockFieldsBySlug` registry from `cms_block_types` rows —
30
+ for webmaster-created types. (A `pramen cms codegen` CLI that fetches the rows over HTTP and
31
+ writes the file is the remaining thin wrapper.)
32
+
33
+ ## SEO & sitemap
34
+
35
+ - Per-page SEO on `cms_pages`: `metaTitle`, `metaDescription`, `canonicalUrl`, `robots`,
36
+ `ogTitle`, `ogDescription`, `ogImage` (a media id, resolved to a URL), `structuredData`
37
+ (JSON-LD). Set via `updatePageSeo({ pageId, … })`; exposed as `page.seo` on the content API.
38
+ - `listPublishedPages` (public) returns published `{ slug, locale, updatedAt }` for sitemaps.
39
+ - `cmsRoutes({ origin?, pageUrl? })` returns turnkey **`GET /sitemap.xml`** + **`/robots.txt`**
40
+ routes — spread into `app.routes`. Helpers `sitemapXml(entries, opts)` / `robotsTxt(opts)`
41
+ are exported if you want to build them yourself. hreflang alternates ride the content API's
42
+ `page.translations`.
43
+
44
+ ## Editorial workflow & audit
45
+
46
+ - **States:** `draft → review → published`, plus `rejected` and `archived`. Handlers:
47
+ `submitForReview` (editor), `approve`/`reject` (reviewer-gated), `publishPage` (direct
48
+ publish), `unpublishPage`, `schedulePage`. Each transition is guarded (e.g. you can only
49
+ approve a page that is in review).
50
+ - **RBAC:** `submitForReview` is gated to `editorRoles`; `approve`/`reject`/`publishPage`/
51
+ `schedulePage` to `reviewerRoles` (default `["reviewer","admin"]`) — so an editor **can't
52
+ bypass review** by publishing directly. Configure via `createCmsHandlers({ editorRoles, reviewerRoles })`.
53
+ - **Audit trail:** every transition writes a `cms_audit` row (`action`, from/to status,
54
+ `actor` = `identity.userId`, note) synchronously in the same transaction. `listPageAudit({ pageId })`
55
+ returns it. Revisions also record the publishing `actor`.
56
+
57
+ ## i18n / multi-locale
58
+
59
+ - A page has a `locale` and a `translationGroupId` (auto-minted; shared across a page's
60
+ translations). Slugs are unique **per locale** (`/en/about` + `/cs/about` coexist).
61
+ - `createTranslation({ pageId, locale, title?, slug? })` makes a new page in another locale
62
+ sharing the group; `listTranslations({ pageId })` lists all locales of a page;
63
+ `listLocales()` returns the distinct locales.
64
+ - `getPage({ slug, locale? })` is locale-aware (`locale` defaults to the configured default).
65
+ The content API's `page.translations` carries the published sibling locales (computed live,
66
+ so a later-published translation shows up) for **hreflang** alternates.
67
+
68
+ ## Media library
69
+
70
+ - **Upload:** `signMediaUpload({ contentType, filename? })` mints a signed PUT url (keyed under
71
+ the tenant's `media/` prefix); the client PUTs the bytes, then `createMedia({ ref, alt? })`
72
+ confirms the blob is in R2 and persists a `cms_media` row. `listMedia`/`getMedia`/`deleteMedia`
73
+ (deleteMedia also removes the R2 blob) round it out. Editor-gated.
74
+ - **Reference from a block:** a `"media"` field stores a `cms_media` id. At assemble/publish time
75
+ the id is resolved (recursively, through group/repeater nesting) to a `ResolvedMedia`
76
+ `{ id, key, url, alt, contentType, filename }` in the snapshot — so the content API returns a
77
+ servable URL, never a bare id.
78
+ - **Serving + transforms:** published media is served by the Worker's **public** `GET
79
+ /media/<tenant>/media/<key>` route (immutable-cached, `nosniff`; restricted to `media/`-prefixed
80
+ keys so it can't leak signed-private objects). Put **Cloudflare Image Resizing** in front for
81
+ on-the-fly resize/format — build URLs with `imageUrl(key, { width, format, origin })`
82
+ (`/cdn-cgi/image/…`). Image Resizing is a **zone setting** (enable it on the Cloudflare zone),
83
+ not a binding.
84
+
85
+ ## Model
86
+
87
+ | Concept | Table | Notes |
88
+ |---|---|---|
89
+ | Block **type** (schema) | `cms_block_types` | slug + a recursive `fieldsSchema` (JSON). Data-driven — add a type with no deploy. |
90
+ | Block **instance** | `cms_blocks` | content matching a type's field schema; optionally `isReusable`. |
91
+ | **Content type** | `cms_content_types` | declares a page's `regions` (each with an `allowedTypes` allow-list) + `defaultBlocks`. |
92
+ | **Page** | `cms_pages` | slug, status (draft/published/archived), scheduling, SEO, i18n locale. |
93
+ | **Placement** | `cms_page_blocks` | puts a block into a page's region at a position; `isShared` + per-placement `overrides`. |
94
+ | **Revision** | `cms_page_revisions` | a fully-assembled JSON snapshot written at publish time (the public API serves this). |
95
+ | **Media** | `cms_media` | a `fileRef` column → R2; bytes go through `ctx.files`, blocks reference a media id. |
96
+
97
+ ## Which pramen primitive does the work
98
+
99
+ Nothing here is bespoke infrastructure — it composes what pramen already ships:
100
+
101
+ - `t.json()` — block/page field payloads, field schemas, overrides, snapshots
102
+ - `t.fileRef()` + R2 + `ctx.files` — media
103
+ - relations (`belongsTo`/`hasMany`) — page ↔ placement ↔ block ↔ type traversal
104
+ - the **ACL** — editor RBAC (`cmsPolicies().editor`) and public read of published pages only (`cmsPolicies().public`)
105
+ - `ctx.tasks` (the transactional outbox) — `schedulePage` enqueues a delayed publish/unpublish
106
+ - live queries (via `@pramen/react`) — a live-updating page/preview for free (single-writer DO)
107
+
108
+ ## Usage
109
+
110
+ ```ts
111
+ import { defineSchema, createApp, role } from "@pramen/server";
112
+ import { cmsSchema, cmsHandlers, cmsPolicies, cmsTasks } from "@pramen/cms";
113
+
114
+ const schema = defineSchema({ ...cmsSchema /*, ...yourEntities */ });
115
+ const handlers = { ...cmsHandlers /*, ...yourHandlers */ };
116
+ const acl = [
117
+ role("anonymous", [...cmsPolicies().public]),
118
+ role("editor", [...cmsPolicies().editor]),
119
+ ];
120
+ export const app = { schema, handlers, acl, tasks: { ...cmsTasks } };
121
+ ```
122
+
123
+ Editor flow: `createBlockType` → `createContentType` → `createPage` → `addBlock` →
124
+ `publishPage`. Public: `getPage({ slug })` returns the published snapshot; editors pass
125
+ `{ slug, preview: true }` to assemble the live draft.
126
+
127
+ ### Rendering (headless)
128
+
129
+ The backend never dictates markup. `@pramen/cms/react` maps a block's `block_type` slug to
130
+ a component you provide:
131
+
132
+ ```tsx
133
+ import { RegionRenderer } from "@pramen/cms/react";
134
+ import { useLiveQuery } from "@pramen/react";
135
+
136
+ const components = { hero: Hero, rich_text: RichText };
137
+ function Page({ slug }: { slug: string }) {
138
+ const { data } = useLiveQuery(client, "getPage", { slug });
139
+ if (!data) return null;
140
+ return <RegionRenderer regions={data.regions} name="content" components={components} />;
141
+ }
142
+ ```
143
+
144
+ ## Limitations
145
+
146
+ - **Block `fields` are opaque JSON**, so pramen's row/cell-level ACL and relational queries
147
+ don't reach inside a block — access is gated at the page/block level. Fine for content;
148
+ note it.
149
+ - **No typed inference for block field data** yet (types are runtime `FieldDefinition[]`). A
150
+ hybrid typed-block layer (inference + codegen) is a planned phase.
151
+ - **Media orphan sweeping is manual** — `deleteMedia` removes a blob explicitly, but media no
152
+ longer referenced by any block isn't auto-collected (refs live inside opaque block JSON).
153
+ Deleting media still used on a published page breaks that page's image until re-publish.
154
+ - **Per-locale slug uniqueness is enforced in handler code**, not the schema — pramen's
155
+ `unique()` is single-column only, so `(slug, locale)` can't be a schema constraint. A
156
+ concurrent double-insert of the same `(slug, locale)` could in theory slip past the
157
+ SELECT-then-insert guard (the DO's single-writer makes this effectively safe per tenant).
158
+ - **No draft autosave / optimistic-locking / presence** — the DO's single-writer + live
159
+ queries make these straightforward to add, but they aren't here yet.
160
+ - **Field validation is structural**, not exhaustive (no cross-field rules, no referential
161
+ checks on `media` ids, unknown keys pass through, `default` values aren't applied).
162
+ - **Scheduled publish uses fire-time intent tokens, not cancellable jobs.** Outbox tasks
163
+ can't be recalled, so `schedulePage` stores the scheduled times on the page and each task
164
+ carries the token it was enqueued for; at fire time it acts only if its token still
165
+ matches (so a reschedule, a manual publish/unpublish, or a duplicate delivery makes a
166
+ stale task a no-op). The `cms:publish` task is not transactional (the interactive
167
+ `publishPage` is), so a crash mid-task self-heals on the next at-least-once redelivery.
168
+ - **Duplicate slugs surface as 500, not 409** (a framework-wide limitation, not CMS-specific).
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@pramen/cms",
3
+ "version": "0.0.14",
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
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/netvarec/pramen.git",
9
+ "directory": "packages/cms"
10
+ },
11
+ "homepage": "https://github.com/netvarec/pramen#readme",
12
+ "bugs": "https://github.com/netvarec/pramen/issues",
13
+ "type": "module",
14
+ "sideEffects": false,
15
+ "exports": {
16
+ ".": {
17
+ "development": "./src/index.ts",
18
+ "bun": "./src/index.ts",
19
+ "workerd": "./src/index.ts",
20
+ "types": "./dist/index.d.ts",
21
+ "default": "./dist/index.js"
22
+ },
23
+ "./react": {
24
+ "development": "./src/react.ts",
25
+ "bun": "./src/react.ts",
26
+ "workerd": "./src/react.ts",
27
+ "types": "./dist/react.d.ts",
28
+ "default": "./dist/react.js"
29
+ }
30
+ },
31
+ "main": "./dist/index.js",
32
+ "types": "./dist/index.d.ts",
33
+ "files": ["dist", "src"],
34
+ "scripts": {
35
+ "build": "rm -rf dist && tsc -p tsconfig.build.json"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "dependencies": {
41
+ "@pramen/server": "workspace:*"
42
+ },
43
+ "peerDependencies": {
44
+ "react": ">=18"
45
+ },
46
+ "peerDependenciesMeta": {
47
+ "react": {
48
+ "optional": true
49
+ }
50
+ }
51
+ }