barakopress 0.3.0 → 0.4.0

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.
Files changed (211) hide show
  1. package/README.md +862 -27
  2. package/dist/assets.d.ts +40 -0
  3. package/dist/assets.d.ts.map +1 -0
  4. package/dist/assets.js +188 -0
  5. package/dist/assets.js.map +1 -0
  6. package/dist/bin.d.ts +3 -0
  7. package/dist/bin.d.ts.map +1 -0
  8. package/dist/bin.js +9 -0
  9. package/dist/bin.js.map +1 -0
  10. package/dist/blocks/bind.d.ts +39 -0
  11. package/dist/blocks/bind.d.ts.map +1 -0
  12. package/dist/blocks/bind.js +291 -0
  13. package/dist/blocks/bind.js.map +1 -0
  14. package/dist/blocks/bindings.d.ts +80 -0
  15. package/dist/blocks/bindings.d.ts.map +1 -0
  16. package/dist/blocks/bindings.js +281 -0
  17. package/dist/blocks/bindings.js.map +1 -0
  18. package/dist/blocks/built-in.d.ts +18 -0
  19. package/dist/blocks/built-in.d.ts.map +1 -0
  20. package/dist/blocks/built-in.js +191 -0
  21. package/dist/blocks/built-in.js.map +1 -0
  22. package/dist/blocks/data.d.ts +20 -0
  23. package/dist/blocks/data.d.ts.map +1 -0
  24. package/dist/blocks/data.js +131 -0
  25. package/dist/blocks/data.js.map +1 -0
  26. package/dist/blocks/library.d.ts +7 -0
  27. package/dist/blocks/library.d.ts.map +1 -0
  28. package/dist/blocks/library.js +398 -0
  29. package/dist/blocks/library.js.map +1 -0
  30. package/dist/blocks/presets.d.ts +68 -0
  31. package/dist/blocks/presets.d.ts.map +1 -0
  32. package/dist/blocks/presets.js +234 -0
  33. package/dist/blocks/presets.js.map +1 -0
  34. package/dist/blocks/primitives.d.ts +12 -0
  35. package/dist/blocks/primitives.d.ts.map +1 -0
  36. package/dist/blocks/primitives.js +577 -0
  37. package/dist/blocks/primitives.js.map +1 -0
  38. package/dist/blocks/registry.d.ts +23 -0
  39. package/dist/blocks/registry.d.ts.map +1 -0
  40. package/dist/blocks/registry.js +57 -0
  41. package/dist/blocks/registry.js.map +1 -0
  42. package/dist/blocks/render.d.ts +8 -0
  43. package/dist/blocks/render.d.ts.map +1 -0
  44. package/dist/blocks/render.js +24 -0
  45. package/dist/blocks/render.js.map +1 -0
  46. package/dist/blocks/schema.d.ts +123 -0
  47. package/dist/blocks/schema.d.ts.map +1 -0
  48. package/dist/blocks/schema.js +183 -0
  49. package/dist/blocks/schema.js.map +1 -0
  50. package/dist/blocks/tokens.d.ts +31 -0
  51. package/dist/blocks/tokens.d.ts.map +1 -0
  52. package/dist/blocks/tokens.js +111 -0
  53. package/dist/blocks/tokens.js.map +1 -0
  54. package/dist/cms.d.ts +82 -0
  55. package/dist/cms.d.ts.map +1 -1
  56. package/dist/cms.js +169 -3
  57. package/dist/cms.js.map +1 -1
  58. package/dist/collections.d.ts +67 -0
  59. package/dist/collections.d.ts.map +1 -0
  60. package/dist/collections.js +217 -0
  61. package/dist/collections.js.map +1 -0
  62. package/dist/config.d.ts +273 -10
  63. package/dist/config.d.ts.map +1 -1
  64. package/dist/config.js +208 -13
  65. package/dist/config.js.map +1 -1
  66. package/dist/delivery.d.ts +87 -1
  67. package/dist/delivery.d.ts.map +1 -1
  68. package/dist/delivery.js +316 -13
  69. package/dist/delivery.js.map +1 -1
  70. package/dist/env.d.ts +37 -0
  71. package/dist/env.d.ts.map +1 -0
  72. package/dist/env.js +44 -0
  73. package/dist/env.js.map +1 -0
  74. package/dist/fonts.d.ts +53 -0
  75. package/dist/fonts.d.ts.map +1 -0
  76. package/dist/fonts.js +170 -0
  77. package/dist/fonts.js.map +1 -0
  78. package/dist/index.d.ts +48 -8
  79. package/dist/index.d.ts.map +1 -1
  80. package/dist/index.js +34 -4
  81. package/dist/index.js.map +1 -1
  82. package/dist/markdown.d.ts +7 -1
  83. package/dist/markdown.d.ts.map +1 -1
  84. package/dist/markdown.js +22 -5
  85. package/dist/markdown.js.map +1 -1
  86. package/dist/proxy.d.ts +18 -0
  87. package/dist/proxy.d.ts.map +1 -0
  88. package/dist/proxy.js +83 -0
  89. package/dist/proxy.js.map +1 -0
  90. package/dist/related.d.ts +10 -0
  91. package/dist/related.d.ts.map +1 -1
  92. package/dist/related.js +10 -1
  93. package/dist/related.js.map +1 -1
  94. package/dist/revalidate-key.d.ts +9 -0
  95. package/dist/revalidate-key.d.ts.map +1 -0
  96. package/dist/revalidate-key.js +35 -0
  97. package/dist/revalidate-key.js.map +1 -0
  98. package/dist/routes/block-schema.d.ts +11 -0
  99. package/dist/routes/block-schema.d.ts.map +1 -0
  100. package/dist/routes/block-schema.js +54 -0
  101. package/dist/routes/block-schema.js.map +1 -0
  102. package/dist/routes/feed.d.ts +6 -2
  103. package/dist/routes/feed.d.ts.map +1 -1
  104. package/dist/routes/feed.js +38 -27
  105. package/dist/routes/feed.js.map +1 -1
  106. package/dist/routes/revalidate.d.ts +6 -1
  107. package/dist/routes/revalidate.d.ts.map +1 -1
  108. package/dist/routes/revalidate.js +60 -16
  109. package/dist/routes/revalidate.js.map +1 -1
  110. package/dist/routes/robots.d.ts +1 -1
  111. package/dist/routes/robots.d.ts.map +1 -1
  112. package/dist/routes/robots.js +11 -3
  113. package/dist/routes/robots.js.map +1 -1
  114. package/dist/routes/share.d.ts +11 -0
  115. package/dist/routes/share.d.ts.map +1 -0
  116. package/dist/routes/share.js +158 -0
  117. package/dist/routes/share.js.map +1 -0
  118. package/dist/routes/sitemap.d.ts +1 -1
  119. package/dist/routes/sitemap.d.ts.map +1 -1
  120. package/dist/routes/sitemap.js +51 -22
  121. package/dist/routes/sitemap.js.map +1 -1
  122. package/dist/screens/archive.d.ts +7 -5
  123. package/dist/screens/archive.d.ts.map +1 -1
  124. package/dist/screens/archive.js +10 -38
  125. package/dist/screens/archive.js.map +1 -1
  126. package/dist/screens/blog-index.d.ts +5 -8
  127. package/dist/screens/blog-index.d.ts.map +1 -1
  128. package/dist/screens/blog-index.js +9 -32
  129. package/dist/screens/blog-index.js.map +1 -1
  130. package/dist/screens/blog-post.d.ts +20 -11
  131. package/dist/screens/blog-post.d.ts.map +1 -1
  132. package/dist/screens/blog-post.js +21 -84
  133. package/dist/screens/blog-post.js.map +1 -1
  134. package/dist/screens/collection.d.ts +91 -0
  135. package/dist/screens/collection.d.ts.map +1 -0
  136. package/dist/screens/collection.js +206 -0
  137. package/dist/screens/collection.js.map +1 -0
  138. package/dist/screens/navigation.d.ts +16 -0
  139. package/dist/screens/navigation.d.ts.map +1 -0
  140. package/dist/screens/navigation.js +40 -0
  141. package/dist/screens/navigation.js.map +1 -0
  142. package/dist/screens/page.d.ts +59 -0
  143. package/dist/screens/page.d.ts.map +1 -0
  144. package/dist/screens/page.js +260 -0
  145. package/dist/screens/page.js.map +1 -0
  146. package/dist/screens/post-view.d.ts.map +1 -1
  147. package/dist/screens/post-view.js +3 -3
  148. package/dist/screens/post-view.js.map +1 -1
  149. package/dist/screens/site-layout.d.ts +20 -0
  150. package/dist/screens/site-layout.d.ts.map +1 -0
  151. package/dist/screens/site-layout.js +223 -0
  152. package/dist/screens/site-layout.js.map +1 -0
  153. package/dist/secret.d.ts +20 -0
  154. package/dist/secret.d.ts.map +1 -0
  155. package/dist/secret.js +31 -0
  156. package/dist/secret.js.map +1 -0
  157. package/dist/site-route.d.ts +28 -0
  158. package/dist/site-route.d.ts.map +1 -0
  159. package/dist/site-route.js +104 -0
  160. package/dist/site-route.js.map +1 -0
  161. package/dist/site.d.ts +72 -0
  162. package/dist/site.d.ts.map +1 -0
  163. package/dist/site.js +729 -0
  164. package/dist/site.js.map +1 -0
  165. package/dist/theme.d.ts +63 -0
  166. package/dist/theme.d.ts.map +1 -1
  167. package/dist/theme.js +65 -0
  168. package/dist/theme.js.map +1 -1
  169. package/package.json +14 -1
  170. package/src/assets.tsx +233 -0
  171. package/src/bin.ts +7 -0
  172. package/src/blocks/bind.ts +393 -0
  173. package/src/blocks/bindings.ts +338 -0
  174. package/src/blocks/built-in.tsx +293 -0
  175. package/src/blocks/data.tsx +179 -0
  176. package/src/blocks/library.ts +459 -0
  177. package/src/blocks/presets.ts +319 -0
  178. package/src/blocks/primitives.tsx +811 -0
  179. package/src/blocks/registry.ts +65 -0
  180. package/src/blocks/render.tsx +37 -0
  181. package/src/blocks/schema.ts +330 -0
  182. package/src/blocks/tokens.ts +136 -0
  183. package/src/cms.ts +237 -2
  184. package/src/collections.ts +286 -0
  185. package/src/config.ts +478 -18
  186. package/src/delivery.ts +358 -16
  187. package/src/env.ts +68 -0
  188. package/src/fonts.ts +189 -0
  189. package/src/index.ts +205 -4
  190. package/src/markdown.ts +28 -5
  191. package/src/proxy.ts +93 -0
  192. package/src/related.ts +25 -3
  193. package/src/revalidate-key.ts +40 -0
  194. package/src/routes/block-schema.ts +71 -0
  195. package/src/routes/feed.ts +47 -22
  196. package/src/routes/revalidate.ts +68 -17
  197. package/src/routes/robots.ts +9 -3
  198. package/src/routes/share.ts +169 -0
  199. package/src/routes/sitemap.ts +52 -22
  200. package/src/screens/archive.tsx +14 -69
  201. package/src/screens/blog-index.tsx +9 -114
  202. package/src/screens/blog-post.tsx +26 -89
  203. package/src/screens/collection.tsx +388 -0
  204. package/src/screens/navigation.tsx +101 -0
  205. package/src/screens/page.tsx +381 -0
  206. package/src/screens/post-view.tsx +4 -4
  207. package/src/screens/site-layout.tsx +446 -0
  208. package/src/secret.ts +44 -0
  209. package/src/site-route.ts +116 -0
  210. package/src/site.ts +823 -0
  211. package/src/theme.ts +134 -0
