@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 +168 -0
- package/package.json +51 -0
- package/src/index.ts +1589 -0
- package/src/react.ts +76 -0
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
|
+
}
|