Pages
Pages
The blog blueprint creates a page type with a slug, a parent page, a navigation flag and an
order. This module adds what that shape cannot say on its own: a page may not be its own ancestor,
may not be nested too deep, and a top-level page may not take a reserved slug. It also answers the
two questions a renderer needs and cannot compute from a list: the site menu, and which page lives
at /about/team.
Enable it
dotnet add package BarakoCMS.Pages
builder.Services.AddBarakoCMS(builder.Configuration);
var app = builder.Build();
app.UseBarakoCMS();
The package reference plus a restart is the install. AddBarakoCMS finds every module in the
application's dependency context, and BarakoCMS:Modules:Enabled decides which of them run
(BarakoCMS__Modules__Enabled=Pages). To name it by hand instead, put
modules.Add(new BarakoCMS.Pages.PagesModule()) in the AddBarakoCMS callback. See MODULES.md in
the repository.
The module seeds nothing. It stores one row per tenant that sets its own page type (see
Per tenant). Create the page type first, for example by applying the blog blueprint.
Configuration
Under Modules:Pages. The defaults match the blog blueprint's page type.
| Key | Default | Meaning |
|---|---|---|
ContentType |
page |
The content type holding the pages |
ParentField |
ParentPage |
Reference field naming the parent; empty means a top-level page |
ShowInNavigationField |
ShowInNavigation |
Boolean field that puts a page in the menu |
OrderField |
NavigationOrder |
Integer field ordering siblings; unset sorts last |
TitleField |
Title |
Field used as the title in the menu, breadcrumbs and tree |
MaxDepth |
8 |
Most ancestors a page may have |
ReservedSlugs |
none | Slugs a top-level page may not take, case-insensitive |
HomeSlug |
home |
The top-level page served at / |
MaxPages |
1000 |
Most pages one request reads; navigation and the tree say truncated when there were more |
The slug field is the one public delivery already uses: a field of type slug, else one named slug.
Per tenant
Modules:Pages is the deployment's default. A tenant can name its own type and fields, so two
tenants on one deployment can each have their own tree:
| Method and path | Purpose | Access |
|---|---|---|
GET /api/pages/settings |
What the tenant set, and what that comes to over Modules:Pages |
manage_content_types |
PUT /api/pages/settings |
Replaces what the tenant set | manage_content_types |
The body takes contentType, parentField, showInNavigationField, orderField, titleField,
maxDepth (1 to 64), reservedSlugs and homeSlug. Each one left out, or null, takes the
deployment's value, so {} puts the tenant back on Modules:Pages. MaxPages stays the
operator's. A type the request names has to exist, and each field it names has to be a field of
the tree's type, so a typo cannot leave the real type with no rules. The answer is { "contract": 2, "tenant": {...}, "effective": {...} }. A tenant that
never sets any reads exactly what Modules:Pages says, as before.
The rules on write, the three endpoints and the tree's options all read the tenant's values.
Declaring the parent on the type
A reference field pointing at its own type may declare "parent": { "maxDepth": 8 } on its
definition. Core then refuses a loop or a chain too deep on every write to that type, with no
module or code involved: a docs site's sections or a shop's categories get a guarded tree from
the definition alone. When the page type declares its parent field this way, with a maxDepth no
deeper than the tree's, the module leaves the walk to core and keeps the reserved-slug rule.
Rules on write
A create, update or rollback of the configured type is refused with 400 when:
- the parent field points at the entry itself, or closes a loop of any length;
- the page would have more than
MaxDepthancestors, or its parent's chain never reaches a root; - a top-level page takes a slug in
ReservedSlugs.
The parent walk runs inside the write's transaction under an advisory lock, so two concurrent saves cannot close a loop between them. Two siblings with the same slug are already refused by core, which refuses any slug another entry of the type holds.
Endpoints
| Method and path | Purpose | Access |
|---|---|---|
GET /api/public/pages/navigation |
Nested, ordered menu of published pages flagged for navigation | Anonymous |
GET /api/public/pages/resolve?path=/about/team |
The page at a path, with breadcrumbs; 404 on a miss | Anonymous |
GET /api/pages/tree |
Every page the caller may read, drafts included, for the console | Signed in, content read permission |
The two anonymous endpoints serve only what GET /api/public/{type}/{slug} would: published,
document-Public entries of a publicly deliverable type, with only Public fields. A page is left out of
the menu and does not resolve when any page above it is not served, so a published page under a
draft stays off the site. Tree fields are read from the Public fields only. Both answer 404 when the
type is not publicly deliverable, and both are cacheable for 60 seconds with Vary: X-Tenant.
A menu page whose parent is not in the menu sits under its nearest ancestor that is, and keeps its
real path. The home page has path /; /home does not resolve to it.
Every body carries contract, currently 2. It moves only on a breaking change to these bodies. A
renderer that does not know the number should show no menu rather than stop.
{ "contract": 2, "truncated": false, "items": [
{ "id": "...", "title": "About", "slug": "about", "path": "/about", "order": 1, "children": [
{ "id": "...", "title": "Team", "slug": "team", "path": "/about/team", "order": null, "children": [] } ] } ] }
{ "contract": 2, "path": "/about/team",
"entry": { "id": "...", "contentType": "page", "slug": "team", "data": { "Title": "Team" }, "createdAt": "...", "updatedAt": "..." },
"breadcrumbs": [
{ "id": "...", "title": "About", "slug": "about", "path": "/about" },
{ "id": "...", "title": "Team", "slug": "team", "path": "/about/team" } ] }
truncated is true when the type holds more than MaxPages published pages, counted before the
navigation flag is read. Pages are read oldest first, so the newer ones, and every page under them,
may be missing from the menu even though resolve still serves them. Raise MaxPages when a site
sees it.
entry is the same shape GET /api/public/{type}/{slug} returns.
boundBlocks lists, for each block of a blocks field that names a binding, the entries that
binding selects: { "field": "Blocks", "index": 1, "binding": "...", "entries": [ ... ] }, each
entry in the shape above. Only the first 10 such blocks are read, each binding returns at most 20
entries, and a binding whose type is not publicly deliverable, or whose sort or conditions name a
field that is not Public, returns none. Empty for a page with no bound blocks. See
docs/blocks.md in the repository.
{ "contract": 2, "truncated": false,
"options": { "contentType": "page", "parentField": "ParentPage", "showInNavigationField": "ShowInNavigation",
"orderField": "NavigationOrder", "titleField": "Title", "maxDepth": 8, "reservedSlugs": [], "homeSlug": "home" },
"items": [
{ "id": "...", "title": "About", "slug": "about", "path": "/about", "status": "Draft",
"showInNavigation": true, "order": 1, "children": [] } ] }
options is the configuration in use for the tenant, so a console that moves a page writes the
parent and order fields this site's type has, not the defaults. Only the tree carries it; the two
anonymous bodies do not.
In the tree, a page whose parent the caller cannot read, or whose chain loops, is listed at the top
level with path null, so it can be found and fixed.
Part of barakoCMS
This is an optional module for barakoCMS, an open-source
headless CMS for .NET 10. Every module is published under the barakocms-module tag, so a single
search on nuget.org returns the whole set.
License
MIT © BaryoDev