package/README.md CHANGED
@@ -91,6 +91,13 @@ decision, not the engine's.
91
91
  | `app/sitemap.ts` | `default` | `createSitemap(config)` |
92
92
  | `app/robots.ts` | `default` | `createRobots(config)` |
93
93
  | `app/api/revalidate/route.ts` | `POST`, `GET` | `createRevalidateRoute(config)` |
94
+ | `app/[...path]/page.tsx` | `default`, `generateMetadata` | `createPage(config, blocks)`, `createPageMetadata(config)` |
95
+ | `app/doctors/page.tsx` | `default` | `createCollectionIndex(config, "doctors")` |
96
+ | `app/doctors/[slug]/page.tsx` | `default`, `generateMetadata`, `generateStaticParams` | `createCollectionDetail(config, "doctors")`, `createCollectionMetadata(config, "doctors")`, `createCollectionStaticParams(config, "doctors")` |
97
+ | `app/api/blocks/route.ts` | `GET`, `OPTIONS` | `createBlockSchemaRoute(blocks)`, `createBlockSchemaPreflight()` |
98
+ | `app/layout.tsx` | `default`, `generateMetadata` | `createSiteLayout(config, { blocks })`, `createSiteMetadata(config)` |
99
+ | `app/%5Fshare/route.ts` | `GET` | `createSharePage()` |
100
+ | `app/api/share/redeem/route.ts` | `POST` | `createShareRedeemRoute(config)` |
94
101
 
95
102
  Mount only what you want. Nothing requires anything else. The paths only have to agree with the
96
103
  `routes` in your config, which is what every generated link is built from.
@@ -102,6 +109,348 @@ for that static case.
102
109
 
103
110
  `Card` and `PostView` are exported too, for a site that wants its own page but the engine's markup.
104
111
 
