foldkit-cms
v0.7.11
Published
What a CMS adds to a Foldkit Plus domain: audience, time, and address. A draft is an unsent form kept beside the content row; state is derived, never stored. Entry history and transitions ship as view helpers.
Maintainers
Readme
foldkit-cms
What a CMS adds to a domain that is already declared. The domain is a
foldkit-entity Entity, editing it is a
foldkit-form form, and the screens are
foldkit-crud's. None of that is CMS-specific. Three things
are: audience (a visitor sees what is published, an author sees everything),
time (drafts, revisions, a schedule), and address (a slug).
The client contract and editor live here. The server adapter persists drafts and enforces the audience boundary. The CMS example runs both as a transcript or in a browser.
What it owns
| Fact | Owner |
| --- | --- |
| Published content | your tables, read as any Entity is |
| What an author has entered and not published | a draft, kept beside the row, never in it |
| Which state a piece of content is in | nobody stores it: Cms.state derives it |
| Which transitions that state offers | Cms.offers, before anyone asks who is asking |
| Who may make a transition | your allow, asked by the server |
| The draft being typed | foldkit-form, in your Model, inside the editor's slice |
| How far the saves have got | Cms.editor, in that slice |
| The draft's updatedAt, the entry's revision | Remote's store: read, never copied |
It reads no database.
Mental model
A draft is an unsent form. The content row holds what is published. A draft
is what an author has entered and not sent: the input of the operation that
would publish it, saved. So publishing is your own mutation, run with the
draft's value; a visitor's read cannot reach unfinished work, because it never
touches drafts; a revision is the same value, kept; and a half-filled form that
does not validate can be saved, which a row with a not null title cannot hold.
Entry ── Draft ── publish ──> your own mutation ──> content row ──> a visitor's read
└── Revision <── appended ──┘An Entry is a piece of content from its first keystroke to its archive. It exists before the row does, and after the row is hidden.
Run the local example
From a checkout, run pnpm install, pnpm build, then
pnpm --filter foldkit-example-cms demo. This is the reproducible path for the
workspace version; use the package install below for a published version.
Install
pnpm add effect foldkit foldkit-entity foldkit-form foldkit-crud foldkit-remote foldkit-cmsExample
This integration extends an existing Entity, form, and pair of Remote mutations.
Blog.Post, PostForm, CreatePostMutation, and UpdatePostMutation come from
your domain module; the example domain shows
those definitions together. Start with Form if you have not
yet defined the operation's input.
import { Cms } from 'foldkit-cms'
// Which members play a CMS part is a fact about the Entity: metadata, by a pipe step.
const Post = Blog.Post.pipe(
Cms.roles({ label: 'title', slug: 'slug', published: 'publishedAt' }),
)
// How a type of content is authored is a fact about the application.
const Posts = Cms.content('posts', {
entity: Post,
form: PostForm, // an ordinary form: Form.make('PostForm', Entity.input(Post, PostInput))
publish: { create: CreatePostMutation, update: UpdatePostMutation },
words: { one: 'Post', many: 'Posts' },
// Optional: how a value would look in the store, for in-app preview.
preview: (value, id) => [{ entity: 'Post', id, values: value }],
})Postis the same Entity asBlog.Post; only its metadata changed. Register and bind the annotated one.- A role names a member that can play it.
slugon a number, orpublishedon a field that admits nonull, is a type error and throws. publish.create's input is the form's value and its output names the row;publish.update's input is that value and the row's id. A mutation of another shape is a type error. They are your own mutations: what publishing a post does stays your code.- A capability is declared, never implied. No
slugrole, no slug handling. Nopublishedrole, no unpublish, and every row is visible.
Cms.rolesOf(Post) reads the roles back as Fields. Anything may: the server's
audience policy, a bySlug query, a sitemap this package knows nothing of.
The lifecycle
State is derived from facts and a clock. Nothing stores a status, so it cannot disagree with what is true.
Cms.state({ archivedAt: null, row: 'visible', draft: { scheduledFor, scheduleError: null } }, now)
// { _tag: 'Changed', schedule: { at, overdue: false, error: null } }| State | Facts |
| --- | --- |
| New | no row yet |
| Published | a row a visitor sees, and no draft |
| Changed | a row a visitor sees, and unpublished work beside it |
| Unpublished | a row hidden by its published role |
| Archived | the entry was archived; nothing else applies until it is restored |
A schedule rides beside a state that has a draft. A scheduled publish that
did not happen reads overdue, with the reason, because that is true; it does
not read Published.
Cms.offers(facts, now, content) is the transitions an entry offers now:
save, discard, publish, schedule, unschedule, unpublish, archive,
unarchive, restore. It is what a view enables and what a server checks
before it asks who is asking. unpublish is offered only to a content type with
a published role.
The editor
Cms.editor is the authoring screen's state: a form, the entry it belongs to,
and the draft that keeps what the author has entered. It is a Bundle, placed
like any other; the view is the form's own.
const Editor = Cms.editor('PostEditor', { content: Posts })
const Base = Bundle.compose({ remote: Remote.Model }).pipe(
Bundle.withMessages(Remote.messages),
Bundle.withChild('editor', Editor.bundle), // its Model and its Messages, in yours
)
// App = Surface.application(Base), and Data = Remote.make({ model: App.model.remote, ... })
const PostEditor = Editor.at({ data: Data, model: App.model.editor })
const Page = Base.pipe(
Bundle.withServices<RemoteClient>(),
Bundle.configure('editor', { onOut: PostEditor.onOut }), // made from Data, so given now
)
const Placed = Page.children.editor
const update = PostEditor.after(Page.placements.update(yourUpdate))
Data.subscriptions({ ...yourSurfaces, ...PostEditor.actives })
Placed.helpers.open(entryId) // resume the draft, else show what is published
Placed.helpers.create(Cms.newEntryId()) // something new; its first save makes the entry
Editor.Message.PublishAsked() // also: ScheduleAsked({ at }), UnscheduleAsked, DiscardAsked,
// RestoreAsked({ revision }), UnpublishAsked, ArchiveAsked, UnarchiveAsked, ReloadAsked, OverwriteAsked
PostEditor.status(model) // Opened | Editing | Saving | Saved | Conflict | Publishing | Published | ...
PostEditor.state(model) // Option: the entry's lifecycle state, as the server last derived it
PostEditor.may(model, 'publish') // whether the server's `allow` lets this principal ask it; false until read
PostEditor.pageId(model) // Option: the row's id, or the entry's until there is a row: what a preview shows under
PostEditor.storedEntry(model) // Option: the entry the server knows, none until something new is first saved- Saving is automatic and is not publishing. Each edit starts a rest (
rest, one second by default), and the edit that is still the last one when its rest ends saves the form as it stands, valid or not. There is no Save button to forget, and a validation error never costs an author their work. - A publish the form's own checks stop is
Incomplete, not whatever the last save said: nothing was sent, the failing fields say why, and the next edit clears it. One waiting for a check still running (an address being looked up) isPublishinguntil the check answers. - What counts as an edit is the form's answer, not a Message tag. The editor
asks
form.authoredChanged(before, after), so a blur, a refusal, or a repeated value starts no rest, while a control the editor has never heard of — a stateful or Bundle-backed one — autosaves exactly like a text field. - A form control backed by a Bundle keeps running inside the editor. Its
Subscriptions and Resources (see
Input.bundle) run while an entry is open and stop when the editor closes. A runtime that runs the editor provides their Resources, as for any Bundle that has some. Editingmeans there are edits that are not saved yet, andOpenedmeans the form is as it was found. A draft the editor filled the form from is on the server already, so it readsSaved.- A draft is listed by what its author called it, valid or not, and
untitleduntil they have. Publish pressed twice asks once. - Publishing submits the form, so its rules and checks decide, and an invalid form publishes nothing and says why in place. What is published is the saved draft, so a publish saves first when the last edit has not.
- Scheduling is a publish promised for later:
ScheduleAsked({ at })submits and saves the form now, and the server keeps the promise. The entry'sstatesays for when, and whether it happened. The form's own submit is always a publish now. - Leaving within the rest.
open,createandclosedrop what is in the form. RunPostEditor.flush(model)first, as the example does: it saves what has not been saved, now, and does nothing when there is nothing to save. - A draft never fails to open. The saved Model is tried first, guarded by the
form's name and
version; then the saved values, key by key, keeping what the form still accepts; then what is published.PostEditor.resumed(model)says which:Model(shown with nothing in flight: a check that was running when it was saved will never answer),Values,Published,Blank, orLost, which is worth telling the author. Bumpversionwhen you change the form incompatibly. - A second author's save is a
Conflict, with the text still in the form.ReloadAskedshows the server's copy;OverwriteAskedsaves over it, based on it. Merging is not attempted. - A taken address lands on the address. A publish refused with
Cms.refusal.field(slugKey, reason)marks the slug's own field invalid, keeping what was typed, and clears when the author edits it. Nothing to wire: the editor reads it withData.refusal. - Restoring a revision replaces what is in the form with that value, as a draft. It publishes nothing.
- Preview is the application's own views. Give the content type
preview, the operations an optimistic publish of a value would show, andPreviewShownlays what is in the form over Remote's store untilPreviewHidden: every Selection and view draws it, edit by edit, and nothing is sent. Only what decodes is shown. It is lifted when the editor closes, reloads or discards. Nopreview, no preview:PostEditor.canPreviewsays which. - Discarding shows what is published again; something never published has nothing left, and the editor closes.
- What the server holds is read from Remote and never copied: a save is based on
the draft's
updatedAtin the store, a publish on the entry'srevision. The server answers each operation with patches, so nothing is refetched to know them. createtakes the id so thatupdatestays pure: make it in a Command or an event handler.
Kinds, and how they are drawn
Two controls and two displays, made with Input.kind and Display.kind exactly
as you make your own. There is no CMS view package: a kind and its renderer are
all a view needs.
const PostForm = Form.make('PostForm', Entity.input(Post, PostInput), {
inputs: {
slug: Cms.slug('title', { prefix: '/blog/' }), // follows the title until the author writes it
goesLiveAt: Cms.dateTime(), // a datetime-local input, submitted as an ISO string
},
})
FormView.define(PostForm, { renderers: Cms.controlRenderers() })
EntryTable({ page, renderers: Cms.displayRenderers() }, h)| Kind | What it is |
| --- | --- |
| Cms.Input.Slug | Text shown after the address it completes. Cms.slug(from, { prefix?, through? }) makes one that follows from through Cms.slugify. A form filled with a published slug does not follow: an address must not move because its title did. Drawn as the prefix in FieldSlots.affix and the input in text, together in group. |
| Cms.Input.DateTime | A moment. Text that is none is Invalid, not submitted. |
| Cms.Display.State | An entry's state as words: Changed, scheduled, New, overdue. of({ words }) takes yours, text with blanks as every words object is: each state, scheduled, overdue, and withSchedule ('{state}, {schedule}'). Its renderer is a span with data-cms-state and data-cms-schedule to style. |
| Cms.Display.Moment | A time. of({ now }) reads relative to that clock (3 days ago), and you decide how often it moves; without one it is the date and time. Its renderer is a time. |
Cms.Entities already say how they are shown: an entry's state is a State,
and its times are Moments, so a Crud.list over Cms.Entries needs only the
renderers.
Entry-level views live beside the kinds: Cms.stateBadge for the state
(written in Cms.stateAttribute, data-cms-state, as a list's state cell
is, so one tone rule styles both),
Cms.revisionsOf for the open entry's published revisions, Cms.historyCard
for the timeline, Cms.moreCard for the rest of what can happen to it. They
draw on your own slot builders, so you keep your slots and styles; who
published and the archive button's icon are yours to name:
Cms.historyCard(slots, h, Cms.revisionsOf(model, history), {
state,
restore: revision => Message.RestoreAsked({ revision }),
authorName: name => directoryName(name),
})Entities and operations
Cms.Entities is Entry, Draft and Revision, related: an entry has one
optional draft and many revisions. They are ordinary Entities. Register them
with Remote beside your own, and list them with Crud.list; an entry's
state is a derived member the server supplies with its clock.
Cms.Operations is what an author may ask, as Remote mutations: SaveDraft,
DiscardDraft, Publish, Unpublish, Schedule, Unschedule, Archive,
Unarchive, Restore. They are the same for every content type. SaveDraft
and Publish each carry what they were based on, so a second author's save or
publish is a conflict and not an overwrite. Cms.operations is the list, for
Remote.make({ mutations }).
An entry's id is EntryId, branded, so an entry is not opened with a post's id.
The client names something new (Cms.newEntryId()), and the first save of an id
nobody has makes the entry, so an editor need not wait to learn what it edits.
API
| Call | Meaning |
| --- | --- |
| Cms.roles({ label?, slug?, published? }) | Pipe step: the members of an Entity that play a CMS part. |
| Cms.rolesOf(entity) | Those roles, as Fields; undefined for a part nobody named. |
| Cms.content(name, { entity, form, publish, words, preview? }) | A type of content: its Entity, form, publish operations, and name. |
| Cms.editor(name, { content, rest?, version?, untitled? }) | The authoring editor: bundle, Message, and at({ data, model }). |
| Cms.newEntryId() | An id for something new. |
| Cms.editorView(view) | A form's Submodel view as its editor's, for Bundle.withView. |
| Cms.slug(from, options?), Cms.dateTime(), Cms.slugify(text) | Controls for a form's inputs. |
| Cms.Input.{Slug, DateTime}, Cms.Display.{State, Moment} | The kinds: .of(data), .is(x). |
| Cms.controlRenderers(), Cms.displayRenderers() | Their renderers, to spread beside the mixins' own. |
| Cms.state(facts, now) | The state of an entry, with its schedule. |
| Cms.offers(facts, now, content) | The transitions it offers now. |
| Cms.Entities, Cms.Operations, Cms.operations | The CMS's own Entities and mutations. |
| Cms.Entries | The worklist query: one content type's entries, by label, archived or not. |
| Cms.bySlug(content) | The query <name>BySlug: the content at an address, a connection of one or none. Throws with no slug role. |
| Cms.refusal | What a server refuses SaveDraft and Publish with: field(key, reason) for one key of the form, conflict for work saved or published since. Both operations declare it as their Refusal. |
End-to-end example
examples/cms takes a post from its first keystroke to being
taken off show, from a writer's, an editor's and a visitor's chair, over SQLite,
with its transcript pinned by a test.
Limits
- A revision list is a
Crud.listyou declare overCms.Entities.Revision. - Preview is for the author, in their own session. A link someone else can open is an audience, and is not built yet.
- The editor has no view of its own: render the form with
foldkit-mixins-form, and the status and buttons yourself. - An address is checked while typing by
Cms.addressFree('posts'), added with theForm.checksstep. It is advice, not the rule: two authors can both be told an address is free and both publish, and the server refuses the second on the same key. Put a unique index on the column. - One working draft per entry, not one per author.
- Media, rich text, localization, and review states beyond "who may publish" are not built yet.
