Content
A page or a post is what a tenant’s public site is made of: a name it is
reached by, a title, a body in Markdown and a status that decides whether the
site serves it. The module promises the lifecycle — draft, published, archived
— and the events that announce it: published means published at a time, a page
is published once, and a reader with no session gets the rendered page only
while it is published, with raw HTML left out and the rest sanitised on every
read. There are no versions and no stored HTML; both are decisions and not
omissions. It takes no dependencies at all: content belongs to a tenant by
carrying its id, which row-level security matches on, and the author comes off
the request’s own context. The module is
modules/content; this page states nothing that
tree does not.
The map
features/content in the recordWhat it promises
Entity |
|
Events |
|
Permissions |
|
Service |
|
Routes and screens
| Method | Path | Does | Authorization | Publishes |
|---|---|---|---|---|
|
|
List contents |
|
— |
|
|
Create a content |
|
|
|
|
Read a content |
|
— |
|
|
Update a content |
|
|
|
|
Delete a content |
|
|
|
|
Archive content |
|
|
|
|
Publish content |
|
|
|
|
Unpublish content |
|
|
|
|
Read published content by slug |
public |
— |
-
GET/POST /api/v1/content/contentsandGET/PATCH/DELETE /api/v1/content/contents/{id}— the five routesrest.Spec.Mountregisters, with soft delete; a create or a patch that namesstatus,publishedAtorauthoris refused with the route that owns the field. -
POST /api/v1/content/contents/{id}/publish— serves it to anybody and records when; what is already published changes nothing and the publication time does not move, because a page has one; archived content is refused with a conflict, because it is taken out of the archive first. The three commands are ininternal/handler.go. -
POST /api/v1/content/contents/{id}/unpublish— takes it back to a draft, from published or from archived, and clears the publication time with it; a draft again changes nothing. -
POST /api/v1/content/contents/{id}/archive— keeps it and serves it to nobody; archiving twice changes nothing. -
GET /api/v1/content/public/{slug}— the one read a reader with no session makes,httpx.Public(), and a sibling of the collection rather than a route under it so that everything under/api/v1/content/contentsneedscontent:read. It answers withPage— the slug, the title, the kind, the body rendered from Markdown and sanitised, andpublishedAt— and not the entity: the author, the timestamps and the status are not a reader’s business. A draft, an archived page and a slug nobody has used are the same404; a slug outsideSlugify’s grammar is a `422at the door; a host that resolves to no tenant is a404. The response carries a weakETagfrom the row’supdated_at, and anIf-None-Matchthat names it is answered304with no body, so a reader who already has the page does not pay for rendering it again. The renderer isinternal/render.go: goldmark with GFM and raw HTML left out, a protocol-relative link rewritten to say which origin it goes to, and bluemonday’s user-generated-content policy over the result. -
Screens: the generated list, detail and forms under
/admin/content/contents, with the body as a textarea and hidden from the list, and the navigation entry "Content" for anyone withcontent:read(module.go). Hand-written pages: none.
Authorization
| Authorization | Who passes | Routes |
|---|---|---|
|
a member of the tenant |
|
|
a member of the tenant |
|
none: public |
anyone |
|
Events, jobs and subscriptions
| Event | Published by | Handled by |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
-
Publishes the six events above;
HookEventsis nil, because nothing publishes from a hook — the three commands declare the lifecycle events they publish, so the boot gate can check them against the manifest. -
Jobs: none —
Jobs: nilinmodule.go: nothing about a page happens because time passed. -
Subscriptions: none —
Subscriptions: nil: the module has no opinion about anybody else’s events, and a site that reads it takes the read route.
What it needs
|
Interface |
Supplied by |
none |
|
|
Configuration: none.
Who uses it
-
No module takes its contracts; the tests and the applications do. The site module names a
HomeSlug, the slug of the content served at/, and validates the same slug grammar, butmodules/site/contracts/site.gosays it does not import the module that serves it, and nothing else in the workspace reaches the public read. -
The admin and the native shell reach it through
httpx.Resource, not throughcontracts/.
Verification
-
go test ./modules/content/…— the fake and the real service pass one conformance suite: publishing records when and a second publish does not move it, archived content is not published from the archive, unpublishing clears the time and takes content out of the archive, only published content is served publicly, a slug is stored and looked up the same way, and an unknown id is not found. The internal tests (internal/service_test.go) prove the commands publish in the caller’s transaction and say nothing when repeated, that the database keepspublishedand its time together against anUPDATEbehind the module’s back, that a slug is unique within the tenant and released by a delete, that the author is the caller and not the body, and thatRenderstrips scripts, event handlers andjavascript:links. The module tests (module_test.go) prove over HTTP that aPATCHrefuses the three fields a route owns and names each, that the public route serves published content to anybody and carries neitherbody,author,statusnor a script, that the page is conditional on its tag and an edit moves the tag, that a body has a ceiling and a title is counted in characters, that a slug survives its language, and that a protocol-relative link is rewritten. -
No browser journey:
make e2eexercises tasks only (e2e/admin-tasks.spec.ts). -
Not proven: the generated content screens in a browser; a public site reading the public route, which nothing in the workspace does yet; the events on a live NATS fleet.