112
+ ### Collections: any content type as a list and a detail page
113
+
114
+ A hospital has departments and doctors, a law firm practice areas and people. Each is a collection: a
115
+ content type with a route, a field map, references to other collections, a sort, and whether it has a
116
+ feed. The blog is three collections that `defineConfig` derives from `types`, `fields` and `routes`
117
+ (`post`, `author` and `category`), and `createBlogIndex`, `createBlogPost` and `createArchive` are thin
118
+ wrappers over the collection screens. A site configured the old way changes nothing:
119
+ `src/blog-wrappers.test.tsx` renders every blog factory, the feed, the sitemap and the metadata, and
120
+ compares them and the requests they make with `test/blog-wrappers.golden.json`, which was written
121
+ before collections existed.
122
+
123
+ On a request-time site the map is the tenant's, from a json field `Collections` on its site settings,
124
+ merged over the configured collections by key. A build-time site passes `collections` to
125
+ `defineConfig` in the same shape:
126
+
127
+ ```json
128
+ {
129
+ "departments": {
130
+ "type": "department", "route": "/departments",
131
+ "fields": { "title": "Name", "body": "About" },
132
+ "noun": ["department", "departments"]
133
+ },
134
+ "doctors": {
135
+ "type": "doctor", "route": "/doctors", "sort": "Name",
136
+ "fields": { "title": "Name", "summary": "Specialty", "image": "Photo" },
137
+ "references": { "Department": { "collection": "departments", "label": "in" } },
138
+ "noun": ["doctor", "doctors"]
139
+ }
140
+ }
141
+ ```
142
+
143
+ | Key | What |
144
+ | --- | --- |
145
+ | `type` | The content type. Required |
146
+ | `route` | The index is served at the route and an item at `route/slug`. Absent, items are listed and never linked |
147
+ | `fields` | `title` (required), `slug`, `summary`, `body` (markdown), `date`, `image`, `imageAlt`, `featured`, `tags`, `url`. Each is a field name or a list tried in order; `@createdAt` and `@updatedAt` read the entry itself |
148
+ | `references` | Reference fields, each naming the collection it points into and the word a card puts before the link. Resolved in the same request |
149
+ | `sort` | Sent to the API, for example `-PublishedAt` |
150
+ | `feed` | Whether `createFeed(config, key)` serves it |
151
+ | `sitemap` | On unless `false` |
152
+ | `index` | Whether the root catch-all serves an index at the route. On unless `false`; the derived `author` and `category` have it off, so `/authors` stays a 404 unless a route file mounts it |
153
+ | `pageSize`, `label`, `noun` | Items on the index, its heading, and how a count reads |
154
+ | `colorBy` | A choice field whose option colours the item. See below |
155
+
156
+ A settings entry that does not read as a collection is left out whole: a type or field that is not a
157
+ plain identifier, a route that is not a plain site path, or no title field. The keys `post`, `author`
158
+ and `category` are refused from settings, because the blog factories map posts through `types` and
159
+ `fields`. A build-time collection mounted at `/` throws in `defineConfig`. When two routes match, the
160
+ longer one wins.
161
+
162
+ A request-time site cannot add a route file per tenant, so the root catch-all from the pages section
163
+ also serves every collection with a route: its index at the route, and an item one segment below. A
164
+ route file wins where there is one. A collection's first segment is reserved from pages at the root.
165
+
166
+ A detail page lists the items of the first collection that references it, so `/departments/cardiology`
167
+ lists its doctors, the way an author's archive lists their posts. `listRelated(config, "doctors",
168
+ department, { via: "Department" })` returns the same list; `listRelated(config, post)` still returns
169
+ related posts by semantic search.
170
+
171
+ **Filtering.** `listCollection(config, "doctors", { filter: { Department: "cardiology" } })` takes a
172
+ reference field by the target's slug and any other field by the value it holds, a choice field by its
173
+ option. The API takes five filters. `createCollectionIndex(config, key, { filter })` takes the same,
174
+ and the `collection` block has `filterField` and `filterValue`.
175
+
176
+ **A colour per option.** With `colorBy: "AreaOfFocus"` on a `project` collection, the site settings
177
+ `OptionColors` entry `{ "project.AreaOfFocus": { "Providing clean water": "sky" } }` names a colour
178
+ from `Colors` (a theme slot or a colour written out also works). Each card, item page and collection
179
+ block item carries it as a left border, with the option beside it. A name that does not resolve to
180
+ something readable as a colour is dropped. A build-time site passes `optionColors` with the colours
181
+ written out.
182
+
183
+ `Card` and `ItemView` are exported for a site that wants its own page, and `Card` still takes a `post`.
184
+ `getItem`, `listCollection` and `getGlobals(config)`, the tenant's settings entry as stored, are
185
+ exported too.
186
+
187
+ ### Pages and navigation from the Pages module
188
+
189
+ With `BarakoCMS.Pages` installed, the CMS owns the page tree: which pages are in the menu, their
190
+ order, their nesting and each page's path. Mount it with `pages`:
191
+
192
+ ```ts
193
+ export const config = defineConfig({ sites: {}, pages: "" });
194
+ ```
195
+
196
+ `""` is the site root. `"/docs"` serves the CMS page at `/about` on `/docs/about`. Leaving `pages` out
197
+ means the site has no page tree, and nothing below asks for one. Then one catch-all route,
198
+ `app/[[...path]]/page.tsx`, or `app/[...path]/page.tsx` when `app/page.tsx` keeps `/`:
199
+
200
+ ```tsx
201
+ import { createPage, createPageMetadata } from "barakopress";
202
+ import { blocks, config } from "@/press.config";
203
+
204
+ export default createPage(config, blocks);
205
+ export const generateMetadata = createPageMetadata(config);
206
+ export const revalidate = 300;
207
+ ```
208
+
209
+ - `createPage` reads `GET /api/public/pages/resolve?path=` and renders the page's blocks, or its
210
+ body, with breadcrumbs above the title when the page has a parent. Mounted on a `[slug]` route
211
+ instead, it reads the page type by slug as before.
212
+ - `createSiteLayout` draws `GET /api/public/pages/navigation` in the header. `Navigation` draws the
213
+ items in the order it received them and links to the paths it was given, under the mount. It does
214
+ not sort, nest, filter or derive a path. A menu that cannot be read is no menu, not a broken page,
215
+ and while holding the holding page is left out of it.
216
+ - A miss asks `GET /api/public/redirects/resolve?path=` before it is a 404, because a catch-all is
217
+ where a rebuilt site's old URLs land. A 301 answers as a permanent redirect and anything else as a
218
+ temporary one. A destination that is not a site path or an http or https URL is ignored.
219
+ - **Reserved slugs.** Next answers a static route before a catch-all, so a root page slugged `blog`
220
+ or `feed.xml` would silently never render. At the root mount, a path whose first segment is
221
+ reserved is a 404 and is never asked for, and it is left out of the sitemap and static params with
222
+ a warning. The list is the first segment of each configured route plus `api`, `feed.xml`,
223
+ `sitemap.xml`, `robots.txt`, `_next`, `_share` and `favicon.ico`; `reservedSlugs` adds to it. Give
224
+ barakoCMS the same list as `Modules:Pages:ReservedSlugs` and an editor is refused the slug on save.
225
+ - **Contract.** Both bodies carry `contract`, and this renderer reads the range in `PAGES_CONTRACT`
226
+ (1 to 1). A body outside it logs a warning and reads as absent: no menu, no page. A public site does
227
+ not stop rendering because a menu shape moved.
228
+ - The sitemap lists the pages in the menu, and `createPageStaticParams` returns their path segments on
229
+ a build-time site, because the module publishes no other public list of paths. A page outside the
230
+ menu renders on request.
231
+
232
+ `Navigation`, `Breadcrumbs`, `getNavigation(config)`, `getPageByPath(config, path)`,
233
+ `getRedirect(config, path)` and `pageHref(config, path)` are exported for a site that draws its own
234
+ layout. Every read goes through the same tagged, cached, per tenant read as the rest of the site.
235
+
236
+ ### Pages built from blocks
237
+
238
+ A page can hold an ordered list of blocks in a json field, each `{ "type": ..., "props": { ... } }`.
239
+ The blueprint's `page` type has no such field, so add one:
240
+
241
+ ```bash
242
+ curl -X POST "$CMS_URL/api/content-types/page/fields" -H "authorization: Bearer $TOKEN" \
243
+ -H 'content-type: application/json' -d '{"fieldName":"Blocks","displayName":"Blocks","type":"json"}'
244
+ ```
245
+
246
+ A page with a non-empty list renders its blocks. A page without one renders its `Body` as before.
247
+ Field names come from `pageFields` in the config, and the type from `types.page`.
248
+
249
+ ```json
250
+ [
251
+ { "type": "richText", "props": { "markdown": "## Hello" } },
252
+ { "type": "columns", "props": { "columns": [
253
+ [{ "type": "image", "props": { "src": "https://example.com/a.png", "alt": "A" } }],
254
+ [{ "type": "callToAction", "props": { "heading": "Talk to us", "label": "Contact", "href": "/contact" } }]
255
+ ] } },
256
+ { "type": "collection", "props": { "collection": "post", "limit": 3, "heading": "Latest" } }
257
+ ]
258
+ ```
259
+
260
+ Blocks come in four layers.
261
+
262
+ **Layout primitives** hold blocks and no content: `section` (a tone, a width and a padding step),
263
+ `stack`, `row` (side by side, wrapping on a phone), `grid`, `flow`, `panel` (a card with a tone and
264
+ a frame), `spacer` and `divider`.
265
+
266
+ `row` and `grid` take one list per cell, which suits a designer placing each one. `flow` takes a
267
+ single list and lays out whatever is in it, which is what a `repeat` and a preset's `slot` produce:
268
+ a card per row that came back, however many that is. Its column count is a choice and not a number,
269
+ because only a string field takes a binding, and a preset has to pass its own `columns` through.
270
+
271
+ **Content primitives** hold content and no layout: `text` (a variant from the theme's type scale),
272
+ `richText` (markdown), `image`, `video`, `embed` (an iframe, only for a host in `embedHosts`),
273
+ `icon`, `button`, `link`, `list` and `disclosure` (a labelled section that opens; give several the
274
+ same `group` and only one is open at a time).
275
+
276
+ Every primitive takes theme tokens and never a colour or a pixel value. Tones are `page`, `surface`,
277
+ `accent` and `inverse`; spacing is `none` to `xxl` from `theme.space`; type is a role from
278
+ `theme.text`; corners are `none`, `control`, `panel` or `pill`. A tenant that changes the scale
279
+ changes every page built from primitives, and nobody can put one client's blue into a block.
280
+
281
+ A tone belongs to the band and everything in it: a `section` or a `panel` publishes its ink, its
282
+ accent and its hairline, and the blocks inside read those rather than the page's. That is what makes
283
+ an inverse band readable, and it is why a block dropped anywhere still looks like it belongs.
284
+
285
+ **Presets** are named arrangements of primitives, stored as data: the shipped library below, plus
286
+ whatever a tenant saves of its own.
287
+
288
+ **Data blocks** load and choose rather than draw: `source`, `repeat`, `showIf`, `pager` and `slot`.
289
+ See bindings below.
290
+
291
+ Also built in, from before the layers: `columns` (up to four lists of blocks), `callToAction` and
292
+ `collection` (the newest posts, or authors or categories when the site has those routes). Pages
293
+ already hold these and they render unchanged.
294
+
295
+ A site adds its own blocks, or replaces a built-in, by registering a definition. The fields are the
296
+ only description of the props: the renderer reads props through them, and `app/api/blocks` publishes
297
+ them for an editor, so the two cannot drift.
298
+
299
+ ```tsx
300
+ import { createBlockRegistry, defineBlock } from "barakopress";
301
+
302
+ const pricing = defineBlock<{ plan: string; price?: number }>({
303
+ type: "pricing",
304
+ label: "Pricing",
305
+ fields: [
306
+ { name: "plan", kind: "text", required: true },
307
+ { name: "price", kind: "number", min: 0 },
308
+ ],
309
+ component: ({ props, theme }) => <p style={{ color: theme.colors.ink }}>{props.plan}: {props.price}</p>,
310
+ });
311
+
312
+ export const blocks = createBlockRegistry(config, [pricing]);
313
+ ```
314
+
315
+ **Reading the schema from barakoBrew.** The console usually runs on another origin than the site, so
316
+ a browser only lets its block editor read `app/api/blocks` when the site names that origin. Set
317
+ `PRESS_CONSOLE_ORIGINS` to a comma separated list, for example
318
+ `PRESS_CONSOLE_ORIGINS=https://brew.example.com`. It is read per request. Unset, no origin is
319
+ allowed. A listed origin gets `Access-Control-Allow-Origin` echoed back on `GET` and on the `OPTIONS`
320
+ preflight, never `*` and never credentials, and every answer carries `Vary: Origin`. Pass
321
+ `{ consoleOrigins: [...] }` to both factories to set the list in code instead.
322
+
323
+ Field kinds are `text`, `markdown`, `url`, `number`, `boolean`, `select` (with `options`) and `slots`
324
+ (lists of nested blocks, handed to the component already rendered). The list is editor input, so a
325
+ block renders only when its type is registered and every prop passes its field. A present but wrong
326
+ value fails the whole block, a `url` must pass the same check markdown links do, and a component
327
+ never receives a prop its fields did not declare. A page reads at most 100 blocks in total, nested
328
+ ones included, and four levels deep.
329
+
330
+ `defineBlock<Props, SlotNames>` checks the fields against the props at compile time: every field
331
+ names a prop, its kind suits the prop's type, and a prop that is not optional must be `required`.
332
+
333
+ A component gets `props`, `slots` and `theme`, not the config, because a client component's props
334
+ are serialised into the page and the config holds the CMS address. A server block that needs the
335
+ config closes over it.
336
+
337
+ A block that shows something depending on who is looking sets `perViewer: true`. `createPage` leaves
338
+ such blocks out, because its output is cached and shared. `createViewerPage` renders them and calls
339
+ `connection()` first, so that route is always dynamic. Signing a viewer in, and gating a block by
340
+ role, is issue #7.
341
+
342
+ ### Bindings
343
+
344
+ Any string prop can hold a placeholder, which is barakoCMS's workflow template syntax with an
345
+ optional format and fallback:
346
+
347
+ ```json
348
+ { "type": "text", "props": { "value": "Welcome to {{site.Name}}", "variant": "display" } }
349
+ { "type": "text", "props": { "value": "{{item.Fee | money ?? Free}}" } }
350
+ { "type": "text", "props": { "value": "{{page.PublishedAt | date}}" } }
351
+ ```
352
+
353
+ Scopes are `site` (the tenant's settings and resolved identity), `page` (the entry the page
354
+ renders), `item` (the row inside a `source` or a `repeat`) and `query` (URL parameters). `viewer`
355
+ arrives with #7. Formats are `text`, `date`, `datetime`, `time`, `money`, `number`, `upper` and
356
+ `lower`; `money` uses the tenant's `Currency` setting, or a plain amount when it has none.
357
+
358
+ Paths, formats and fallbacks only. There are no expressions and no JavaScript. Everything resolves
359
+ on the server as the request's tenant, before a component is called, so a binding never makes the
360
+ browser call the API. A placeholder that finds no value renders its fallback and is reported in the
361
+ server log, never a crash. What a binding resolves to is checked against the field again, so a link
362
+ whose stored field holds `javascript:` drops the block; and a resolved value is never rescanned, so
363
+ one field cannot reach another through its own contents.
364
+
365
+ `source` loads one entry or a page of them and puts it in scope. `repeat` renders its content once
366
+ per row. `pager` draws prev and next links for the `source` it sits in, paged by the API. `showIf`
367
+ keeps its content only when a bound value has something, or equals what it names.
368
+
369
+ ```json
370
+ { "type": "source", "props": {
371
+ "collection": "enrolments", "mode": "list", "pageSize": 20, "pageParam": "p",
372
+ "filterField": "Class", "filterValue": "{{query.class}}",
373
+ "content": [[
374
+ { "type": "repeat", "props": { "empty": "No one yet.", "content": [[
375
+ { "type": "text", "props": { "value": "{{item.Title}}" } }
376
+ ]] } },
377
+ { "type": "pager", "props": {} }
378
+ ]]
379
+ } }
380
+ ```
381
+
382
+ A filter narrows what the API already lets the reader see. It is never access control: who may read
383
+ which rows is decided in barakoCMS. A page reads at most eight sources, and a `source` at most fifty
384
+ rows a page.
385
+
386
+ ### Presets
387
+
388
+ A preset is a named block saved as data, not code: a few props and an arrangement of primitives that
389
+ reads them through the `props` scope. A designer saves one in barakoBrew and every site on the
390
+ published image can use it, with no barakoPress release.
391
+
392
+ ```json
393
+ {
394
+ "type": "band",
395
+ "label": "Band",
396
+ "fields": [
397
+ { "name": "heading", "kind": "text", "required": true },
398
+ { "name": "tone", "kind": "select", "options": ["page", "accent"] },
399
+ { "name": "content", "kind": "slots" }
400
+ ],
401
+ "blocks": [
402
+ { "type": "section", "props": { "tone": "{{props.tone}}", "content": [[
403
+ { "type": "text", "props": { "value": "{{props.heading}}", "variant": "title" } },
404
+ { "type": "slot", "props": { "name": "content" } }
405
+ ]] } }
406
+ ]
407
+ }
408
+ ```
409
+
410
+ A request-time site reads its tenant's presets from the `Presets` site setting. A build-time site
411
+ passes them as `presets` in the config. A preset never replaces a block that is code. It does
412
+ replace a preset, which is how a tenant adjusts one of the shipped blocks below without waiting for
413
+ a release. A preset body may not use another preset saved in the same pass, so a cycle cannot form;
414
+ it may use one compiled earlier, which is how a tenant's own block builds on a shipped one. `slot` marks where the content an editor dropped into the preset
415
+ goes, and that content binds in the page's scope rather than the preset's.
416
+
417
+ A preset that is not used says why, once, in the server log, with one line per reason rather than one
418
+ catchall: an entry that is not a preset, a type that is not a name, a type an earlier preset already
419
+ uses, a field list that is not a list, fields inside it that are not fields, a name a registered
420
+ block already has, and the presets past the point where a tenant's bodies hold more than 400 blocks
421
+ between them. A setting that quietly does nothing reads as done, which is worse than one that is
422
+ missing. A bad paste is spelled out five times and then counted.
423
+
424
+ ### The block library
425
+
426
+ Every site gets these, compiled in from the primitives. None of them is code, so a site that wants
427
+ one to look different saves its own under the same name and that one wins.
428
+
429
+ | Block | What it is | Props |
430
+ | --- | --- | --- |
431
+ | `hero` | The band at the head of a page | heading, body, image, imageAlt, primaryLabel, primaryHref, secondaryLabel, secondaryHref, tone, columns, align, padding |
432
+ | `band` | Copy with one call to action | tone, heading, body, label, href, align, padding, width |
433
+ | `statBand` | A row of figures | heading, tone, columns, padding, items |
434
+ | `cardGrid` | Cards from a collection | heading, collection, filterField, filterValue, empty, tone, columns, padding |
435
+ | `peopleGrid` | People from a collection, typed in place, or both | heading, collection, filterField, filterValue, role, tone, columns, padding, items |
436
+ | `timeline` | Dated entries | heading, tone, padding, width, items |
437
+ | `steps` | Numbered entries | heading, tone, padding, width, items |
438
+ | `tiers` | Giving or pricing tiers | heading, tone, columns, padding, items |
439
+ | `keyValueTable` | A panel of facts | heading, tone, padding, radius, rows |
440
+ | `tabs` | Sections that open | heading, tone, padding, width, items |
441
+ | `map` | An embedded map, held to `embedHosts` | src, title, heading, aspect, tone, padding |
442
+ | `stat` | One figure and its label | value, label, align |
443
+ | `timelineEntry` | One dated entry | date, title, body |
444
+ | `step` | One numbered step | number, title, body |
445
+ | `tier` | One tier | name, amount, body, label, href, tone |
446
+ | `person` | One person | name, role, photo, href, linkLabel, align |
447
+ | `keyValueRow` | One fact | label, value |
448
+
449
+ The blocks in the second half go in the first half's slots: stats in a `statBand`, entries in a
450
+ `timeline`, `disclosure` blocks in `tabs`. A card grid reads its entries through `{{item.Title}}`,
451
+ `{{item.Summary}}`, `{{item.Date | date}}` and `{{item.Href}}`, so it works against whatever the
452
+ tenant calls those fields, and it takes the collection as a prop rather than knowing any name.
453
+
105
454
  ## Configuring it
106
455
 
107
456
  `press.config.ts` is the one file a site owns, and the seam the whole package turns on.
@@ -125,25 +474,325 @@ without: the engine never asks the API for that data and never renders an empty
125
474
  reference type you do not have is worse than useless, because `include=Author` is a 400 from the API
126
475
  for a post type with no such field.
127
476
 
128
- | Key | Default | What |
477
+ | Key | Default | Tenant setting | What |
478
+ | --- | --- | --- | --- |
479
+ | `site` | required | `Name`, `Tagline`, `Url` and one per field | Name, tagline and absolute origin. Every absolute link is built from the origin |
480
+ | `types` | `post`, `author`, `category` | operator only | The content type names |
481
+ | `fields` | the blueprint's PascalCase names | operator only | Which field holds what |
482
+ | `pageFields` | the blueprint's PascalCase names | operator only | Which field on the page type holds what |
483
+ | `routes` | `/blog`, `/authors`, `/categories` | operator only | Where you mounted each route |
484
+ | `pageSizes` | 20, 50, 1000, 50 | `PageSizes` | Index, feed, sitemap, archive |
485
+ | `cacheTag` | `cms` | operator only | The tag this site purges. Two sites on one server need two tags |
486
+ | `backstopSeconds` | 300 | operator only | How long a cached read may live with no webhook. 0 disables it |
487
+ | `cmsTimeoutMs` | 5000 | operator only | How long any one call to the CMS may take, a share link redemption included. Past it the call has failed, and a request-time site answers from its last good copy |
488
+ | `locale` | `en-GB` | `Locale` | Passed to `toLocaleDateString` |
489
+ | `currency` | none | `Currency` | The ISO code a `money` binding formats with. Unset, an amount renders as a plain number |
490
+ | `embedHosts` | seven player hosts | `EmbedHosts` | The hosts an `embed` block may frame |
491
+ | `presets` | none | `Presets` | Named blocks saved as arrangements of primitives. See Presets |
492
+ | `cmsUrl` | `CMS_URL`, or `http://localhost:5005` | operator only | Where the CMS is, from this server. The variable is read when the CMS is called, not when this file runs |
493
+ | `tenant` | `CMS_TENANT` | operator only | Tenant slug, for a multi-tenant deployment. On a request-time site, pins every host to it. The variable is read when the CMS is called, not when this file runs |
494
+ | `sites` | off | operator only | Request-time identity and theme from the tenant's site settings. See below |
495
+ | `pages` | off | operator only | Where the Pages module's pages are mounted. `""` is the site root. It has to match a route file on disk, which is why it is not a tenant's to set |
496
+ | `reservedSlugs` | the routes and the engine's files | `ReservedSlugs`, added to them | First path segments a root-mounted page may not take. Adds to the defaults |
497
+ | `regions` | off | `HeaderPath`, `HeaderTone`, `FooterPath`, `FooterTone` | The header and the footer as block regions |
498
+ | `collections` | the blog's `post`, `author` and `category` | `Collections` | Content types rendered as lists and detail pages. See Collections |
499
+ | `optionColors` | none | `OptionColors` | CSS colours by `type.field` and option, for `colorBy` |
500
+ | `theme` | the barakoCMS palette | `Colors`, `Fonts`, `Radii`, `Layout`, `Space`, `Text` | Colours, faces, radii and column widths. See below |
501
+
502
+ The third column is the whole of the split. A key marked operator only is one the image decides for
503
+ every tenant it serves, and each is that for a reason you can name: `types`, `fields`, `pageFields`
504
+ and `routes` are what the blog factories compile against, `cacheTag`, `backstopSeconds`,
505
+ `cmsTimeoutMs`, `cmsUrl`, `tenant` and `sites` are facts about the deployment rather than the site,
506
+ and `pages` has to match a route file on disk. Everything else is that tenant's data, edited in
507
+ barakoBrew, and wins over what this file said. A tenant that sets nothing renders exactly as the
508
+ file says.
509
+
510
+ ### The environment
511
+
512
+ Every environment value this package reads goes through one reader, `readEnv` in `src/env.ts`, and
513
+ every one of them is read on the call that uses it. None is read when `press.config.ts` runs. That
514
+ used to differ per variable: `CMS_URL` and `CMS_TENANT` were read inside `defineConfig`, the rest
515
+ per request, so when a value was read depended on which value it was.
516
+
517
+ | Variable | What it sets |
518
+ | --- | --- |
519
+ | `CMS_URL` | Where the delivery API is. `cmsUrl` in the config wins |
520
+ | `CMS_TENANT` | Pins the process to one tenant. `tenant` in the config wins |
521
+ | `CMS_DEFAULT_TENANT` | The tenant for a host the CMS does not know. `sites.defaultTenant` wins |
522
+ | `CMS_RENDERER_KEY` | Sent to the CMS when a share link is redeemed. See [Share links](#one-build-many-sites) |
523
+ | `PRESS_CONSOLE_ORIGINS` | Browser origins allowed to read the block schema, comma separated |
524
+ | `PRESS_SECRET` | The HMAC key for everything this renderer signs. See [One secret](#one-secret) |
525
+ | `REVALIDATE_SECRET` | The webhook key before `PRESS_SECRET`, read only while that is unset |
526
+ | `PRESS_PREVIEW_SECRET` | The share key before `PRESS_SECRET`, read only while that is unset |
527
+
528
+ Values are used exactly as the environment has them, untrimmed. A secret with a trailing space is a
529
+ different HMAC key, so trimming one here would stop a webhook that verifies today.
530
+
531
+ **A site is build time or request time.** Without `sites`, identity is build time: the index, the
532
+ feed, the sitemap and robots are prerendered, so anything *your own* `press.config.ts` reads from the
533
+ environment is baked when you build, not when the server starts. Write per-site values as literals in
534
+ that file. Getting this wrong is how a client site ships with the vendor's name in its masthead. With
535
+ `sites`, identity is data in the CMS and read per request, which is the next section.
536
+
537
+ ### One build, many sites
538
+
539
+ barakoCMS D22 reverses "identity is build time" for the shared renderer: every site runs the same
540
+ image, and what makes a site that site is its tenant's `site` settings entry, edited in barakoBrew.
541
+
542
+ ```ts
543
+ export const config = defineConfig({ sites: {} });
544
+ ```
545
+
546
+ A request-time site puts its page routes under one tenant segment and adds a `proxy.ts` beside
547
+ `app/`:
548
+
549
+ ```
550
+ proxy.ts export default createPressProxy(config)
551
+ app/
552
+ %5Fpress/[site]/ the tenant segment. %5F is `_`, which Next reads as a
553
+ layout.tsx private folder when it is written plainly
554
+ page.tsx
555
+ [...path]/page.tsx
556
+ blog/[slug]/page.tsx
557
+ feed.xml/route.ts these resolve their own tenant and stay where they are
558
+ robots.ts
559
+ sitemap.ts
560
+ api/...
561
+ %5Fshare/route.ts
562
+ ```
563
+
564
+ ```ts
565
+ // proxy.ts
566
+ import { createPressProxy } from "barakopress";
567
+ import { config } from "@/press.config";
568
+
569
+ export default createPressProxy(config);
570
+ ```
571
+
572
+ The proxy resolves the tenant and the share session once and rewrites to
573
+ `/_press/<tenant>~<gate>~<host>/<path>`. The pages read all three out of that segment, so nothing in
574
+ a page reads a header or a cookie, and Next can keep the render. Why the tenant has to be in the
575
+ path, and what is cached and what is not, is [The render cache](#the-render-cache) below. A build-time
576
+ site needs none of this and keeps its routes where they are.
577
+
578
+ On each request the engine resolves the tenant, reads that tenant's settings, and renders with them:
579
+
580
+ 1. `CMS_TENANT` (or `tenant`) set: every host is that tenant, with no lookup.
581
+ 2. `sites.tenantHeader` set and the request carries a valid handle in it: that tenant. Off by default.
582
+ 3. The host, from `sites.hostHeader` (default `host`), looked up with `GET /api/tenants/by-host/{host}`.
583
+ 4. `sites.defaultTenant`, or `CMS_DEFAULT_TENANT`, read at request time.
584
+ 5. None of those: a 404. Never another tenant's site.
585
+
586
+ A handle is only ever read from the request through a header the operator named. `X-Tenant` and
587
+ `X-Forwarded-Host` sent by a caller are ignored unless you configure them, and you should configure
588
+ them only behind a proxy that sets the header and strips a caller's value.
589
+
590
+ The settings are the singleton `site` type from barakoCMS `docs/site-settings.md`
591
+ (`POST /api/content-types/blueprints/site`, then publish its one entry). The engine reads `Name`,
592
+ `Tagline`, `Url`, `Locale`, `Logo`, `LogoAlt`, `FooterLogo`, `Favicon`, `ShareImage`, `Copyright`,
593
+ `Colors` (the theme slots), `Fonts` (a family name per role, and the stylesheet that loads it),
594
+ `Radii`, `Layout`, `TopBar`, `HeaderLinks`, `FooterColumns`, `SocialLinks`, `HeaderPath`,
595
+ `HeaderTone`, `FooterPath`, `FooterTone`, `AssetsAsSupplied`, `LogoAsSupplied`, `LogoClearSpace`,
596
+ `PageSizes` and `ReservedSlugs`. `Collections` and `OptionColors` are read as the collections section
597
+ describes. `Variants` are not rendered yet. Every value is checked for shape; one that fails, and any the
598
+ entry leaves out, keeps the configured value, so a half-filled theme renders. A link is a path on the
599
+ site or an absolute http or https URL. Set `Url`: without it the feed and sitemap fall back to the
600
+ host the tenant was found by.
601
+
602
+ `createSiteLayout` and `createSiteMetadata` render the root layout from all of this: `lang`, the
603
+ faces, the palette, the top bar, header links, footer columns, social links and the copyright line.
604
+
605
+ **How many items, per tenant.** `PageSizes` sets the four counts for this site, each on its own:
606
+
607
+ ```json
608
+ { "PageSizes": { "index": 50 } }
609
+ ```
610
+
611
+ A bakery listing 50 products and an agency listing 9 case studies run the same image. A key the
612
+ entry leaves out keeps the configured count, and a collection's own `pageSize` still wins over the
613
+ site's. barakoCMS clamps a public list at 100 whatever is asked for, so a larger number is not an
614
+ error and does not buy more rows.
615
+
616
+ **Paths this site does not serve.** `ReservedSlugs` adds to the configured reserved list:
617
+
618
+ ```json
619
+ { "ReservedSlugs": ["shop", "status"] }
620
+ ```
621
+
622
+ A first segment on it is left out of the menu and the sitemap, and a page there is never asked for.
623
+ It is what a proxy in front of this domain answers instead of the renderer, which is a fact about
624
+ one tenant rather than about the image. It only ever adds: a tenant cannot free a segment the app's
625
+ own routes already hold, because a page there would sit behind a route file and render nowhere. A
626
+ tenant's collection routes need no entry, since those are read off its settings already.
627
+
628
+ **Fonts, from an allow list.** A family name on its own is loaded from Google Fonts, which is what it
629
+ has always meant:
630
+
631
+ ```json
632
+ { "Fonts": { "heading": "Zilla Slab" } }
633
+ ```
634
+
635
+ A site that cannot use Google Fonts, a school with a licensed face on its own host or a tenant that
636
+ must not send visitor addresses to a third party, names the stylesheet instead:
637
+
638
+ ```json
639
+ { "Fonts": { "heading": { "family": "Zilla Slab", "url": "https://type.school.example/zilla.css" } } }
640
+ ```
641
+
642
+ That URL is a tenant's setting on its way into a `<link>` in every visitor's page, so which origins a
643
+ page may reach is the deployment's decision and not the tenant's. `PRESS_FONT_ORIGINS` is the list:
644
+ origins separated by commas or spaces, each `https://host`, a bare host read as https.
645
+
646
+ | `PRESS_FONT_ORIGINS` | What a page may link |
647
+ | --- | --- |
648
+ | unset or blank | Google Fonts, and nothing else. This is what every site rendered before the list existed |
649
+ | `https://type.school.example` | That origin only. Nothing goes to Google Fonts, the built-in link and its preconnects included |
650
+ | `fonts.googleapis.com, type.school.example` | Both |
651
+
652
+ A URL on any other origin is refused: no link to it is rendered, the role falls back to its family
653
+ name, and the server log says so once rather than once a page view. The list replaces the default
654
+ rather than adding to it, which is what gives a tenant that must not reach Google Fonts a deployment
655
+ where nothing can. `createSiteLayout(config, { loadFonts: false })` still turns off every font link
656
+ for the whole image.
657
+
658
+ Only an absolute https URL is kept, for the whole chain: http is blocked as mixed content on every
659
+ site this serves, so allowing it would mean rendering a link that never loads. A role the tenant names
660
+ is the tenant's, family and stylesheet together, so a family set with no url clears a configured
661
+ stylesheet rather than leaving the page loading a face it no longer uses. A build-time site sets the
662
+ same thing in `theme.fontSources`, and it is held to the same list.
663
+
664
+ **Header and footer as block regions.** The built-in header and footer take links and text and
665
+ nothing else, so a clinic that wants a light footer with opening hours and a map cannot have one, and
666
+ a school that wants an enrolment banner with a button cannot either. Point a region at a page and its
667
+ blocks are drawn there instead, resolved and bound exactly as the page route resolves and binds them.
668
+
669
+ | Field | Type | What |
129
670
  | --- | --- | --- |
130
- | `site` | required | Name, tagline and absolute origin. Every absolute link is built from the origin |
131
- | `types` | `post`, `author`, `category` | The content type names |
132
- | `fields` | the blueprint's PascalCase names | Which field holds what |
133
- | `routes` | `/blog`, `/authors`, `/categories` | Where you mounted each route |
134
- | `pageSizes` | 20, 50, 1000, 50 | Index, feed, sitemap, archive |
135
- | `cacheTag` | `cms` | The tag this site purges. Two sites on one server need two tags |
136
- | `backstopSeconds` | 300 | How long a cached read may live with no webhook. 0 disables it |
137
- | `locale` | `en-GB` | Passed to `toLocaleDateString` |
138
- | `cmsUrl` | `CMS_URL`, or `http://localhost:5005` | Where the CMS is, from this server |
139
- | `tenant` | `CMS_TENANT` | Tenant slug, for a multi-tenant deployment |
140
- | `theme` | the barakoCMS palette | Colours, faces, radii and column widths. See below |
141
-
142
- **Identity is build time.** The index, the feed, the sitemap and robots are prerendered, so anything
143
- `press.config.ts` reads from `process.env` is baked when you build, not when the server starts. Write
144
- per-site values as literals in that file. Only per-environment values (`CMS_URL`,
145
- `REVALIDATE_SECRET`) come from the environment, and neither of those is rendered. Getting this wrong
146
- is how a client site ships with the vendor's name in its masthead.
671
+ | `HeaderPath` | string | A site path such as `/site/header`. The page served there is drawn in place of the top bar and the header band |
672
+ | `HeaderTone` | string | `page`, `surface`, `accent` or `inverse`: the tone behind the header region. `page` when unset or not one of the four |
673
+ | `FooterPath` | string | A site path such as `/site/footer`. The page served there is drawn in place of the footer |
674
+ | `FooterTone` | string | The same four names, behind the footer region |
675
+
676
+ Set neither and nothing changes: `TopBar`, `HeaderLinks`, `FooterColumns`, `SocialLinks` and
677
+ `Copyright` draw the built-in chrome with the markup they always had, which is what keeps a site
678
+ whose own CSS keys off that markup rendering. A path with nothing served at it does the same, so
679
+ naming a page before writing it is safe, and so is a typo.
680
+
681
+ A region page is chrome rather than somewhere to go, so it is left out of the menu and the sitemap.
682
+ It still answers on its own route, which is how an editor opens it to work on it.
683
+
684
+ A header region replaces the whole band, the site name, the menu and the RSS link along with the top
685
+ bar. There is no navigation or logo block yet, so a header region lists its own links until the block
686
+ library has one.
687
+
688
+ A build-time site sets the same thing in its config, and a tenant's settings win over it:
689
+
690
+ ```ts
691
+ export const config = defineConfig({
692
+ site: { name: "Mabini Clinic", url: "https://clinic.example" },
693
+ pages: "",
694
+ regions: { footer: { path: "/site/footer", tone: "surface" } },
695
+ });
696
+ ```
697
+
698
+ The registry the regions render with is the one passed to `createSiteLayout(config, { blocks })`, and
699
+ the built-in blocks when none was passed.
700
+
701
+ **Caching per tenant.** Every read carries the tenant in `X-Tenant` and in its cache tag,
702
+ `<cacheTag>:<tenant>`. The webhook purges the tag of the tenant its host resolves to, so point each
703
+ tenant's webhook at `https://<that tenant's domain>/api/revalidate`, signed with that tenant's own key
704
+ (see [One key per tenant](#one-key-per-tenant)). A publish on one tenant leaves every other tenant's
705
+ cached reads in place.
706
+
707
+ **When the CMS is down.** Each successful read is also kept in process, keyed by CMS, tenant and path.
708
+ A read that fails with a network error, a 5xx, or no answer within `cmsTimeoutMs` answers from the
709
+ last good copy and logs a warning; the next successful read replaces it. For ten seconds after such a
710
+ failure that read answers from the copy without asking the CMS, so an outage costs one request per
711
+ read every ten seconds rather than one per visitor. The same holds after a purge: a publish that lands
712
+ within those ten seconds shows once the ten seconds are up. Known hosts keep resolving the same way. A page that was cached
713
+ for a tenant keeps answering 200 with that tenant's identity and theme. A tenant never gets another
714
+ tenant's kept answer.
715
+
716
+ **Holding mode.** A tenant can show a holding page on every route in place of its site, for a launch,
717
+ maintenance or a seasonal break, and share the real site with a few people through site share links.
718
+ Three fields on the `site` entry control it:
719
+
720
+ | Field | Type | What |
721
+ | --- | --- | --- |
722
+ | `Mode` | string | `Live` or `Holding`. Unset, or anything else, is `Live` |
723
+ | `HoldingPath` | string | A site path such as `/coming-soon`. The page the Pages module serves there is the holding page. Empty, or nothing served there, renders the default holding page: the name, the tagline and `HoldingMessage`, in the theme |
724
+ | `HoldingMessage` | string | The line the default holding page shows under the name and tagline, as text, for example `Closed until 6 January.` Unset or blank shows no line. Read only while holding. For more than a sentence, use `HoldingPath` |
725
+
726
+ Holding is presentation, not access control: the API still delivers every published entry. Content
727
+ that must stay hidden before launch stays unpublished with a scheduled publish.
728
+
729
+ While a tenant is holding, for that tenant only:
730
+
731
+ - every page answers the holding page with `noindex` and `Cache-Control: private, no-store`. A page
732
+ stops in `siteConfig` with a not-found before it reads anything, so its content and its title never
733
+ reach the response. Next has already sent the status by then, so it is 200, the same for a path
734
+ that exists and one that does not;
735
+ - the holding page is read with `GET /api/public/pages/resolve?path=<HoldingPath>` and rendered with
736
+ the page renderer and the registry passed to `createSiteLayout(config, { blocks })`;
737
+ - a link to `HoldingPath` is left out of the header, top bar and footer. `feed.xml` and `sitemap.xml`
738
+ are 404 for everyone, session or not, and `robots.txt` answers `Disallow: /` with no sitemap line;
739
+ - route handlers (revalidate, blocks, `/_share`, `/api/share/redeem`) and `/_next/static` are served
740
+ as usual.
741
+
742
+ Switching `Mode` is a publish: the webhook purges the tenant's tag and the next request reads the new
743
+ settings. No deploy.
744
+
745
+ **Site share links.** barakoCMS creates, lists and revokes them; anyone who may update the `site` type
746
+ can. A client is given `{site Url}/_share#{key}`. The key is in the fragment, so no server log, proxy
747
+ or referrer sees it:
748
+
749
+ 1. `/_share` is a tiny page. Its script reads the fragment, removes it from the address and history,
750
+ and posts the key in a form to `/api/share/redeem`. Without JavaScript it says the link needs
751
+ JavaScript. No key is ever accepted in a query string.
752
+ 2. `/api/share/redeem` asks barakoCMS once, `POST /api/public/site/share-links/redeem` with the key in
753
+ the body and the tenant header. On 200 it sets `__Host-press-share` (HttpOnly, Secure,
754
+ SameSite=Lax, Path=/, no Domain, so only that host gets it) and answers a `no-store` 303 to `/`.
755
+ On 404, 429 or any failure it sets nothing and sends the visitor to `/#share-invalid`, the holding
756
+ page with "This link is not valid or has expired." A post whose `Origin` or `Sec-Fetch-Site` names
757
+ another site is refused the same way. The key is never logged.
758
+ 3. With a valid cookie that visitor sees the real site for that tenant. A forged, expired or
759
+ other-tenant cookie is ignored.
760
+
761
+ The cookie holds no key. It is an expiry and an HMAC-SHA256 over the tenant and that expiry, so each
762
+ later request is checked in process without asking the CMS, and a cookie made for one tenant opens no
763
+ other. It needs one setting:
764
+
765
+ | Variable | What |
766
+ | --- | --- |
767
+ | `PRESS_SECRET` | The HMAC key, at least 32 characters, for example `openssl rand -base64 48`. Read per request. Unset or shorter, no session is issued or accepted and everyone gets the holding page. Every instance behind one domain needs the same value. `PRESS_PREVIEW_SECRET` is read in its place when `PRESS_SECRET` is unset. See [One secret](#one-secret) |
768
+ | `CMS_RENDERER_KEY` | Optional. Sent to barakoCMS as `X-Barako-Renderer-Key` when a link is redeemed, and must match the renderer key barakoCMS is configured with. Read per request and never logged. Unset, no key header is sent |
769
+
770
+ **Redemption is rate limited per tenant and visitor.** Every redemption leaves this container from the
771
+ same address, so barakoCMS needs the visitor's address to tell visitors apart. Name the header a
772
+ proxy in front sets it in, `sites: { visitorIpHeader: "x-real-ip" }`, and it is sent on as
773
+ `X-Barako-Visitor-IP`. Name it only behind a proxy that sets that header and strips a caller's value,
774
+ the same rule as `hostHeader` and `tenantHeader`. A value that is not exactly one IPv4 or IPv6
775
+ address, a comma separated list included, is not sent. barakoCMS trusts the address only when
776
+ `CMS_RENDERER_KEY` matches, so both are needed for a per visitor limit. With no header named nothing
777
+ is sent: a Next route handler never sees the socket's address, and the `X-Forwarded-For` Next adds
778
+ keeps whatever a caller put there.
779
+
780
+ **A session lasts until the link expires or for 24 hours, whichever is sooner.** Opening the link
781
+ again starts a new one while the link is valid. **A revoked link can keep working for up to 24
782
+ hours** for someone who already opened it, because the session is checked here, not in barakoCMS. To
783
+ end every session now, change `PRESS_SECRET`, which does it for every tenant on that
784
+ deployment, and changes every tenant's webhook key with it.
785
+
786
+ Whether a request gets the holding page is decided by the proxy, from the cookie, once per request,
787
+ and the answer is a segment of the path the render is kept under. A render made for a visitor with a
788
+ session is never served to one without, or the reverse, because the two are different paths. A page
789
+ you write by hand must call `siteConfig(config, params)` before it reads or renders anything, which
790
+ is what keeps it behind the holding page and what gives it its tenant. A build-time site has no
791
+ settings entry and no holding mode.
792
+
793
+ `generateStaticParams` factories return nothing on a request-time site, since there is no tenant at
794
+ build. `scripts/two-hosts.sh` runs the built reference app against a stand-in CMS with two tenants
795
+ and checks all of the above; CI runs it on every push.
147
796
 
148
797
  ### The look is configuration too
149
798
 
@@ -166,7 +815,9 @@ values or build a palette from them.
166
815
 
167
816
  Loading the faces is the site's job, not the engine's. The default theme names Sora, Manrope and
168
817
  JetBrains Mono with real fallback stacks, and a `<link>` in your root layout is what makes them
169
- arrive.
818
+ arrive. `createSiteLayout` writes that link for you: a family name from Google Fonts, or the
819
+ stylesheet named in `theme.fontSources` when the face is not loaded from there, held to
820
+ `PRESS_FONT_ORIGINS` either way.
170
821
 
171
822
  **Three slots**, for what belongs to the site rather than the engine:
172
823
 
@@ -186,6 +837,45 @@ paragraphs belongs to the block model, which is issue #6, because only the body
186
837
  Read time is derived from the body at 200 words a minute, with fenced code blocks excluded, so there
187
838
  is no field to fill in and nothing to keep in sync.
188
839
 
840
+ ### Assets used exactly as supplied
841
+
842
+ Some marks come with an identity manual: never recoloured, never outlined, never put in a box, never
843
+ crowded. A renderer that rounds a corner or drops a logo into a tinted panel breaks that rule, and on
844
+ screen it looks like a nice touch. Mark the asset and it is drawn from the file, with a minimum clear
845
+ space held around it, everywhere the engine draws an image.
846
+
847
+ ```ts
848
+ export const config = defineConfig({
849
+ site: { name: "The club", url: "https://club.example", logo: "/mark.svg" },
850
+ theme: { asSupplied: [{ url: "/mark.svg", clearSpace: "lg" }] },
851
+ });
852
+ ```
853
+
854
+ A tenant says the same thing in its site settings:
855
+
856
+ | Field | Type | What |
857
+ | --- | --- | --- |
858
+ | `AssetsAsSupplied` | list | URLs, or `{ "url": "...", "clearSpace": "lg" }` for one that needs more room. A list saved empty clears the configured one |
859
+ | `LogoAsSupplied` | boolean | Marks `Logo` and `FooterLogo`, so replacing the logo file does not mean editing a second setting that names the old one |
860
+ | `LogoClearSpace` | string | The clear space around those two |
861
+
862
+ The clear space is a name from the theme's spacing scale (`none`, `xs`, `sm`, `md`, `lg`, `xl`,
863
+ `xxl`), `md` when unset, and it is a minimum: a block asking for more gets more, a block asking for
864
+ less gets the site's. A URL is matched without its query, so a mark the CMS resized with `?w=480` is
865
+ still that mark.
866
+
867
+ What a marked asset gets is the file: no tint, no border, no corner, no shadow, no filter, no crop,
868
+ and the clear space around it at every width. A block that asked for a frame draws the mark alone
869
+ instead, which is what the `image` block's own `asSupplied` and `clearSpace` props are for when the
870
+ site has not listed the file. It holds in the header, the footer, the holding page, a header or
871
+ footer region, a page block, a preset, a post's cover image, a collection item's image and an image
872
+ inside markdown, and `src/assets.test.tsx` walks all of those and fails if a new way to draw an image
873
+ skips the rule.
874
+
875
+ One thing it cannot do: it does not know what is behind the mark. A tenant that puts a marked asset
876
+ on an `inverse` band gets the supplied file on that band, drawn plainly. Choosing the band is the
877
+ tenant's, and a white box behind the mark would itself be the boxing the rule forbids.
878
+
189
879
  ### Related posts, if the CMS has the AI module
190
880
 
191
881
  `listRelated` asks `BarakoCMS.AI` for the posts closest to this one and hands them to `PostView`,
@@ -225,12 +915,118 @@ nothing would say why. With it, a missing webhook degrades publishing from insta
225
915
  type, trigger `Published`, one Webhook action with the URL and a shared secret. Nothing is deployed
226
916
  to change it.
227
917
 
918
+ ### The render cache
919
+
920
+ Reads being cached is half of it. The other half is not rendering the page again, and for a
921
+ request-time site that used to be impossible: resolving the tenant meant reading the request host,
922
+ reading a header makes a route dynamic, and Next never keeps a dynamic route. One container serving
923
+ forty domains re-rendered every page for every visitor, with every read under it already cached.
924
+
925
+ Next keys what it keeps by the path and by nothing else. There is no `Vary` and no second key to
926
+ add. So whatever a render depends on has to be in the path, and three things are:
927
+
928
+ | In the segment | Why |
929
+ | --- | --- |
930
+ | the tenant | two tenants must never share an entry. This is the whole reason the segment exists |
931
+ | the gate | `public`, or `shared` for a request carrying a valid share session. A holding tenant answers differently for each, so each gets its own entry |
932
+ | the host | the site's origin when the tenant's settings leave `Url` empty. Two hosts on one tenant are two renders, not one |
933
+
934
+ The proxy writes them, as `/_press/<tenant>~<gate>~<host>/<path>`, and it is the only thing that
935
+ does: a path that arrives under `/_press` from outside is a 404, because otherwise
936
+ `https://one.example/_press/other~public~other.example/` would render another tenant's site on this
937
+ tenant's domain. A host the CMS does not know is a 404 before anything renders, so an invented host
938
+ never makes an entry: only a host a tenant owns can put one there.
939
+
940
+ **What is cached.** The index, the pages tree, the collection indexes and the archives, per tenant
941
+ and per path. `revalidateTag` drops a tenant's renders along with its reads, so the webhook that was
942
+ already purging one purges both, and a publish on one tenant leaves every other tenant's renders in
943
+ place. The first few views of a path answer `STALE` rather than `HIT` while the reads under them are
944
+ still filling Next's data cache; after that it is `HIT` with no render and no read.
945
+
946
+ **What is not cached, deliberately.**
947
+
948
+ | Not cached | Why |
949
+ | --- | --- |
950
+ | a route that reads `?preview=` | a draft must never be in a shared cache, and reading the query is what keeps the route out of it. `createBlogPostPreview` is such a route; `createBlogPost` is not |
951
+ | a page binding `{{query.X}}` | same reason. On a kept route the query is not handed to the blocks at all, so the binding is reported as an unbound scope and renders as nothing. A site that wants it writes `createPage(config, blocks, { query: true })` and leaves `generateStaticParams` out of that file |
952
+ | `createViewerPage` | per viewer by definition. It calls `connection()`, and a route that does cannot be kept |
953
+ | the feed, the sitemap and robots | `sitemap.ts` and `robots.ts` have to sit at the root of the app tree, so neither can carry a tenant segment. They resolve their own tenant and stay dynamic |
954
+ | `/_share` and the redeem route | per visitor |
955
+
956
+ **Which routes are kept is the consumer's call, in the consumer's file**, the same as `revalidate`.
957
+ A page route that exports `createSiteStaticParams()` is kept; one that does not is rendered per
958
+ request. That is also the switch for the two rows above: a route that reads the query leaves it out.
959
+
960
+ **Nothing downstream may keep a copy.** Every rewritten answer carries `Cache-Control: private,
961
+ no-store`, which is what a request-time site has always sent. Next's cache is the server's own and is
962
+ not that header, so the render is still kept here. What stays true is that a browser or a proxy
963
+ holding `/` is never holding one visitor's gate for the next visitor.
964
+
965
+ ### One secret
966
+
967
+ `PRESS_SECRET` keys everything this renderer signs: each tenant's webhook key and each share session
968
+ cookie. Every signature puts its purpose first in what it signs (`revalidate.` or `press-share.`), so
969
+ one made for one purpose never verifies as another. It is read per request and has one rule for every
970
+ purpose: at least 32 characters, for example `openssl rand -base64 48`.
971
+
972
+ | When `PRESS_SECRET` is | Webhooks | Share sessions |
973
+ | --- | --- | --- |
974
+ | 32 characters or more | verified with it | signed with it |
975
+ | set but shorter | refused with 503 | none issued or accepted |
976
+ | unset | `REVALIDATE_SECRET`, as in 0.3.0 | `PRESS_PREVIEW_SECRET` |
977
+
978
+ The older names are read only when `PRESS_SECRET` is unset, so a site that set them keeps working, and
979
+ the keys derive byte for byte as before, so a key already pasted into a tenant's workflow keeps
980
+ verifying. A `REVALIDATE_SECRET` shorter than 32 characters still verifies and logs a warning once,
981
+ until 1.0.0. A `PRESS_PREVIEW_SECRET` shorter than 32 characters opens no session, as before.
982
+
983
+ Moving to the new name is copying the value: `PRESS_SECRET` set to what `REVALIDATE_SECRET` held
984
+ derives the same tenant keys, provided it is 32 characters or more. A shorter one has to be replaced,
985
+ and each tenant given its new key.
986
+
228
987
  ### Wiring the webhook
229
988
 
230
- 1. Put a long random value in `REVALIDATE_SECRET` where your app runs.
989
+ 1. Put a random value of at least 32 characters in `PRESS_SECRET` where your app runs.
231
990
  2. In barakoBrew, create a workflow on your post content type, event `Published`.
232
991
  3. Add a Webhook action with `Url` set to `https://your-site/api/revalidate` and `Secret` set to the
233
- same value.
992
+ same value. On a site with `sites` configured, the `Secret` is the tenant's key instead, below.
993
+
994
+ ### One key per tenant
995
+
996
+ With `sites` configured, one app serves many tenants, and each tenant's admin can read and edit its
997
+ own workflows. So no tenant is given `PRESS_SECRET`. Each delivery is verified with a key derived
998
+ for the tenant its host resolves to:
999
+
1000
+ ```
1001
+ key = lowercase hex HMAC-SHA256(PRESS_SECRET, "revalidate." + tenant handle)
1002
+ ```
1003
+
1004
+ A delivery signed with one tenant's key gets a 401 on every other tenant's host, and so does one
1005
+ signed with `PRESS_SECRET` itself. Print a tenant's key where `PRESS_SECRET` (or `REVALIDATE_SECRET`) is set:
1006
+
1007
+ ```bash
1008
+ npx barakopress revalidate-key baryo
1009
+ # or, with no Node:
1010
+ printf 'revalidate.%s' baryo | openssl dgst -sha256 -hmac "${PRESS_SECRET:-$REVALIDATE_SECRET}" | sed 's/^.* //'
1011
+ ```
1012
+
1013
+ Both read the secret from the environment and print only the key. Paste it into the `Secret` of that
1014
+ tenant's Webhook action, with `Url` on that tenant's domain. A new tenant needs nothing on the
1015
+ renderer. Changing `PRESS_SECRET` changes every tenant's key, so each workflow needs its new one.
1016
+
1017
+ A site without `sites` (one build, one site) verifies with `PRESS_SECRET` itself, as it did with
1018
+ `REVALIDATE_SECRET`. Its workflow holds that value, so give it a value no other deployment uses.
1019
+
1020
+ **Upgrading a multi-tenant site to 0.4.0 is a breaking change.** Every tenant's workflow holds
1021
+ `REVALIDATE_SECRET` today, and after the upgrade that value verifies for no tenant: deliveries get a
1022
+ 401 and purges stop, with the backstop the only refresh. Before or right after deploying, print each
1023
+ tenant's key and put it in that tenant's Webhook `Secret`. Then set a new `PRESS_SECRET` and print
1024
+ the keys again, because every tenant admin has seen the old value and could derive any tenant's key
1025
+ from it.
1026
+
1027
+ The barakoCMS webhook body does not name its tenant yet, so the host is what picks the key. Once the
1028
+ body carries the tenant (barakoCMS #868), a delivery whose body names a different tenant than its host
1029
+ resolves to is to be refused.
234
1030
 
235
1031
  **If your site sets `trailingSlash: true`, the webhook URL needs the slash.** Next redirects
236
1032
  `/api/revalidate` to `/api/revalidate/` with a 308, and barakoCMS does not follow redirects on a
@@ -245,8 +1041,9 @@ way.
245
1041
 
246
1042
  Verification follows the recipe in the API's `docs/webhooks.md`: HMAC-SHA256 over
247
1043
  `"<timestamp>.<raw body>"`, compared in constant time, with a 300 second tolerance so a captured
248
- delivery cannot be replayed later, and each signature honoured once. An unsigned, missigned or stale
249
- delivery gets a 401. If `REVALIDATE_SECRET` is unset the endpoint answers 503 and purges nothing,
1044
+ delivery cannot be replayed later, and each signature honoured once per tenant. An unsigned, missigned or stale
1045
+ delivery gets a 401. If neither `PRESS_SECRET` nor `REVALIDATE_SECRET` is set, or `PRESS_SECRET` is
1046
+ shorter than 32 characters, the endpoint answers 503 and purges nothing,
250
1047
  because an open cache-purge endpoint is a free denial of service.
251
1048
 
252
1049
  ## What it reads
@@ -267,6 +1064,20 @@ attribute. An editor is authenticated, so this is the second line of defence, no
267
1064
  trade is that an author cannot embed raw HTML or an iframe. When you want that, the answer is a
268
1065
  content field your frontend renders deliberately, not a hole here.
269
1066
 
1067
+ The same renderer is its own entry, `barakopress/markdown`, for code that runs in the browser, such
1068
+ as an editor previewing a post. The only package it reaches is `marked`, where the main entry also
1069
+ pulls in `next/cache`, `next/server` and `node:crypto`.
1070
+
1071
+ ```ts
1072
+ import { renderMarkdown } from "barakopress/markdown";
1073
+
1074
+ renderMarkdown(source); // what the site renders
1075
+ renderMarkdown(source, { headingIds: false, newTab: true }); // no ids, links open a new tab
1076
+ ```
1077
+
1078
+ Both options change only the markup around what is already sanitised. `headingIds` defaults to true
1079
+ and `newTab` to false, which is the site's output.
1080
+
270
1081
  ## Two behaviours that were measured, not assumed
271
1082
 
272
1083
  **`revalidateTag` needs `{ expire: 0 }`, not a named profile.** Next 16 made the second argument
@@ -285,7 +1096,7 @@ does, so the package cannot quietly depend on something only its own repository
285
1096
  container, a compose stack with Caddy terminating TLS, and a BaryoVM release manifest.
286
1097
 
287
1098
  ```bash
288
- cp .env.example .env # point CMS_URL at your instance, set REVALIDATE_SECRET
1099
+ cp .env.example .env # point CMS_URL at your instance, set PRESS_SECRET
289
1100
  npm install
290
1101
  npm run build && npm start
291
1102
  ```
@@ -302,10 +1113,35 @@ baryovm stack release blog
302
1113
 
303
1114
  Before the first release, three DNS names have to point at the machine (`SITE_DOMAIN`, `API_DOMAIN`,
304
1115
  `CONSOLE_DOMAIN`) and a `.env` has to exist on it at `/opt/barakopress/.env`. The release refuses to
305
- start without that file, and refuses again if `REVALIDATE_SECRET` is empty, because a stack that
1116
+ start without that file, and refuses again if `PRESS_SECRET` and `REVALIDATE_SECRET` are both empty, because a stack that
306
1117
  comes up with no secret can never be told that content changed. Only ports 80 and 443 are published:
307
1118
  the API, the console and the site are reachable only through Caddy on the compose network.
308
1119
 
1120
+ ## The look check
1121
+
1122
+ Before a site's domain moves to this stack, the look check proves the rebuilt pages look like the
1123
+ design that was approved. It takes pairs, a reference and a rebuilt page, screenshots both at 390px
1124
+ and 1280px in the same run, and fails the page whose pixels moved more than that page allows,
1125
+ handing back the reference, the rebuilt screenshot and a diff image.
1126
+
1127
+ The reference is either a prototype file on disk with a page state, or the live URL of the site
1128
+ being replaced. The pages themselves are data the site owns, so adding a page, or a whole site,
1129
+ never means editing the job:
1130
+
1131
+ ```yaml
1132
+ jobs:
1133
+ look:
1134
+ uses: BaryoDev/barakoPress/.github/workflows/look-check.yml@master
1135
+ with:
1136
+ pairs: look/pairs.json
1137
+ variables: '{"REBUILT_BASE":"https://staging.rckoronadal.org"}'
1138
+ ```
1139
+
1140
+ The pair list format, the per page threshold and the list of things that would otherwise make a run
1141
+ flake are in [docs/look-check.md](docs/look-check.md). `npm run look:selftest` runs the check twice
1142
+ over fixture pages, once green and once against a deliberate colour change, which is how anyone can
1143
+ see for themselves that it fails when it should.
1144
+
309
1145
  ## Working on the engine
310
1146
 
311
1147
  ```bash
@@ -329,5 +1165,4 @@ Releasing is in [RELEASING.md](RELEASING.md). No npm token is stored anywhere.
329
1165
  ## Status
330
1166
 
331
1167
  Early. The blog works end to end against a real instance. Known gaps, all tracked upstream: no media
332
- picker, no rich editor, no site settings object, and a content type's fields cannot be changed after
333
- it is created.
1168
+ picker, no rich editor, and no visitor theme variants.