barakopress 0.4.0 → 0.8.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 (249) hide show
  1. package/README.md +1064 -54
  2. package/dist/blocks/bind.d.ts +20 -0
  3. package/dist/blocks/bind.d.ts.map +1 -1
  4. package/dist/blocks/bind.js +523 -40
  5. package/dist/blocks/bind.js.map +1 -1
  6. package/dist/blocks/bindings.d.ts +65 -4
  7. package/dist/blocks/bindings.d.ts.map +1 -1
  8. package/dist/blocks/bindings.js +79 -8
  9. package/dist/blocks/bindings.js.map +1 -1
  10. package/dist/blocks/built-in.d.ts +19 -1
  11. package/dist/blocks/built-in.d.ts.map +1 -1
  12. package/dist/blocks/built-in.js +42 -18
  13. package/dist/blocks/built-in.js.map +1 -1
  14. package/dist/blocks/data.d.ts +6 -0
  15. package/dist/blocks/data.d.ts.map +1 -1
  16. package/dist/blocks/data.js +68 -2
  17. package/dist/blocks/data.js.map +1 -1
  18. package/dist/blocks/filter-bar.d.ts +17 -0
  19. package/dist/blocks/filter-bar.d.ts.map +1 -0
  20. package/dist/blocks/filter-bar.js +14 -0
  21. package/dist/blocks/filter-bar.js.map +1 -0
  22. package/dist/blocks/filter.d.ts +34 -0
  23. package/dist/blocks/filter.d.ts.map +1 -0
  24. package/dist/blocks/filter.js +84 -0
  25. package/dist/blocks/filter.js.map +1 -0
  26. package/dist/blocks/library.d.ts.map +1 -1
  27. package/dist/blocks/library.js +300 -12
  28. package/dist/blocks/library.js.map +1 -1
  29. package/dist/blocks/motion.d.ts +18 -0
  30. package/dist/blocks/motion.d.ts.map +1 -0
  31. package/dist/blocks/motion.js +0 -0
  32. package/dist/blocks/motion.js.map +1 -0
  33. package/dist/blocks/plugins.d.ts +18 -0
  34. package/dist/blocks/plugins.d.ts.map +1 -0
  35. package/dist/blocks/plugins.js +49 -0
  36. package/dist/blocks/plugins.js.map +1 -0
  37. package/dist/blocks/presets.d.ts +7 -1
  38. package/dist/blocks/presets.d.ts.map +1 -1
  39. package/dist/blocks/presets.js +80 -29
  40. package/dist/blocks/presets.js.map +1 -1
  41. package/dist/blocks/primitives.d.ts +18 -0
  42. package/dist/blocks/primitives.d.ts.map +1 -1
  43. package/dist/blocks/primitives.js +789 -42
  44. package/dist/blocks/primitives.js.map +1 -1
  45. package/dist/blocks/registry.d.ts +6 -0
  46. package/dist/blocks/registry.d.ts.map +1 -1
  47. package/dist/blocks/registry.js +100 -4
  48. package/dist/blocks/registry.js.map +1 -1
  49. package/dist/blocks/render.d.ts.map +1 -1
  50. package/dist/blocks/render.js +34 -2
  51. package/dist/blocks/render.js.map +1 -1
  52. package/dist/blocks/schema.d.ts +99 -11
  53. package/dist/blocks/schema.d.ts.map +1 -1
  54. package/dist/blocks/schema.js +243 -35
  55. package/dist/blocks/schema.js.map +1 -1
  56. package/dist/blocks/search-attrs.d.ts +7 -0
  57. package/dist/blocks/search-attrs.d.ts.map +1 -0
  58. package/dist/blocks/search-attrs.js +14 -0
  59. package/dist/blocks/search-attrs.js.map +1 -0
  60. package/dist/blocks/search-keys.d.ts +19 -0
  61. package/dist/blocks/search-keys.d.ts.map +1 -0
  62. package/dist/blocks/search-keys.js +197 -0
  63. package/dist/blocks/search-keys.js.map +1 -0
  64. package/dist/blocks/tokens.d.ts +4 -2
  65. package/dist/blocks/tokens.d.ts.map +1 -1
  66. package/dist/blocks/tokens.js +69 -7
  67. package/dist/blocks/tokens.js.map +1 -1
  68. package/dist/blocks/tree-blocks.d.ts +4 -0
  69. package/dist/blocks/tree-blocks.d.ts.map +1 -0
  70. package/dist/blocks/tree-blocks.js +108 -0
  71. package/dist/blocks/tree-blocks.js.map +1 -0
  72. package/dist/cms.d.ts +48 -5
  73. package/dist/cms.d.ts.map +1 -1
  74. package/dist/cms.js +161 -88
  75. package/dist/cms.js.map +1 -1
  76. package/dist/collections.d.ts +63 -2
  77. package/dist/collections.d.ts.map +1 -1
  78. package/dist/collections.js +127 -8
  79. package/dist/collections.js.map +1 -1
  80. package/dist/config.d.ts +381 -5
  81. package/dist/config.d.ts.map +1 -1
  82. package/dist/config.js +86 -3
  83. package/dist/config.js.map +1 -1
  84. package/dist/current-path.d.ts +18 -0
  85. package/dist/current-path.d.ts.map +1 -0
  86. package/dist/current-path.js +53 -0
  87. package/dist/current-path.js.map +1 -0
  88. package/dist/delivery.d.ts +27 -1
  89. package/dist/delivery.d.ts.map +1 -1
  90. package/dist/delivery.js +306 -85
  91. package/dist/delivery.js.map +1 -1
  92. package/dist/env.d.ts +1 -1
  93. package/dist/env.d.ts.map +1 -1
  94. package/dist/index.d.ts +35 -21
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +21 -14
  97. package/dist/index.js.map +1 -1
  98. package/dist/markdown.d.ts +25 -0
  99. package/dist/markdown.d.ts.map +1 -1
  100. package/dist/markdown.js +146 -0
  101. package/dist/markdown.js.map +1 -1
  102. package/dist/recipes.d.ts +50 -0
  103. package/dist/recipes.d.ts.map +1 -0
  104. package/dist/recipes.js +330 -0
  105. package/dist/recipes.js.map +1 -0
  106. package/dist/related.d.ts +19 -2
  107. package/dist/related.d.ts.map +1 -1
  108. package/dist/related.js +45 -14
  109. package/dist/related.js.map +1 -1
  110. package/dist/revalidate-key.d.ts +6 -1
  111. package/dist/revalidate-key.d.ts.map +1 -1
  112. package/dist/revalidate-key.js +23 -7
  113. package/dist/revalidate-key.js.map +1 -1
  114. package/dist/routes/binding-report.d.ts +37 -0
  115. package/dist/routes/binding-report.d.ts.map +1 -0
  116. package/dist/routes/binding-report.js +141 -0
  117. package/dist/routes/binding-report.js.map +1 -0
  118. package/dist/routes/block-schema.d.ts +4 -0
  119. package/dist/routes/block-schema.d.ts.map +1 -1
  120. package/dist/routes/block-schema.js +52 -4
  121. package/dist/routes/block-schema.js.map +1 -1
  122. package/dist/routes/revalidate.d.ts +1 -0
  123. package/dist/routes/revalidate.d.ts.map +1 -1
  124. package/dist/routes/revalidate.js +100 -30
  125. package/dist/routes/revalidate.js.map +1 -1
  126. package/dist/routes/share.d.ts +1 -2
  127. package/dist/routes/share.d.ts.map +1 -1
  128. package/dist/routes/share.js +40 -10
  129. package/dist/routes/share.js.map +1 -1
  130. package/dist/routes/sitemap.d.ts +5 -1
  131. package/dist/routes/sitemap.d.ts.map +1 -1
  132. package/dist/routes/sitemap.js +75 -6
  133. package/dist/routes/sitemap.js.map +1 -1
  134. package/dist/screens/archive.d.ts +4 -0
  135. package/dist/screens/archive.d.ts.map +1 -1
  136. package/dist/screens/archive.js +4 -0
  137. package/dist/screens/archive.js.map +1 -1
  138. package/dist/screens/article-view.d.ts +29 -0
  139. package/dist/screens/article-view.d.ts.map +1 -0
  140. package/dist/screens/article-view.js +217 -0
  141. package/dist/screens/article-view.js.map +1 -0
  142. package/dist/screens/blog-index.d.ts +4 -1
  143. package/dist/screens/blog-index.d.ts.map +1 -1
  144. package/dist/screens/blog-post.d.ts.map +1 -1
  145. package/dist/screens/blog-post.js +10 -2
  146. package/dist/screens/blog-post.js.map +1 -1
  147. package/dist/screens/collection.d.ts +26 -5
  148. package/dist/screens/collection.d.ts.map +1 -1
  149. package/dist/screens/collection.js +176 -24
  150. package/dist/screens/collection.js.map +1 -1
  151. package/dist/screens/header-menu.d.ts +54 -0
  152. package/dist/screens/header-menu.d.ts.map +1 -0
  153. package/dist/screens/header-menu.js +84 -0
  154. package/dist/screens/header-menu.js.map +1 -0
  155. package/dist/screens/page-blocks.d.ts +16 -0
  156. package/dist/screens/page-blocks.d.ts.map +1 -0
  157. package/dist/screens/page-blocks.js +30 -0
  158. package/dist/screens/page-blocks.js.map +1 -0
  159. package/dist/screens/page.d.ts +21 -16
  160. package/dist/screens/page.d.ts.map +1 -1
  161. package/dist/screens/page.js +127 -41
  162. package/dist/screens/page.js.map +1 -1
  163. package/dist/screens/post-view.d.ts +7 -3
  164. package/dist/screens/post-view.d.ts.map +1 -1
  165. package/dist/screens/post-view.js +18 -183
  166. package/dist/screens/post-view.js.map +1 -1
  167. package/dist/screens/site-layout.d.ts +1 -1
  168. package/dist/screens/site-layout.d.ts.map +1 -1
  169. package/dist/screens/site-layout.js +150 -14
  170. package/dist/screens/site-layout.js.map +1 -1
  171. package/dist/screens/tree.d.ts +157 -0
  172. package/dist/screens/tree.d.ts.map +1 -0
  173. package/dist/screens/tree.js +535 -0
  174. package/dist/screens/tree.js.map +1 -0
  175. package/dist/secret.d.ts +6 -1
  176. package/dist/secret.d.ts.map +1 -1
  177. package/dist/secret.js +8 -1
  178. package/dist/secret.js.map +1 -1
  179. package/dist/site-route.d.ts +2 -2
  180. package/dist/site-route.d.ts.map +1 -1
  181. package/dist/site-route.js +2 -2
  182. package/dist/site-route.js.map +1 -1
  183. package/dist/site.d.ts.map +1 -1
  184. package/dist/site.js +387 -35
  185. package/dist/site.js.map +1 -1
  186. package/dist/store.d.ts +36 -0
  187. package/dist/store.d.ts.map +1 -0
  188. package/dist/store.js +98 -0
  189. package/dist/store.js.map +1 -0
  190. package/dist/theme.d.ts +72 -3
  191. package/dist/theme.d.ts.map +1 -1
  192. package/dist/theme.js +161 -7
  193. package/dist/theme.js.map +1 -1
  194. package/dist/tree.d.ts +99 -0
  195. package/dist/tree.d.ts.map +1 -0
  196. package/dist/tree.js +318 -0
  197. package/dist/tree.js.map +1 -0
  198. package/package.json +4 -1
  199. package/src/blocks/bind.ts +605 -41
  200. package/src/blocks/bindings.ts +100 -9
  201. package/src/blocks/built-in.tsx +67 -18
  202. package/src/blocks/data.tsx +102 -2
  203. package/src/blocks/filter-bar.tsx +57 -0
  204. package/src/blocks/filter.ts +99 -0
  205. package/src/blocks/library.ts +323 -17
  206. package/src/blocks/motion.ts +0 -0
  207. package/src/blocks/plugins.ts +83 -0
  208. package/src/blocks/presets.ts +77 -31
  209. package/src/blocks/primitives.tsx +1244 -104
  210. package/src/blocks/registry.ts +108 -6
  211. package/src/blocks/render.tsx +43 -2
  212. package/src/blocks/schema.ts +340 -40
  213. package/src/blocks/search-attrs.ts +14 -0
  214. package/src/blocks/search-keys.tsx +194 -0
  215. package/src/blocks/tokens.ts +69 -8
  216. package/src/blocks/tree-blocks.tsx +127 -0
  217. package/src/cms.ts +170 -98
  218. package/src/collections.ts +169 -10
  219. package/src/config.ts +468 -8
  220. package/src/current-path.ts +59 -0
  221. package/src/delivery.ts +346 -80
  222. package/src/env.ts +1 -1
  223. package/src/index.ts +107 -8
  224. package/src/markdown.ts +151 -0
  225. package/src/recipes.ts +349 -0
  226. package/src/related.ts +56 -14
  227. package/src/revalidate-key.ts +27 -7
  228. package/src/routes/binding-report.ts +210 -0
  229. package/src/routes/block-schema.ts +78 -4
  230. package/src/routes/revalidate.ts +99 -28
  231. package/src/routes/share.ts +40 -11
  232. package/src/routes/sitemap.ts +79 -7
  233. package/src/screens/archive.tsx +4 -0
  234. package/src/screens/article-view.tsx +480 -0
  235. package/src/screens/blog-post.tsx +10 -2
  236. package/src/screens/collection.tsx +335 -44
  237. package/src/screens/header-menu.tsx +268 -0
  238. package/src/screens/page-blocks.ts +46 -0
  239. package/src/screens/page.tsx +153 -56
  240. package/src/screens/post-view.tsx +34 -383
  241. package/src/screens/site-layout.tsx +212 -19
  242. package/src/screens/tree.tsx +1117 -0
  243. package/src/secret.ts +7 -1
  244. package/src/site-route.ts +2 -2
  245. package/src/site.ts +416 -42
  246. package/src/store.ts +117 -0
  247. package/src/styles.css +9 -0
  248. package/src/theme.ts +216 -10
  249. package/src/tree.ts +385 -0
package/README.md CHANGED
@@ -83,7 +83,7 @@ decision, not the engine's.
83
83
 
84
84
  | Create | Export | From |
85
85
  | --- | --- | --- |
86
- | `app/page.tsx` | `default` | `createBlogIndex(config)` |
86
+ | `app/page.tsx` | `default`, `generateMetadata` | `createHome(config, blocks)`, `createHomeMetadata(config)` |
87
87
  | `app/blog/[slug]/page.tsx` | `default`, `generateMetadata` | `createBlogPost(config)`, `createPostMetadata(config)` |
88
88
  | `app/authors/[slug]/page.tsx` | `default` | `createArchive(config, "author")` |
89
89
  | `app/categories/[slug]/page.tsx` | `default` | `createArchive(config, "category")` |
@@ -94,11 +94,16 @@ decision, not the engine's.
94
94
  | `app/[...path]/page.tsx` | `default`, `generateMetadata` | `createPage(config, blocks)`, `createPageMetadata(config)` |
95
95
  | `app/doctors/page.tsx` | `default` | `createCollectionIndex(config, "doctors")` |
96
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()` |
97
+ | `app/api/blocks/route.ts` | `GET`, `OPTIONS` | `createBlockSchemaRoute(config, blocks)`, `createBlockSchemaPreflight()` |
98
+ | `app/api/blocks/bindings/route.ts` | `GET`, `OPTIONS` | `createBindingReportRoute(config, blocks)`, `createBindingReportPreflight()` |
98
99
  | `app/layout.tsx` | `default`, `generateMetadata` | `createSiteLayout(config, { blocks })`, `createSiteMetadata(config)` |
99
- | `app/%5Fshare/route.ts` | `GET` | `createSharePage()` |
100
+ | `app/%5Fshare/route.ts` | `GET` | `createSharePage(config)` |
100
101
  | `app/api/share/redeem/route.ts` | `POST` | `createShareRedeemRoute(config)` |
101
102
 
103
+ `createHome` serves whatever the tenant picked for `/`: the page at `HomePath`, the index of
104
+ `HomeCollection`, or the post index when it picked neither, which is what `createBlogIndex` did and
105
+ still does for a site that mounts that instead.
106
+
102
107
  Mount only what you want. Nothing requires anything else. The paths only have to agree with the
103
108
  `routes` in your config, which is what every generated link is built from.
104
109
 
@@ -107,6 +112,27 @@ It reads `searchParams`, which forces the route dynamic, so a site using `output
107
112
  `createBlogPost` and gives up preview. `createPostStaticParams` and `createArchiveStaticParams` exist
108
113
  for that static case.
109
114
 
115
+ A site that draws its own post page keeps preview by giving the detail route its own view, and maps
116
+ the item with the engine's own mapping:
117
+
118
+ ```tsx
119
+ export default createCollectionDetail(config, POST_COLLECTION, {
120
+ preview: true,
121
+ view: ({ config, item, preview }) => <MyPost post={postFromItem(config, item)} preview={preview} />,
122
+ });
123
+ ```
124
+
125
+ The binding report says why a binding on a page did not resolve, so an editor fixes it in barakoBrew
126
+ instead of asking whoever can read the server log. Ask for one page at a time, by `?slug=` or
127
+ `?path=`, and each problem names the binding as typed, the reason (`unknown scope`, `unbound scope`
128
+ or `no value`), and the block and field it came from. It reports the page as rendered, not as
129
+ stored, so a `{{item.X}}` outside a repeat is reported as an unbound scope. Give it the same `query`
130
+ option the page route has: off, which is the default, a `{{query.X}}` is an unbound scope for every
131
+ visitor and the report says so, and a report that resolved one anyway would tell an editor a field
132
+ works where it does not. It is never anonymous:
133
+ the caller presents the tenant's key as `Authorization: Bearer`, derived from `PRESS_SECRET` and
134
+ printed by `barakopress bindings-key <tenant>`. Answers are never cached.
135
+
110
136
  `Card` and `PostView` are exported too, for a site that wants its own page but the engine's markup.
111
137
 
112
138
  ### Collections: any content type as a list and a detail page
@@ -144,20 +170,26 @@ merged over the configured collections by key. A build-time site passes `collect
144
170
  | --- | --- |
145
171
  | `type` | The content type. Required |
146
172
  | `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 |
173
+ | `fields` | `title` (required), `slug`, `summary`, `body` (markdown), `date`, `image`, `imageAlt`, `featured`, `tags`, `url`, `photo`, `progress`, `progressCount`, `progressTotal`, `href`. Each is a field name or a list tried in order; `@createdAt` and `@updatedAt` read the entry itself. `photo` is a portrait drawn above the title, `image` the wide one, and `progress` is a number from 0 to 100 that `progressList` draws. `progressCount` and `progressTotal` draw the same bar from two counts instead, for a source that gives those rather than a figure already worked out; `progress` wins when both are set. `href` is where a card links when that is not the item's own route: a collection filled by a sync usually carries the source's own URL and has no route on this site |
148
174
  | `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
175
  | `sort` | Sent to the API, for example `-PublishedAt` |
150
176
  | `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 |
177
+ | `sitemap` | On unless `false`. A collection is paged to `pageSizes.sitemap` entries; the file stops at the standard's 50,000 URLs and says so in the log |
178
+ | `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. An object is the index's copy, and the index is on. See below |
179
+ | `indexPage` | A site path such as `/site/blog`. The blocks of the page served there are drawn above the list on the index. See below |
180
+ | `defaultAuthor` | The byline name on an entry that names no author, on its card and on its `article` page |
153
181
  | `pageSize`, `label`, `noun` | Items on the index, its heading, and how a count reads |
154
182
  | `colorBy` | A choice field whose option colours the item. See below |
183
+ | `related` | What an item page lists under the item: `"semantic"` for the items of this collection closest to it by meaning, `false` for none. Unset, the first collection that references this one |
184
+ | `readingTime` | Whether an item page shows a read time worked out from its body. Off unless `true` |
185
+ | `layout` | How an item page is drawn: `"list"`, the shell markup every collection has had, or `"article"`, the reading column the blog's posts are drawn in. See below |
186
+ | `tree` | Set when the collection is a manual rather than a flat list: sections, an order, nesting, products. See below |
155
187
 
156
188
  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.
189
+ plain identifier, a route that is not a plain site path, or no title field. `post`, `author` and
190
+ `category` are replaceable by key like any other collection, since every read goes through the
191
+ collection's own field map. A build-time collection mounted at `/` throws in `defineConfig`. When two
192
+ routes match, the longer one wins.
161
193
 
162
194
  A request-time site cannot add a route file per tenant, so the root catch-all from the pages section
163
195
  also serves every collection with a route: its index at the route, and an item one segment below. A
@@ -168,22 +200,267 @@ lists its doctors, the way an author's archive lists their posts. `listRelated(c
168
200
  department, { via: "Department" })` returns the same list; `listRelated(config, post)` still returns
169
201
  related posts by semantic search.
170
202
 
203
+ With `related: "semantic"` it lists the items of its own collection nearest it by meaning instead, so
204
+ an agency's case study gets the band a post has. That needs the CMS AI module: without it the search
205
+ answers nothing and the page draws no band at all, which is the same way the post page degrades.
206
+ `listRelatedItems(config, key, item)` returns that list on its own.
207
+
208
+ **The index's own words.** An editor sets what a collection's index says in the collection's
209
+ settings, rather than a theme writing a route file for it:
210
+
211
+ ```json
212
+ {
213
+ "blog": {
214
+ "type": "post", "route": "/blog", "fields": { "title": "Title" },
215
+ "index": {
216
+ "eyebrow": "Changelog", "heading": "What shipped",
217
+ "lede": "Every release, newest first.",
218
+ "empty": "Nothing shipped yet.", "unavailable": "The changelog is resting. Try again shortly."
219
+ },
220
+ "indexPage": "/site/blog",
221
+ "defaultAuthor": "The barakoCMS team"
222
+ }
223
+ }
224
+ ```
225
+
226
+ `eyebrow` is a short line above the heading, `heading` replaces `label`, `lede` replaces the site's
227
+ tagline under it, `empty` is what an index with nothing published says, and `unavailable` what one
228
+ whose read failed says, each in place of the `labels` lines that say it otherwise. Every line is
229
+ optional, one of the wrong kind or too long is dropped on its own, and a route file's own `heading`
230
+ still wins. `indexPage` draws a page's blocks above the list, so the index is composed like any page
231
+ and the route stays the collection's. With `defaultAuthor`, an entry whose first reference (the
232
+ byline) did not come back, or a collection with no reference at all, is signed with that name after
233
+ `labels.by`. Set none of the three and the index and the byline draw what they always did.
234
+
235
+ A page named by `indexPage` only holds data, the way a header or footer region page does: it answers
236
+ 404 at its own path and is left out of the menu and the sitemap.
237
+
171
238
  **Filtering.** `listCollection(config, "doctors", { filter: { Department: "cardiology" } })` takes a
172
239
  reference field by the target's slug and any other field by the value it holds, a choice field by its
173
240
  option. The API takes five filters. `createCollectionIndex(config, key, { filter })` takes the same,
174
241
  and the `collection` block has `filterField` and `filterValue`.
175
242
 
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.
243
+ **A style per option.** With `colorBy: "AreaOfFocus"` on a `project` collection, the site settings
244
+ `OptionStyles` entry says how each option of that field is shown:
245
+
246
+ ```json
247
+ {
248
+ "OptionStyles": {
249
+ "project.AreaOfFocus": {
250
+ "Providing clean water": { "tone": "sky", "icon": "location", "label": "Water" }
251
+ }
252
+ }
253
+ }
254
+ ```
255
+
256
+ `tone` names a colour from `Colors`, a theme slot, or a colour written out, and each card, item page
257
+ and collection block item carries it as a left border. `icon` is one of the engine's icon names, and
258
+ `label` is the word a visitor reads in place of the option's own value; the value stays on the element
259
+ as `data-option`, where a site's own CSS can still find it. A tone that does not resolve to something
260
+ readable as a colour is dropped, a label past 40 characters is dropped, and an icon name nothing
261
+ draws draws nothing, each on its own rather than losing the whole entry.
262
+
263
+ `OptionColors` is the same thing said shorter, `{ "project.AreaOfFocus": { "Providing clean water":
264
+ "sky" } }`, an option whose style is a tone and nothing else. A tenant that saved colours keeps them,
265
+ and a style for the same option wins field by field. A build-time site passes `optionStyles`, or
266
+ `optionColors`, in the same shapes.
182
267
 
183
268
  `Card` and `ItemView` are exported for a site that wants its own page, and `Card` still takes a `post`.
184
269
  `getItem`, `listCollection` and `getGlobals(config)`, the tenant's settings entry as stored, are
185
270
  exported too.
186
271
 
272
+ **A long-form layout.** `layout: "article"` draws an item as a reading column: a back link, the title
273
+ and standfirst, a byline from the collection's first reference, the date, a read time worked out from
274
+ the body, the other references, the tags, the cover image, the prose and a band of neighbours under
275
+ it. That is the page the blog's posts have always had, and it is a collection setting rather than a
276
+ blog screen, so a law firm's briefings or a newsroom's features get it by asking. Styled inline from
277
+ the theme, so it looks right whether or not the consumer imports `barakopress/styles.css`.
278
+ `PostView` is a wrapper over the same layout and is `@deprecated` for 1.0.0.
279
+
280
+ **Docs: a collection as a tree.** A manual is a collection plus four fields saying where each page
281
+ sits. `tree` names them, and the settings beside them say what the sidebar and the switcher show:
282
+
283
+ ```json
284
+ {
285
+ "docs": {
286
+ "type": "doc", "route": "/docs", "label": "Documentation", "layout": "article",
287
+ "fields": { "title": "Title", "slug": "Slug", "body": "Body" },
288
+ "tree": {
289
+ "section": "Section", "order": "Order", "parent": "Parent", "product": "Product",
290
+ "sections": ["Getting started", "Guides", "Reference"],
291
+ "searchPath": "/docs",
292
+ "editPath": "Source",
293
+ "editBase": "https://github.com/owner/repo/edit/master/",
294
+ "products": [
295
+ { "key": "cms", "label": "barakoCMS", "href": "/docs" },
296
+ { "key": "press", "label": "barakoPress", "href": "/docs/press" }
297
+ ]
298
+ }
299
+ }
300
+ }
301
+ ```
302
+
303
+ | Key | What |
304
+ | --- | --- |
305
+ | `section` | The field holding the heading a page is grouped under |
306
+ | `order` | The field holding its position in that section. A page with none comes after those with one |
307
+ | `parent` | The field holding the slug of the page it hangs under, or a reference to it. A parent nobody has leaves the page at the top of its section rather than dropping it |
308
+ | `product` | The field naming the product it documents, matched against a product's `key`. One field name and not a list, since this one goes into an API filter |
309
+ | `sections` | The sections in the order the sidebar shows them. One not named here follows those that are. Named rather than worked out, because no ordering of the pages says which section comes first |
310
+ | `products` | What the switcher offers: a key, the word a reader sees, and where it goes. A destination that is not a site path or an http URL is dropped |
311
+ | `searchPath` | Where the search box submits, and whether one is drawn at all. Unset, no box, because only the site knows which of its routes reads the query |
312
+ | `editPath` | The field holding the page's path in whatever repository it is written in. Its slug when unset |
313
+ | `editBase` | Where "edit this page" points, with that path appended. Unset, no such link is drawn |
314
+ | `limit` | The most pages read to build the tree. 500 unless set, and 500 is the ceiling as well as the default |
315
+ | `variant` | How the parts are laid out. See "Styling the tree" below. Every part is today's layout when unset |
316
+ | `searchIndex` | `true` to search the tree's titles and headings in the page as the reader types. The box is drawn whenever this is set; with `searchPath` too, a reader with no script submits there |
317
+ | `icons` | `{ "search": "#id", "chevron": "#id" }`: symbols already on the page (a site's own sprite) for the compact search box's magnifier and the closed disclosure's chevron. A value that is not `#` and a name is dropped, and the engine draws its own |
318
+
319
+ A product may also carry a `note`, a few words drawn after its label in the list switcher (`"on GitHub"`).
320
+
321
+ An item page in such a collection draws the sidebar with the page being read marked, the switcher,
322
+ a search box, previous and next from the tree's reading order, and the edit link. The sidebar is a
323
+ `details` element, so it collapses on a phone with no script. `collectionTree(config, key, { product })`
324
+ and `treeNeighbours(order, slug)` return the same tree and the same neighbours for a site's own page.
325
+
326
+ An item links where the collection's `href` field says it is read, when the collection maps one and it
327
+ is a path on this site, and at its route and slug otherwise: the sidebar, previous and next, the
328
+ in-page index and the sitemap all follow it (`treeItemHref`). An absolute address is not followed. Slugs are unique across a tenant, so a manual whose products each have a
329
+ quickstart keeps `cms-quickstart` as the slug and names `/docs/cms/quickstart` in the field. The route
330
+ serving that path is the site's own. A site drawing the sidebar on a page the tree does not hold, an
331
+ index, gives `TreeAside` or `TreeSidebar` a `summary` (`{ line, title }`) for the closed control to say.
332
+
333
+ **Search.** `searchCollection(config, key, query)` goes through barakoCMS's `/api/public/{type}/search`,
334
+ which matches only over the fields a type publishes, so a draft or a field held back from public
335
+ delivery can never come back. `createCollectionIndex(config, key, { search: true })` answers `?q=` with
336
+ what matched instead of the index; that reads the query, so the route is dynamic and `output: "export"`
337
+ refuses it, which is why it is off unless asked for. The box is a `form`, the results are links, and
338
+ the keyboard handling on top ("/" to focus, the arrow keys to walk the results, escape to clear) is the
339
+ one client component in the package. The root catch-all answers `?q=` on a collection index too, but
340
+ only where the route may be dynamic: a request-time site rewrites to a kept route, and a kept route
341
+ asking for the query fails rather than bailing out, so there the index lists and a page of blocks
342
+ holding the `search` block is where a reader searches.
343
+
344
+ Which is why `searchPath` exists rather than the box pointing at the collection's own route. Only the
345
+ site knows which of its routes reads the query, and a box submitting somewhere that ignores `q` sends
346
+ a reader to an unfiltered index that looks like a search which matched everything. Name the route that
347
+ answers, or name nothing and get no box.
348
+
349
+ **As blocks.** `docsSidebar`, `docsSwitcher` and `search` draw the same three on a page of blocks, each
350
+ taking a collection key. `search` takes a bindable `query`, so a landing page binds `{{query.q}}` and
351
+ the route file passes the query with `createPage(config, blocks, { query: true })`. `docsSwitcher`
352
+ takes a `variant`, `tabs` or `list`, and follows the collection's own when it is left unset.
353
+
354
+ **Styling the tree.** A site restyles the tree screens without drawing them itself, three ways.
355
+
356
+ *Tokens.* Every colour, gap, padding, margin, radius, font size, weight and line height in them is read as
357
+ `var(--t-tree-<name>, <today's value>)`. Letter spacing, and the flex bases that decide when a row wraps, are fixed.
358
+ A token named in the tenant's `Tokens` setting (or `theme.tokens`) lands on the root as `--t-<name>`,
359
+ so `"Tokens": { "tree-link-current-bg": "#FDEBD3" }` restyles the current page's row and nothing else.
360
+ A token in the settings is held to a colour, a length or a font stack; a weight or a unitless line
361
+ height is set from the site's stylesheet under the same name (`:root { --t-tree-link-weight: 500 }`).
362
+ A site that names none draws what it drew before, pixel for pixel.
363
+
364
+ | Token | Default | What |
365
+ | --- | --- | --- |
366
+ | `tree-gap` | `space.lg` | Between the sidebar and the page, `plain` |
367
+ | `tree-shell-width`, `tree-shell-pad-y`, `tree-shell-pad-x` | `layout.wide`, `space.lg`, `layout.gutter` | The shell's width and padding, `plain` |
368
+ | `tree-edge` | `hairline` | The rules between the columns and above them, `boxed` |
369
+ | `tree-min-height` | `0px` | The shell's least height, `boxed` |
370
+ | `tree-sidebar-width` | `280px` | The sidebar column (its most, `plain`; its width, `boxed`) |
371
+ | `tree-sidebar-bg`, `tree-sidebar-pad-y`, `tree-sidebar-pad-x` | `surface`, `space.lg`, `space.md` | The sidebar column, `boxed` |
372
+ | `tree-body-pad-y`, `tree-body-pad-x` | `space.xl`, `space.xl` | The page column, `boxed` |
373
+ | `tree-aside-gap` | `space.md` | Between the switcher, the search box and the sidebar |
374
+ | `tree-section-gap` | `space.md` | Between sections, and between the list switcher and the sections |
375
+ | `tree-label-gap`, `tree-label-size`, `tree-label-ink` | `space.xs`, `text.meta`, `muted` | Section labels, the list switcher's label and the rail's heading. The size is also the search box's label and the phone control's |
376
+ | `tree-summary-bg`, `tree-summary-edge`, `tree-summary-ink`, `tree-summary-radius`, `tree-summary-space`, `tree-summary-pad-y`, `tree-summary-pad-x` | `surface`, `hairline`, `muted`, `radii.control`, `space.sm`, `10px`, `12px` | The sidebar's control on a phone, and the space under it while open |
377
+ | `tree-summary-pad-y`, `tree-summary-pad-x`, `tree-summary-min-height`, `tree-summary-gap`, `tree-summary-line-gap` | `10px`, `12px`, `0px`, `space.sm`, `3px` | The closed disclosure's control |
378
+ | `tree-summary-title-size`, `tree-summary-title-weight`, `tree-summary-title-ink` | `text.small`, `700`, `ink` | The page it names |
379
+ | `tree-summary-action-size`, `tree-summary-action-weight`, `tree-summary-action-ink`, `tree-summary-action-gap`, `tree-summary-icon-size` | `text.small`, `600`, `accentInk`, `6px`, `13px` | Its "Contents" and "Close", and the chevron |
380
+ | `tree-link-gap`, `tree-indent` | `0px`, `space.md` | Between rows, and a child's indent |
381
+ | `tree-link-pad-y`, `tree-link-pad-x`, `tree-link-radius`, `tree-link-size`, `tree-link-leading` | `6px`, `10px`, `radii.control`, `text.small`, `1.45` | A row in the sidebar, and a product in the list switcher |
382
+ | `tree-link-ink`, `tree-link-weight` | `secondaryInk`, `inherit` | A row that is not the page being read |
383
+ | `tree-link-current-bg`, `tree-link-current-ink`, `tree-link-current-weight` | `accentTint`, `accentInk`, `600` | The page (or product) being read |
384
+ | `tree-note-gap`, `tree-note-size`, `tree-note-weight`, `tree-note-ink` | `6px`, `text.meta`, `inherit`, `muted` | A product's note |
385
+ | `tree-tab-gap`, `tree-tab-pad-y`, `tree-tab-pad-x`, `tree-tab-radius`, `tree-tab-size`, `tree-tab-weight` | `space.xs`, `6px`, `12px`, `radii.pill`, `text.small`, `600` | The tabs switcher |
386
+ | `tree-tab-bg`, `tree-tab-edge`, `tree-tab-ink`, `tree-tab-current-bg`, `tree-tab-current-ink` | `surface`, `hairline`, `secondaryInk`, `accent`, `inverseInk` | The tabs, and the current one |
387
+ | `tree-search-bg`, `tree-search-edge`, `tree-search-ink`, `tree-search-radius`, `tree-search-size`, `tree-search-pad-y`, `tree-search-pad-x` | `surface`, `hairline`, `ink`, `radii.control`, `text.small`, `9px`, `12px` | The search box |
388
+ | `tree-search-label-gap`, `tree-search-label-ink` | `space.xs`, `muted` | Its label |
389
+ | `tree-search-results-gap`, `tree-search-hit-gap`, `tree-search-hit-radius`, `tree-search-hit-ink`, `tree-search-hit-weight`, `tree-search-empty-ink`, `tree-search-hit-pad-y`, `tree-search-hit-pad-x` | `space.sm`, `2px`, `radii.control`, `ink`, `600`, `muted`, `7px`, `10px` | Its results |
390
+ | `tree-search-height`, `tree-search-gap`, `tree-search-icon-size` | `36px`, `9px`, `13px` | The compact well. It also reads `tree-search-bg` (`pageBg` here), `tree-search-radius`, `tree-search-pad-x`, `tree-search-size` (`text.meta` here), `tree-search-ink` and, for the well's own ink, `tree-search-label-ink` |
391
+ | `tree-search-key-pad-y`, `tree-search-key-pad-x`, `tree-search-key-radius`, `tree-search-key-bg`, `tree-search-key-edge`, `tree-search-key-size`, `tree-search-key-weight` | `2px`, `6px`, `6px`, `surface`, `hairline`, `text.meta`, `700` | The "/" key hint |
392
+ | `tree-search-panel-gap`, `tree-search-panel-pad`, `tree-search-panel-bg`, `tree-search-panel-edge`, `tree-search-panel-radius`, `tree-search-panel-shadow` | `6px`, `6px`, `surface`, `hairline`, `radii.panel`, `0 10px 24px -12px rgba(16,18,35,.25)` | The compact box's floating results |
393
+ | `tree-search-hit-size`, `tree-search-empty-pad-y`, `tree-search-empty-pad-x` | `text.small`, `8px`, `10px` | A compact result, and the line saying nothing matched |
394
+ | `tree-rail-width`, `tree-rail-pad-y`, `tree-rail-pad-x`, `tree-rail-label-gap`, `tree-rail-gap` | `layout.columnMin`, `space.xl`, `space.md`, `space.sm`, `2px` | The rail (its padding only when `boxed`) |
395
+ | `tree-rail-link-pad-y`, `tree-rail-link-pad-x`, `tree-rail-link-size`, `tree-rail-link-weight`, `tree-rail-link-ink`, `tree-rail-link-edge` | `6px`, `10px`, `text.small`, `inherit`, `secondaryInk`, `hairline` | A heading in the rail |
396
+ | `tree-pager-top`, `tree-pager-gap`, `tree-pager-pad-y`, `tree-pager-pad-x`, `tree-pager-radius`, `tree-pager-bg`, `tree-pager-edge` | `space.lg`, `space.sm`, `16px`, `18px`, `radii.panel`, `surface`, `hairline` | Previous and next |
397
+ | `tree-pager-label-size`, `tree-pager-label-ink`, `tree-pager-title-gap`, `tree-pager-title-size`, `tree-pager-title-weight`, `tree-pager-title-ink` | `text.meta`, `muted`, `space.xs`, `text.small`, `600`, `ink` | Their words |
398
+ | `tree-edit-top`, `tree-edit-ink`, `tree-edit-size` | `space.md`, `muted`, `text.meta` | "Edit this page" |
399
+
400
+ *Variants.* What a token cannot say, because it changes which elements are drawn or where they sit, is
401
+ the tree's `variant`, and a `variant` prop on the component:
402
+
403
+ ```json
404
+ "tree": { "variant": { "switcher": "list", "sidebar": "boxed", "rail": true, "pager": "halves" } }
405
+ ```
406
+
407
+ | Part | Values | What |
408
+ | --- | --- | --- |
409
+ | `switcher` | `tabs` (default), `list` | A row of pills above the search box, or a labelled column of rows inside the sidebar above the sections, under the search box, folding away with the pages on a phone |
410
+ | `sidebar` | `plain` (default), `boxed` | The sidebar beside the page in the wide column, or the page split edge to edge with the sidebar a surface column and a hairline between. `boxed` stacks on a phone |
411
+ | `rail` | `false` (default), `true` | An "on this page" column of the item's second level headings at the inline end, linking to the ids its body renders. Hidden below 64rem |
412
+ | `pager` | `wide` (default), `halves` | Previous and next taking the room there is and wrapping on a phone, or two halves that never wrap, next on the right even with no previous |
413
+ | `search` | `box` (default), `compact` | A labelled input with its results under it, or one well holding a magnifier, the input and a "/" key hint, the input named by `aria-label` with no label on screen, and the results in a panel floating over what follows. "/" focuses either |
414
+ | `disclosure` | `open` (default), `closed` | On a phone, the sidebar open under a "Contents" control, or closed until tapped, the control naming the product, the section and the page being read, with "Contents" and a chevron that turn to `closeContents` and point up while open. Above the phone breakpoint both show the whole sidebar and no control. `closed` needs no script: the content is shown above the breakpoint through `::details-content`, and a browser without it keeps the control there too, so the sidebar is one tap away rather than gone |
415
+
416
+ A value the engine does not know is today's layout for that part.
417
+
418
+ *Classes.* Each part carries a class a stylesheet can reach, for what neither a token nor a variant
419
+ covers (a hover, a transition): `bp-tree-shell` (and `bp-tree-shell-boxed`), `bp-tree-aside`,
420
+ `bp-tree-body`, `bp-tree-rail`, `bp-tree-sidebar`, `bp-tree-summary`, `bp-tree-sidebar-body`,
421
+ `bp-tree-sections`, `bp-tree-section`, `bp-tree-section-label`, `bp-tree-list`, `bp-tree-item`,
422
+ `bp-tree-link`, `bp-tree-link-current`, `bp-tree-switcher` (with `bp-tree-switcher-tabs` or
423
+ `bp-tree-switcher-list`), `bp-tree-switcher-label`, `bp-tree-tab`, `bp-tree-tab-current`,
424
+ `bp-tree-product`, `bp-tree-product-note`, `bp-tree-search`, `bp-tree-search-label`,
425
+ `bp-tree-search-input`, `bp-tree-search-results`, `bp-tree-search-index`, `bp-tree-search-hit` (a
426
+ result a route answered with), `bp-tree-search-empty`, `bp-tree-rail-nav`,
427
+ `bp-tree-rail-label`, `bp-tree-rail-link`, `bp-tree-pager`, `bp-tree-pager-link`,
428
+ `bp-tree-pager-prev`, `bp-tree-pager-next`, `bp-tree-pager-label`, `bp-tree-pager-title`,
429
+ `bp-tree-pager-empty`, `bp-tree-edit`, and for the variants `bp-tree-search-compact`,
430
+ `bp-tree-search-box`, `bp-tree-search-icon`, `bp-tree-search-key`, `bp-tree-nav-closed`, `bp-tree-summary-closed`, `bp-tree-summary-group`,
431
+ `bp-tree-summary-title`, `bp-tree-summary-action`, `bp-tree-summary-show`, `bp-tree-summary-hide` and
432
+ `bp-tree-summary-chevron`. Every label paragraph (a section's, the list switcher's, the pager's, the
433
+ rail's) also carries `bp-label`, so a rule aimed at running text can skip them all with
434
+ `:not(.bp-label)`. An index entry carries no class of its own, to stay small: it is
435
+ `.bp-tree-search-index li > a`, holding a `span` with the words that matched and, for a heading, a
436
+ second `span` naming its page. The styles are inline, so a stylesheet rule that sets a property the
437
+ part already sets needs `!important`; a token does not. The index's entries are the exception: they
438
+ are styled from one stylesheet scoped to their box, so an ordinary rule of the same or higher
439
+ specificity reaches them.
440
+
441
+ **The search index.** `treeSearchIndex(tree)` lists every page with a route and then its second level
442
+ headings, in reading order, each heading linked to its anchor. It is built once per tree read and
443
+ shared by everything drawn from that read, and a body's headings (`itemHeadings(item)`, from
444
+ `markdownHeadings` in `barakopress/markdown`) are tokenised once per distinct body and kept, so a static
445
+ build of a manual of n pages lexes each body once rather than n times. With `searchIndex`, the box
446
+ draws the index into the page hidden and its client code shows the entries holding every word typed,
447
+ eight at a time.
448
+
449
+ What that costs: an entry is a list item, a plain link and its words, styled from one stylesheet, so
450
+ it is its href and its text plus about 50 bytes. The index is part of the server render, so Next
451
+ carries it in the page's payload as well as its HTML, like any other server markup; it is never also
452
+ handed to the client component as props. At most 2000 entries (`TREE_INDEX_LIMIT`) go into a page.
453
+ Past that it is headings that are left out, never a page: every page's title is counted first and
454
+ headings fill the rest in reading order, and the server log says once which manual lost how many.
455
+ A body gives at most 100 headings (`HEADINGS_PER_BODY`), and what is kept between reads is those
456
+ headings under a digest of the body, never the body.
457
+
458
+ The results are put away, with what was typed kept, when focus leaves the box, when something outside
459
+ it is pressed, when one of them is followed, and on Escape; they come back when the box has focus again. Enter goes to the first
460
+ match. With no `searchPath` there is no form at all, so Enter can never reload the page with the
461
+ query: with script the box answers in the page, and a reader with no script gets a field that does
462
+ nothing, not one that reloads the page and empties itself.
463
+
187
464
  ### Pages and navigation from the Pages module
188
465
 
189
466
  With `BarakoCMS.Pages` installed, the CMS owns the page tree: which pages are in the menu, their
@@ -246,6 +523,10 @@ curl -X POST "$CMS_URL/api/content-types/page/fields" -H "authorization: Bearer
246
523
  A page with a non-empty list renders its blocks. A page without one renders its `Body` as before.
247
524
  Field names come from `pageFields` in the config, and the type from `types.page`.
248
525
 
526
+ `PageView` draws the page's `Title` above its blocks. A page whose blocks open with their own
527
+ heading sets a boolean `HideTitle` field to stop it drawing twice: `Title` still names the page for
528
+ a tab and for search either way.
529
+
249
530
  ```json
250
531
  [
251
532
  { "type": "richText", "props": { "markdown": "## Hello" } },
@@ -261,23 +542,62 @@ Blocks come in four layers.
261
542
 
262
543
  **Layout primitives** hold blocks and no content: `section` (a tone, a width and a padding step),
263
544
  `stack`, `row` (side by side, wrapping on a phone), `grid`, `flow`, `panel` (a card with a tone and
264
- a frame), `spacer` and `divider`.
545
+ a frame), `stickyBar` (a band that stays put while the page moves under it, at the top or the
546
+ bottom), `spacer` and `divider`.
547
+
548
+ A `stack` or a `panel` given an `href` is a link, the whole of it: a card whose face goes somewhere.
549
+ A site path is drawn with Next's `Link`, anything with a scheme as a plain anchor, and the address is
550
+ held to the same check as any link field. What is inside is the link's name, so nothing inside should
551
+ be a link of its own: one that holds a link, a button, a linked container, a control or a text with
552
+ a markdown link in it is drawn without its `href`, and the server log says so once, since a link
553
+ inside a link is markup a browser rewrites. A `section`, `stack` or `panel` given an `anchor` carries
554
+ it as its `id`, for a link to `#start` elsewhere; one that is not a plain name is left off.
265
555
 
266
556
  `row` and `grid` take one list per cell, which suits a designer placing each one. `flow` takes a
267
557
  single list and lays out whatever is in it, which is what a `repeat` and a preset's `slot` produce:
268
558
  a card per row that came back, however many that is. Its column count is a choice and not a number,
269
559
  because only a string field takes a binding, and a preset has to pass its own `columns` through.
270
560
 
271
- **Content primitives** hold content and no layout: `text` (a variant from the theme's type scale),
561
+ **Content primitives** hold content and no layout: `text` (a variant from the theme's type scale,
562
+ and `format: "inline"` for code, emphasis, strong, links and an accent in the line; see
563
+ [Inline marks](#inline-marks-in-a-text-block); `tag` draws it as `p`, `span`, `code`, `strong`,
564
+ `em` or `h1` to `h4` rather than the variant's element, `decorative` hides it from a screen reader,
565
+ for an arrow after a link's words, unless its inline marks hold a link, and `title` is shown on hover),
272
566
  `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).
567
+ `icon`, `button`, `link`, `list`, `disclosure` (a labelled section that opens; give several the
568
+ same `group` and only one is open at a time), `comparisonTable` (rows typed as lines with `|`
569
+ between the cells, drawn as a real table with a heading on every column and every row) and
570
+ `progressBar` (how far along one thing is, as `role="progressbar"` so it is read and not only seen,
571
+ from a `value` already worked out, or from a `count` with either a `total` or what is `remaining`).
572
+
573
+ A comparison's first line is the column headings and the first cell of every line after it is that
574
+ row's heading. What goes in a cell is whatever the tenant types, ticks and dashes included: a mark
575
+ chosen inside the engine would be one more piece of English in the markup.
576
+
577
+ **Motion primitives** are drawn finished and animate only away from that: `reveal` (a wrapper whose
578
+ content lifts and fades in as it scrolls into view), `rotatingText` (a list of words in
579
+ `words`, one shown at a time; the older comma separated `items` still reads), `typingTerminal` (lines typed in sequence, held, then started again) and
580
+ `codeSample` (a snippet with its language and one click to select the whole of it). `text` takes a
581
+ `motion` of `countUp`, which counts a plain number up to the figure already written in the markup,
582
+ and `flow` takes a `hueRotate` of `subtle` or `wide`, which turns each cell's hue through the
583
+ theme's own colours in a cycle of three.
584
+
585
+ All of it is CSS. There is no script, so a page with JavaScript off renders the terminal typed out,
586
+ the figure at its number and the first word of the rotation standing, and every animation sits inside
587
+ `prefers-reduced-motion: no-preference`, so a visitor who asked for less motion gets exactly the same
588
+ finished page. "When it comes into view" is `animation-timeline: view()`, and a browser without it
589
+ shows the finished state too. `src/blocks/motion.test.tsx` strips the guards out of every stylesheet
590
+ these emit and fails if anything left animates or hides anything.
275
591
 
276
592
  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
593
+ `accent`, `inverse`, `gradient` (the inverse band with the theme's three dark roles spread across
594
+ it) and `wash`, plus any the site names in `Tones` (see [Tokens and tones](#tokens-and-tones)); spacing is `none` to `xxl` from `theme.space`; type is a role from
278
595
  `theme.text`; corners are `none`, `control`, `panel` or `pill`. A tenant that changes the scale
279
596
  changes every page built from primitives, and nobody can put one client's blue into a block.
280
597
 
598
+ The one way past the tokens is a [style recipe](#style-recipes): every primitive takes `recipe`, the
599
+ name of a look the site defines in its settings, and draws with it in place of its own.
600
+
281
601
  A tone belongs to the band and everything in it: a `section` or a `panel` publishes its ink, its
282
602
  accent and its hairline, and the blocks inside read those rather than the page's. That is what makes
283
603
  an inverse band readable, and it is why a block dropped anywhere still looks like it belongs.
@@ -285,7 +605,8 @@ an inverse band readable, and it is why a block dropped anywhere still looks lik
285
605
  **Presets** are named arrangements of primitives, stored as data: the shipped library below, plus
286
606
  whatever a tenant saves of its own.
287
607
 
288
- **Data blocks** load and choose rather than draw: `source`, `repeat`, `showIf`, `pager` and `slot`.
608
+ **Data blocks** load and choose rather than draw: `source`, `repeat`, `showIf`, `pager`, `filterBar`
609
+ and `slot`.
289
610
  See bindings below.
290
611
 
291
612
  Also built in, from before the layers: `columns` (up to four lists of blocks), `callToAction` and
@@ -320,12 +641,53 @@ allowed. A listed origin gets `Access-Control-Allow-Origin` echoed back on `GET`
320
641
  preflight, never `*` and never credentials, and every answer carries `Vary: Origin`. Pass
321
642
  `{ consoleOrigins: [...] }` to both factories to set the list in code instead.
322
643
 
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
644
+ **Which tenant's schema.** Given the config, `createBlockSchemaRoute(config, blocks)` answers a
645
+ request-time site with the schema of the tenant the request belongs to: its own presets, and its own
646
+ tones on every tone field. The tenant is found as a page finds it, from `CMS_TENANT`, the header the
647
+ operator named, the host through the CMS, or `CMS_DEFAULT_TENANT`, so barakoBrew reads a tenant's
648
+ schema at that tenant's domain. Nothing else a caller sends picks the tenant. A host with no tenant
649
+ gets 404 `{ "error": "no site" }`, and a lookup that fails gets 503. The settings behind it are the
650
+ cached read every page makes, under the tenant's cache tag, so a settings change shows after the
651
+ delivery that purges it. A build-time site with no plugins gets the same answer as
652
+ `createBlockSchemaRoute(blocks)`, which still works and still ignores the request. Given only the
653
+ registry, the route cannot tell which plugins the site enabled, so it offers no plugin's blocks and
654
+ lists every plugin as off; a site with plugins passes the config.
655
+
656
+ Field kinds are `text`, `markdown`, `url`, `number`, `boolean`, `select` (with `options`), `slots`
657
+ (lists of nested blocks, handed to the component already rendered), `list` and `group` (below). The list is editor input, so a
325
658
  block renders only when its type is registered and every prop passes its field. A present but wrong
326
659
  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.
660
+ never receives a prop its fields did not declare. A page reads at most 400 blocks in total, nested
661
+ ones included, and ten levels deep. The count is spent on every block the binder walks through as
662
+ well as every one that comes out, so a band from the library costs eight or ten of it, and a page
663
+ that goes over loses its tail with nothing said. What a `repeat` draws for its rows is counted
664
+ against a budget of its own; see `mode: "all"` under Bindings.
665
+
666
+ **Lists and groups.** A block that needs several of one thing declares a `list`, and a thing made
667
+ of parts declares a `group`, rather than numbered fields or text split in the component:
668
+
669
+ ```ts
670
+ fields: [
671
+ { name: "stages", kind: "list", min: 2, max: 6, item: { kind: "group", label: "Stage", fields: [
672
+ { name: "label", kind: "text", required: true },
673
+ { name: "body", kind: "markdown" },
674
+ ] } },
675
+ { name: "tags", kind: "list", max: 8, item: { kind: "text" } },
676
+ { name: "cta", kind: "group", fields: [{ name: "label", kind: "text" }, { name: "href", kind: "url" }] },
677
+ ]
678
+ ```
679
+
680
+ A list's `item` is `text`, `url`, `number` (with its own `min` and `max`) or `group`, and the list's
681
+ own `min` and `max` count its entries. A list with no `max` holds at most 100. A group's `fields`
682
+ are any field kind except `slots`, and lists and groups nest three deep. A stored list checks every
683
+ entry, and one wrong entry, a list too short or too long, or a group missing a required part fails
684
+ the block like any wrong value. An empty list reads as absent. A group hands its component only the
685
+ keys its fields declare.
686
+
687
+ The schema at `app/api/blocks` stays version 2 and publishes `item` and `fields` for these, with
688
+ `bindable` resolved at every level. They only add a kind and two keys, and a console that does not
689
+ know a kind edits that one field as JSON, so a block with no list or group publishes exactly what it
690
+ did before.
329
691
 
330
692
  `defineBlock<Props, SlotNames>` checks the fields against the props at compile time: every field
331
693
  names a prop, its kind suits the prop's type, and a prop that is not optional must be `required`.
@@ -351,8 +713,11 @@ optional format and fallback:
351
713
  ```
352
714
 
353
715
  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
716
+ renders), `item` (the row inside a `source` or a `repeat`) and `query` (URL parameters). An item
717
+ reads its entry's own fields by name, with the collection's roles (`Title`, `Summary`, `Body`,
718
+ `Date` and the rest) laid over them only where the collection's `fields` maps that role, so an
719
+ unmapped `Body` is still the entry's own `Body`. `count`, `sum` and `group` are about a set of rows
720
+ rather than one, and are described with `source` below. `viewer` arrives with #7. Formats are `text`, `date`, `datetime`, `time`, `money`, `number`, `upper` and
356
721
  `lower`; `money` uses the tenant's `Currency` setting, or a plain amount when it has none.
357
722
 
358
723
  Paths, formats and fallbacks only. There are no expressions and no JavaScript. Everything resolves
@@ -362,6 +727,14 @@ server log, never a crash. What a binding resolves to is checked against the fie
362
727
  whose stored field holds `javascript:` drops the block; and a resolved value is never rescanned, so
363
728
  one field cannot reach another through its own contents.
364
729
 
730
+ A `list` or a `group` takes its whole value from one placeholder with nothing around it, no format
731
+ and no fallback: `"tags": "{{item.Tags}}"` fills the list with the array itself, and
732
+ `"cta": "{{item.Link}}"` a group with the object. What it resolves to is data. Each entry is checked
733
+ against the field, an entry that fails is left out rather than failing the block, the list is cut at
734
+ its `max` since the data is not the editor's to shorten, and one that comes up short of `min` drops
735
+ the block. Nothing in it is scanned for placeholders. A list or a group typed out in full binds
736
+ each string inside it like any string prop.
737
+
365
738
  `source` loads one entry or a page of them and puts it in scope. `repeat` renders its content once
366
739
  per row. `pager` draws prev and next links for the `source` it sits in, paged by the API. `showIf`
367
740
  keeps its content only when a bound value has something, or equals what it names.
@@ -383,6 +756,125 @@ A filter narrows what the API already lets the reader see. It is never access co
383
756
  which rows is decided in barakoCMS. A page reads at most eight sources, and a `source` at most fifty
384
757
  rows a page.
385
758
 
759
+ `mode: "all"` is for a page that is the whole of a collection rather than a page of it, a changelog
760
+ say: the source reads every row, fifty to a request, up to 500 (`MAX_ALL_ROWS`). Its first request
761
+ is one of the page's eight reads, and the rest are made at once, out of 16 more a page's `all`
762
+ sources share between them (`MAX_ALL_REQUESTS`). A source past 500 rows, or past what the requests
763
+ cover, reads what it can and says so in the server log, and `{{count}}` stays what the collection
764
+ holds. A `pager` inside it draws nothing, and sums, distinct counts, groups and a filter bar cover
765
+ every row it read. A `repeat` draws every row its source read unless its `limit` names fewer. What
766
+ the rows of a repeat and the groups of a grouped source draw spends a budget of its own, 10,000
767
+ blocks (`MAX_ROW_BLOCKS`), rather than the page's 400, so a long list neither takes the blocks after
768
+ it off the page nor renders without end; rows or groups past it are left out and the log says how
769
+ many were drawn.
770
+
771
+ **Counts, sums and groups.** `{{count.<collection>}}` anywhere on a page is how many published
772
+ entries the collection has, for example `{{count.posts}}`. It is the delivery API's `totalItems` for
773
+ a page of one row, cached like every other read, and the public API lists published entries only, so
774
+ it is the number of rows the page could list. Inside a `source`, `{{count}}` on its own is how many
775
+ rows that source's filter matched, all of them and not just the page it read:
776
+
777
+ ```json
778
+ { "type": "source", "props": {
779
+ "collection": "packages", "mode": "list", "pageSize": 1,
780
+ "filterField": "Category", "filterValue": "Auth",
781
+ "content": [[ { "type": "text", "props": { "value": "{{count}} auth modules" } } ]]
782
+ } }
783
+ ```
784
+
785
+ A filtered count is a `source` rather than something like `{{count.packages where Category=Auth}}`
786
+ because a placeholder is paths, formats and fallbacks, and a filter value is free text: a value with a
787
+ space or a hyphen cannot be a path segment, and a placeholder that could carry a condition would be
788
+ the start of an expression language. `filterField` and `filterValue` already bind, so the value can
789
+ come from `{{query.c}}` or an item, and the count costs the one read the source makes anyway. Set
790
+ `pageSize` to 1 when the count is all the source is for.
791
+
792
+ Each collection counted is one of the page's eight reads, spent the first time a placeholder names
793
+ it and shared by every other placeholder that names it. Sources and counts draw on the same eight, in
794
+ the order the page is written. A count past the budget, one for a collection the tenant does not
795
+ have, and one whose read failed all render the fallback and are reported as `no value`.
796
+
797
+ `{{sum.<Field>}}` inside a `source` adds that field over the rows the source read, for example the
798
+ open issues across a roadmap's milestones. A number stored as text counts. A field with a word in
799
+ any row is not a sum and renders its fallback, and a source with no rows has no sums, so write
800
+ `{{sum.Open ?? 0}}` where zero is the right answer. A sum covers the rows read: fifty at most in
801
+ `list` mode, where a source that pages sums the page it is on, and up to 500 in `all` mode.
802
+
803
+ `{{distinct.<Field>}}` is how many different values that field holds among the same rows: the
804
+ repositories a list of issues spans, "16 issues across 3 repositories". Values compare as the text a
805
+ placeholder prints, each entry of a list field counts on its own, and a row with nothing in the field
806
+ adds nothing, so a field no row holds renders its fallback. Inside a group it counts that group's rows.
807
+ It counts only what the source read, so a source that matched more rows than it read (a page of a
808
+ longer list) has no distinct counts and renders the fallback: `{{distinct.Repo ?? several}}`. It
809
+ reads a top-level field; a dotted path such as `Owner.Login` renders the fallback.
810
+
811
+ `groupBy` names a field, and the source's content then renders once per distinct value of it among
812
+ the rows read, in the order first seen. `groupOrder` is a comma separated list of values to put
813
+ first, in that order; the rest follow in the order first seen. Inside each group `{{group.key}}` is
814
+ the value and `{{group.count}}` how many rows have it, and `repeat` and `{{sum.X}}` work over that
815
+ group's rows. `{{count}}` stays what the whole source matched. A row with nothing in the field is
816
+ kept, in a group whose key is empty, so `{{group.key ?? Other}}` names it. A pager inside a grouped
817
+ source renders nothing, since a page of rows is not a page of groups. One grouped source is one read,
818
+ where a source per value was one read each:
819
+
820
+ ```json
821
+ { "type": "source", "props": {
822
+ "collection": "milestones", "mode": "list", "pageSize": 50,
823
+ "groupBy": "Repository", "groupOrder": "barakoCMS, barakoPress",
824
+ "content": [[
825
+ { "type": "text", "props": { "value": "{{group.key}}: {{sum.Open}} open in {{group.count}} milestones" } },
826
+ { "type": "repeat", "props": { "content": [[ { "type": "text", "props": { "value": "{{item.Title}}" } } ]] } }
827
+ ]]
828
+ } }
829
+ ```
830
+
831
+ All of it resolves on the server as the request's tenant, so a count or a sum never makes the
832
+ browser call the API. A page that uses none of it reads and renders exactly what it did before.
833
+
834
+ **Filter buttons.** A `filterBar` inside a `source` draws one button per distinct value of `field`
835
+ among the rows the source read, after an "all" button (`allLabel`, which binds, so `All {{count}}`
836
+ works). The values come in the order first seen; `order` is a comma separated list to put first, and
837
+ a value it names that no row holds gets no button. `separator` splits a text field that holds several
838
+ values, such as `4.4.0, 4.3.0`, and a list field gives one value per entry. A bar offers at most a
839
+ hundred values (`MAX_FILTER_VALUES`), the first hundred in that order, and a row carries only values
840
+ the bar offers. Fewer than two values is no choice, so the bar draws nothing.
841
+
842
+ ```json
843
+ { "type": "source", "props": {
844
+ "collection": "packages", "mode": "list", "pageSize": 50,
845
+ "content": [[
846
+ { "type": "filterBar", "props": { "field": "Category", "allLabel": "All {{count}}", "label": "Filter by category" } },
847
+ { "type": "repeat", "props": { "content": [[ { "type": "text", "props": { "value": "{{item.Title}}" } } ]] } }
848
+ ]]
849
+ } }
850
+ ```
851
+
852
+ The rows stay server-rendered and the buttons read nothing. The binder marks each row's own blocks
853
+ with `data-bp-filter`, the bar's id, and `data-bp-filter-values`, its values, each written as
854
+ `<id>:<value>` with the value percent-encoded so a value with a space is one token. A row of a source
855
+ nested in another source's row belongs to both bars, so each attribute can hold two, and each bar's
856
+ rule reads only its own. The id is `f<n>-<scope>`, where the scope is the part of the page the bind
857
+ draws (`body`, `header`, `footer`, or `index` for a collection's index page, and `scope` in
858
+ `bindBlocks` options for a site that binds its own), so a bar in the header and an identical one in the
859
+ body never share an id, and the same page renders the same bytes every time. A click sets `aria-pressed` on the button and
860
+ `data-bp-filter-value` on the bar, and writes one rule that hides every row of that bar without the
861
+ value, with the value escaped as a CSS string and `!important` so it wins over a row's inline
862
+ `display`. A site writes no rule of its own, so a value nobody planned for still filters, and a
863
+ reader with no script sees every row. The buttons are toggles with `aria-pressed` inside a labelled
864
+ group, not a tablist, since the tab pattern promises arrow keys and panels this does not have. A row
865
+ with nothing in the field has no value, so any choice hides it.
866
+
867
+ The bar draws its buttons from the theme's colours. `recipe` names a style recipe for the row,
868
+ `buttonRecipe` one for each button and `pressedRecipe` one for the button that is pressed, each
869
+ replacing that look outright as a recipe does on a primitive; a button keeps `cursor: pointer`.
870
+
871
+ In a grouped source the bar is drawn once, ahead of the groups, and filters all of them. With
872
+ `hideEmptyGroups`, a group's own blocks carry every value its rows hold, so the same rule hides a
873
+ group that has no row left. Without it a group stays with its heading and no rows. Only the first bar
874
+ in a source counts. A grouped source honours only a bar at the top level of its content; one inside a
875
+ band there would repeat with every group, so it draws nothing and marks no row. The
876
+ values are those of the rows read, so a source that pages filters the page it is on.
877
+
386
878
  ### Presets
387
879
 
388
880
  A preset is a named block saved as data, not code: a few props and an arrangement of primitives that
@@ -407,8 +899,20 @@ published image can use it, with no barakoPress release.
407
899
  }
408
900
  ```
409
901
 
902
+ A preset's fields may be lists and groups, in the same shape a block declares them, and
903
+ `{{props.x}}` of a list passes the list itself into a primitive's list field:
904
+
905
+ ```json
906
+ {
907
+ "type": "taglines",
908
+ "fields": [{ "name": "words", "kind": "list", "max": 8, "item": { "kind": "text" } }],
909
+ "blocks": [{ "type": "rotatingText", "props": { "words": "{{props.words}}" } }]
910
+ }
911
+ ```
912
+
410
913
  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
914
+ passes them as `presets` in the config. `/api/blocks` lists a tenant's own presets beside the shipped ones when
915
+ it is mounted with the config. A preset never replaces a block that is code. It does
412
916
  replace a preset, which is how a tenant adjusts one of the shipped blocks below without waiting for
413
917
  a release. A preset body may not use another preset saved in the same pass, so a cycle cannot form;
414
918
  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
@@ -431,25 +935,142 @@ one to look different saves its own under the same name and that one wins.
431
935
  | `hero` | The band at the head of a page | heading, body, image, imageAlt, primaryLabel, primaryHref, secondaryLabel, secondaryHref, tone, columns, align, padding |
432
936
  | `band` | Copy with one call to action | tone, heading, body, label, href, align, padding, width |
433
937
  | `statBand` | A row of figures | heading, tone, columns, padding, items |
434
- | `cardGrid` | Cards from a collection | heading, collection, filterField, filterValue, empty, tone, columns, padding |
938
+ | `cardGrid` | Cards from a collection | heading, collection, filterField, filterValue, empty, tone, columns, padding, hueRotate, option |
435
939
  | `peopleGrid` | People from a collection, typed in place, or both | heading, collection, filterField, filterValue, role, tone, columns, padding, items |
436
940
  | `timeline` | Dated entries | heading, tone, padding, width, items |
437
941
  | `steps` | Numbered entries | heading, tone, padding, width, items |
438
942
  | `tiers` | Giving or pricing tiers | heading, tone, columns, padding, items |
439
943
  | `keyValueTable` | A panel of facts | heading, tone, padding, radius, rows |
440
944
  | `tabs` | Sections that open | heading, tone, padding, width, items |
945
+ | `announcement` | The one line above everything, and it stays there | message, label, href, tone, edge, align |
946
+ | `codeTabs` | A snippet to run, with the other ways behind it | heading, body, code, language, selectLabel, tone, padding, width, items |
947
+ | `progressList` | How far along each entry of a collection is | heading, collection, filterField, filterValue, empty, tone, padding, width |
948
+ | `changelogList` | Release entries, with the word for the kind of release | heading, collection, filterField, filterValue, empty, tone, padding, width |
949
+ | `faq` | Questions, all openable at once | heading, body, tone, padding, width, items |
441
950
  | `map` | An embedded map, held to `embedHosts` | src, title, heading, aspect, tone, padding |
442
- | `stat` | One figure and its label | value, label, align |
951
+ | `stat` | One figure and its label | value, label, align, motion |
443
952
  | `timelineEntry` | One dated entry | date, title, body |
444
953
  | `step` | One numbered step | number, title, body |
445
954
  | `tier` | One tier | name, amount, body, label, href, tone |
446
955
  | `person` | One person | name, role, photo, href, linkLabel, align |
447
956
  | `keyValueRow` | One fact | label, value |
957
+ | `codeTab` | One more way to run it | label, code, language, selectLabel, group |
958
+ | `faqItem` | One question and its answer | question, answer, tone |
448
959
 
449
960
  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.
961
+ `timeline`, `disclosure` blocks in `tabs`, `codeTab` blocks in `codeTabs`, `faqItem` blocks in
962
+ `faq`. A card grid reads its entries through `{{item.Title}}`, `{{item.Summary}}`,
963
+ `{{item.Date | date}}` and `{{item.Href}}`, so it works against whatever the tenant calls those
964
+ fields, and it takes the collection as a prop rather than knowing any name. `{{item.Href}}` is the
965
+ collection's `href` field when it has one, and the item's own route otherwise, so a collection with
966
+ no route on this site (one filled by a sync, say) still gets a working card once its entries carry
967
+ their own link.
968
+
969
+ Set a card grid's `option` to `show` and each card carries the glyph and the word the site declared
970
+ for that entry's option, read through `{{item.Icon}}` and `{{item.Word}}`. With `filterField` and
971
+ `filterValue` picking the category, that is a module grid: one band per category, each card marked
972
+ with its own. It is off by default, so a grid that did not ask for it draws what it always drew.
973
+
974
+ `progressList` reads its figure through `{{item.Progress}}`, which is the collection's `progress`
975
+ field role: the tenant says which of its own fields holds a number from 0 to 100. Where a source
976
+ gives two counts instead, `progressCount` and `progressTotal` carry those through and the bar does
977
+ the division, so a GitHub milestone's open and closed issues need no field computing a percentage
978
+ first. `changelogList` does not group by itself, because grouping means knowing which field holds
979
+ the kind and that is the tenant's field name. One band per kind with `filterField` and `filterValue`
980
+ is the grouping, and the chip on each entry is the option's own word.
981
+
982
+ ### Plugin packages
983
+
984
+ When no block covers a need, a developer writes one as a plugin package, and the published image
985
+ stays as it is. A plugin is an npm package that ships built JavaScript, like this one does, and whose
986
+ default export is `definePlugin`:
987
+
988
+ ```tsx
989
+ import { defineBlock, definePlugin } from "barakopress";
990
+
991
+ type TallyProps = { count: number; label: string };
992
+
993
+ const tally = defineBlock<TallyProps>({
994
+ type: "tally",
995
+ label: "Tally",
996
+ fields: [
997
+ { name: "count", kind: "number", required: true },
998
+ { name: "label", kind: "text", required: true },
999
+ ],
1000
+ component: ({ props, theme }) => (
1001
+ <p style={{ color: theme.colors.accent }}>{props.count} {props.label}</p>
1002
+ ),
1003
+ });
1004
+
1005
+ export default definePlugin({ name: "tally", blocks: [tally] });
1006
+ ```
1007
+
1008
+ `defineBlock` checks the fields against the props at compile time, the same as the built-ins: a field
1009
+ name the props do not have, a kind that does not suit the prop's type, or an optional field for a prop
1010
+ the component treats as always there does not compile. `examples/plugin-sample` is a complete one,
1011
+ with its `package.json` and `tsconfig.json`; CI packs it, builds an image with it and renders it.
1012
+
1013
+ A plugin block is passed what every block is passed: its props, checked against its fields, its
1014
+ slots, rendered, and the theme. It is not passed the config, the CMS address or a token, and data from
1015
+ the CMS should reach it through a binding in its props or a module endpoint, not a credential. That is
1016
+ what it is handed, not what it can reach: its code runs in the server with full access, `process.env`
1017
+ and every `barakopress` export included. Installing a plugin means trusting it with the deployment.
1018
+
1019
+ Its name may not be one a built-in, a library block, the site or another plugin already registered.
1020
+ Replacing one would change that block for every tenant, including the ones that never enabled the
1021
+ plugin.
1022
+
1023
+ **Installing: a derived image.** Blocks are registered when the Next build runs, so a package cannot
1024
+ be added to a built image. The published image holds the built server and not the source or the
1025
+ toolchain, so the derived image is built from the engine's source at a release tag, which is what the
1026
+ published image of that tag was built from, with the plugins handed in as a build context:
1027
+
1028
+ ```bash
1029
+ mkdir plugins
1030
+ (cd ../my-plugin && npm pack --pack-destination ../site/plugins) # your own plugin
1031
+ npm pack barakopress-plugin-tally@1.2.0 --pack-destination plugins # one from npm
1032
+
1033
+ docker buildx build \
1034
+ --build-context plugins=./plugins \
1035
+ -t my-press:0.8.0-plugins \
1036
+ https://github.com/BaryoDev/barakoPress.git#v0.8.0
1037
+ ```
1038
+
1039
+ `examples/derived-image/compose.yml` is the same thing in compose. The `plugins` directory holds
1040
+ tarballs and nothing else, so what is built is exactly the bytes that were packed. The install runs
1041
+ offline, so a plugin's own dependencies must travel inside its tarball: list each one in
1042
+ `bundleDependencies`, and a tarball with a dependency it does not bundle is refused. So is a plugin
1043
+ named like a package the engine already has (`react`, `next`, anything in its lockfile), which would
1044
+ otherwise be linked over it. The build installs them beside the engine with no install scripts,
1045
+ and writes `press.plugins.ts`, which the reference
1046
+ `press.config.ts` passes to `createBlockRegistry(config, [], { plugins })`. An overlay with its own
1047
+ `press.config.ts` imports `plugins` from `@/press.plugins` and passes it the same way. Pin the tag,
1048
+ and next to it the commit it points at (`git ls-remote https://github.com/BaryoDev/barakoPress.git
1049
+ v0.8.0`), since a tag can be moved; building from `#<commit>` is the strict form. The `v0.8.0` tag
1050
+ exists from the 0.8.0 release on. Moving it is an engine upgrade, and a plugin should be rebuilt and
1051
+ checked against it.
1052
+
1053
+ **Enabling: per tenant.** One derived image carries every plugin the deployment installs. A tenant
1054
+ renders a plugin's blocks only when the `Plugins` setting in its `site` settings entry names it:
1055
+
1056
+ ```json
1057
+ { "Plugins": ["tally"] }
1058
+ ```
1059
+
1060
+ Until then, it is as if the plugin were not installed for that tenant: `/api/blocks` does not offer its
1061
+ blocks, a page that holds one renders everything around it and not the block, and a preset whose body
1062
+ draws one is left out. A list saved empty turns every plugin off; a missing field leaves the ones the
1063
+ config names (`plugins` in `defineConfig`, empty unless set). A build-time site enables plugins with
1064
+ `plugins` in its config. `/api/blocks` also answers `plugins`, every installed plugin with whether the
1065
+ tenant enabled it, which is what barakoBrew reads to show the switch. Each block a plugin added carries
1066
+ `plugin` with its name.
1067
+
1068
+ What enablement does not do is keep code apart. Every plugin's module is loaded in the container for
1069
+ every tenant it serves, and a tenant that has not enabled it is only kept from rendering it. So a
1070
+ plugin nobody on the deployment trusts is not installed there. When tenants must not share plugins,
1071
+ the answer is a separate deployment, its own barakoCMS, barakoBrew and barakoPress, not a second
1072
+ barakoPress against the same API. A hotel's branch landing pages share one deployment; its booking
1073
+ system gets its own.
453
1074
 
454
1075
  ## Configuring it
455
1076
 
@@ -481,7 +1102,7 @@ for a post type with no such field.
481
1102
  | `fields` | the blueprint's PascalCase names | operator only | Which field holds what |
482
1103
  | `pageFields` | the blueprint's PascalCase names | operator only | Which field on the page type holds what |
483
1104
  | `routes` | `/blog`, `/authors`, `/categories` | operator only | Where you mounted each route |
484
- | `pageSizes` | 20, 50, 1000, 50 | `PageSizes` | Index, feed, sitemap, archive |
1105
+ | `pageSizes` | 20, 50, 1000, 50 | `PageSizes` | Index, feed, sitemap, archive. The sitemap one bounds the entries one collection contributes, and is not a page size: the sitemap pages to that number at whatever size the API allows |
485
1106
  | `cacheTag` | `cms` | operator only | The tag this site purges. Two sites on one server need two tags |
486
1107
  | `backstopSeconds` | 300 | operator only | How long a cached read may live with no webhook. 0 disables it |
487
1108
  | `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 |
@@ -495,14 +1116,19 @@ for a post type with no such field.
495
1116
  | `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
1117
  | `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
1118
  | `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 |
1119
+ | `collections` | the blog's `post`, `author` and `category` | `Collections` | Content types rendered as lists and detail pages, with each index's copy, index page and default byline. See Collections |
1120
+ | `optionStyles` | none | `OptionStyles` | Tone, icon and label by `type.field` and option, for `colorBy` |
1121
+ | `optionColors` | none | `OptionColors` | The same, when a tone is all an option has. Read as `optionStyles` |
1122
+ | `labels` | English | `Labels` | The words the screens print for a visitor. See below |
1123
+ | `store` | in process | operator only | Where the state a fleet has to agree on is kept: kept answers, the host map, the replay guard, the generation of each cache tag. Needed only when more than one container serves the site. See below |
1124
+ | `home` | the post index | `HomePath`, `HomeCollection` | What `createHome` serves at `/`. See below |
1125
+ | `theme` | the barakoCMS palette | `Colors`, `Fonts`, `Radii`, `Layout`, `Space`, `Text`, `Tokens`, `Tones`, `StyleRecipes` | Colours, faces, radii, column widths, a site's own named values and tones, and the looks its blocks can name. See below |
501
1126
 
502
1127
  The third column is the whole of the split. A key marked operator only is one the image decides for
503
1128
  every tenant it serves, and each is that for a reason you can name: `types`, `fields`, `pageFields`
504
1129
  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,
1130
+ `cmsTimeoutMs`, `cmsUrl`, `tenant`, `sites` and `store` are facts about the deployment rather than
1131
+ the site,
506
1132
  and `pages` has to match a route file on disk. Everything else is that tenant's data, edited in
507
1133
  barakoBrew, and wins over what this file said. A tenant that sets nothing renders exactly as the
508
1134
  file says.
@@ -591,17 +1217,90 @@ The settings are the singleton `site` type from barakoCMS `docs/site-settings.md
591
1217
  (`POST /api/content-types/blueprints/site`, then publish its one entry). The engine reads `Name`,
592
1218
  `Tagline`, `Url`, `Locale`, `Logo`, `LogoAlt`, `FooterLogo`, `Favicon`, `ShareImage`, `Copyright`,
593
1219
  `Colors` (the theme slots), `Fonts` (a family name per role, and the stylesheet that loads it),
594
- `Radii`, `Layout`, `TopBar`, `HeaderLinks`, `FooterColumns`, `SocialLinks`, `HeaderPath`,
1220
+ `Radii`, `Layout`, `Tokens` and `Tones` (see [Tokens and tones](#tokens-and-tones)), `StyleRecipes` (see [Style recipes](#style-recipes)), `TopBar`, `HeaderLinks`, `MenuLinks`, `HeaderActions`, `FooterColumns`, `SocialLinks`, `HeaderPath`,
595
1221
  `HeaderTone`, `FooterPath`, `FooterTone`, `AssetsAsSupplied`, `LogoAsSupplied`, `LogoClearSpace`,
596
- `PageSizes` and `ReservedSlugs`. `Collections` and `OptionColors` are read as the collections section
1222
+ `PageSizes`, `ReservedSlugs`, `Labels`, `HomePath` and `HomeCollection`. `Collections`, `OptionStyles` and `OptionColors` are read as the collections section
597
1223
  describes. `Variants` are not rendered yet. Every value is checked for shape; one that fails, and any the
598
1224
  entry leaves out, keeps the configured value, so a half-filled theme renders. A link is a path on the
599
1225
  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.
1226
+ host the tenant was found by. A link in `HeaderLinks`, `TopBar` or `FooterColumns` may also carry
1227
+ `badge`, a marker of up to 12 characters drawn beside the label, and `external: true`, which the
1228
+ built-in header does not need (every header link is already a plain anchor) and a theme can read.
1229
+ A link in `HeaderLinks` or `MenuLinks` may also carry `activeOn` and `children`; see
1230
+ [The built-in header](#the-built-in-header).
601
1231
 
602
1232
  `createSiteLayout` and `createSiteMetadata` render the root layout from all of this: `lang`, the
603
1233
  faces, the palette, the top bar, header links, footer columns, social links and the copyright line.
604
1234
 
1235
+ **What the site serves at `/`.** Every site used to be a blog at the root, because the root route
1236
+ mounted the post index and nothing else could be named:
1237
+
1238
+ | Field | Type | What |
1239
+ | --- | --- | --- |
1240
+ | `HomePath` | string | A site path such as `/home`. The page the Pages module serves there is the home page. Nothing served there falls back to the index, so naming a page before writing it is safe |
1241
+ | `HomeCollection` | string | The key of a collection. Its index is the home page. `HomePath` wins when both are set |
1242
+
1243
+ Neither set, `/` is the post index, which is what it was. The blog's own `post`, `author` and
1244
+ `category` are ordinary `Collections` entries now, so a school whose news lives in `article` with a
1245
+ `Headline` replaces `post` in its settings and gets both its list and its item pages from that entry.
1246
+ The RSS link in the built-in header, and the feed alternate in the page metadata, appear only when
1247
+ some collection has `feed` on, so a clinic with no posts stops advertising an empty feed.
1248
+
1249
+ **The words a visitor reads.** `Labels` is the visitor-facing copy, key by key. A school setting
1250
+ `Locale` to `fil-PH` used to get Filipino dates beside English "min read" and "Related":
1251
+
1252
+ ```json
1253
+ { "Labels": { "minRead": "minutong pagbasa", "by": "ni", "related": "Kaugnay" } }
1254
+ ```
1255
+
1256
+ | Key | English |
1257
+ | --- | --- |
1258
+ | `minRead` | `min read` |
1259
+ | `by` | `by` |
1260
+ | `related` | `Related` |
1261
+ | `relatedNote` | `cosine similarity, computed on load, not curated` |
1262
+ | `featured` | `Featured` |
1263
+ | `back` | `Back` |
1264
+ | `home` | `Home` |
1265
+ | `preview` | The banner over a draft being previewed |
1266
+ | `untitled` | `Untitled` |
1267
+ | `feed` | `RSS` |
1268
+ | `empty`, `emptyNote` | The notice on an index with nothing published |
1269
+ | `failed`, `failedNote` | The notice on an index whose read failed |
1270
+ | `shareInvalid` | `This link is not valid or has expired.` |
1271
+ | `openMenu`, `closeMenu` | `Open menu`, `Close menu`: the header's phone menu button |
1272
+ | `menu` | `Menu`: the name of the phone menu's links |
1273
+ | `submenu` | `{label} links`: the button beside a header link with children, `{label}` its label |
1274
+ | `onThisPage` | `On this page`: the heading over a tree page's rail |
1275
+ | `closeContents` | `Close`: the closed phone disclosure's control while it is open |
1276
+
1277
+ A key left out, or saved as anything but a word, keeps the English, so a half-filled map reads. A
1278
+ build-time site passes `labels` to `defineConfig`. Nothing about a site's own content is here: a
1279
+ collection's heading is its `label` and how its count reads is its `noun`.
1280
+
1281
+ **The colour slots, by role.** `Colors` sets any slot of the palette, one at a time. The slots are
1282
+ `pageBg`, `surface`, `ink`, `proseInk`, `secondaryInk`, `muted`, `hairline`, `accent`, `accentHover`,
1283
+ `accentInk`, `accentTint`, `accentTintBorder`, `accentBorderStrong`, `inverse`, `inverseChrome`,
1284
+ `inverseInk`, `inverseAccent`, `code` and `success`. A role says where a colour goes rather than what
1285
+ it looks like: `inverse` is the band that reverses the page, the footer and a code panel and an
1286
+ inverse block, so a bakery with a cream footer sets `inverse` to cream and reads right doing it.
1287
+
1288
+ ```json
1289
+ { "Colors": { "accent": "#17458F", "inverse": "#F4E3C1", "inverseInk": "#3B2A17" } }
1290
+ ```
1291
+
1292
+ Six slots shipped in 0.3.0 under barakocms.com's own names, and those still work: `darkPanel`,
1293
+ `darkPanelChrome`, `darkPanelInk`, `darkPanelAccent`, `codeGreen` and `accentTintBorderStrong` are
1294
+ read into `inverse`, `inverseChrome`, `inverseInk`, `inverseAccent`, `code` and `accentBorderStrong`.
1295
+ Setting either name sets both, so a tenant saved before the rename keeps its site and a consumer's
1296
+ own component reading `theme.colors.darkPanel` keeps compiling. The old names are deprecated and go
1297
+ in 2.0.0. An `OptionStyles` or `OptionColors` entry naming an old slot resolves too.
1298
+
1299
+ Sizes work the same way. `Text` is the type scale by role, `meta`, `small`, `body`, `lead`,
1300
+ `subheading`, `heading`, `title`, `display` and `pageTitle`, and `Space` is the spacing scale. The
1301
+ blocks and the screens read those names, so a tenant that wants bigger headings sets `title` once
1302
+ instead of asking for a release.
1303
+
605
1304
  **How many items, per tenant.** `PageSizes` sets the four counts for this site, each on its own:
606
1305
 
607
1306
  ```json
@@ -669,17 +1368,39 @@ blocks are drawn there instead, resolved and bound exactly as the page route res
669
1368
  | Field | Type | What |
670
1369
  | --- | --- | --- |
671
1370
  | `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 |
1371
+ | `HeaderTone` | string | `page`, `surface`, `accent`, `inverse`, `gradient`, `wash`, or a name from `Tones`: the tone behind the header region. `page` when unset or not one of those |
673
1372
  | `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 |
1373
+ | `FooterTone` | string | The same names, behind the footer region |
675
1374
 
676
1375
  Set neither and nothing changes: `TopBar`, `HeaderLinks`, `FooterColumns`, `SocialLinks` and
677
1376
  `Copyright` draw the built-in chrome with the markup they always had, which is what keeps a site
678
1377
  whose own CSS keys off that markup rendering. A path with nothing served at it does the same, so
679
1378
  naming a page before writing it is safe, and so is a typo.
680
1379
 
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.
1380
+ ### The built-in header
1381
+
1382
+ With no `HeaderPath`, the header is the logo, the page menu, `HeaderLinks` and the feed link. Four
1383
+ more settings shape it:
1384
+
1385
+ | Setting | What |
1386
+ | --- | --- |
1387
+ | `activeOn` on a link | Space separated site paths the link is current on. `/` is the home page alone; any other path covers itself and everything below it, so `/docs` is current on `/docs/intro`. A trailing slash is ignored. The current link gets `aria-current="page"` and the class `bp-current`. It can name a path the link does not point to: a chat server's link can be current on `/community` |
1388
+ | `children` on a link | Links under this one, one level deep, each with its own `label`, `href`, `badge`, `external` and `activeOn`. Drawn as a dropdown that opens on hover and on keyboard focus and closes on Escape and when focus leaves, beside a button with `aria-expanded` that opens it too. A child's `activeOn` marks its parent current as well. The parent keeps its own `href` |
1389
+ | `MenuLinks` | The rows of the phone menu, in the shape of `HeaderLinks`. Children are drawn as an indented group. Unset or empty, the phone menu shows `HeaderLinks` |
1390
+ | `HeaderActions` | Up to four call to action links after the header links, each `{ label, href, variant }` with `badge` and `external` as on any link. `variant` is `primary` (filled with the accent), `secondary` (outlined) or `plain`; unset or unknown is `primary` |
1391
+
1392
+ Below 48rem, a site that uses any of these gets a menu button in place of the header links and the
1393
+ actions, opening a sheet with the `MenuLinks` rows, the feed link and the actions. The button is a
1394
+ `details` element and every link is in the server's HTML, so the menu and the dropdowns work
1395
+ without JavaScript; the script adds Escape and keeps `aria-expanded` true to what is shown. The
1396
+ words on the controls are `openMenu`, `closeMenu`, `menu` and `submenu` in `Labels` (`submenu` is
1397
+ `{label} links`, with the parent's label in place of `{label}`).
1398
+
1399
+ A site that sets none of them gets the header it had before, byte for byte.
1400
+
1401
+ A region page is chrome rather than somewhere to go, so it is left out of the menu and the sitemap,
1402
+ and it answers 404 at its own route: what it holds is already on every page. A collection's
1403
+ `indexPage` is treated the same way.
683
1404
 
684
1405
  A header region replaces the whole band, the site name, the menu and the RSS link along with the top
685
1406
  bar. There is no navigation or logo block yet, so a header region lists its own links until the block
@@ -699,12 +1420,14 @@ The registry the regions render with is the one passed to `createSiteLayout(conf
699
1420
  the built-in blocks when none was passed.
700
1421
 
701
1422
  **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.
1423
+ `<cacheTag>:<tenant>`, and a narrower tag beside it: `<tenant tag>:entry:<type>:<slug>` on a read of
1424
+ one entry, `<tenant tag>:type:<type>` on a list, a search, the settings and the page tree. The webhook
1425
+ purges the tags of the tenant its host resolves to, so point each tenant's webhook at
1426
+ `https://<that tenant's domain>/api/revalidate`, signed with that tenant's own key (see
1427
+ [One key per tenant](#one-key-per-tenant)). A publish on one tenant leaves every other tenant's cached
1428
+ reads in place.
1429
+
1430
+ **When the CMS is down.** Each successful read is also kept in the store, keyed by CMS, tenant and path.
708
1431
  A read that fails with a network error, a 5xx, or no answer within `cmsTimeoutMs` answers from the
709
1432
  last good copy and logs a warning; the next successful read replaces it. For ten seconds after such a
710
1433
  failure that read answers from the copy without asking the CMS, so an outage costs one request per
@@ -739,8 +1462,8 @@ While a tenant is holding, for that tenant only:
739
1462
  - route handlers (revalidate, blocks, `/_share`, `/api/share/redeem`) and `/_next/static` are served
740
1463
  as usual.
741
1464
 
742
- Switching `Mode` is a publish: the webhook purges the tenant's tag and the next request reads the new
743
- settings. No deploy.
1465
+ Switching `Mode` is a publish: the webhook drops the tag the settings read carries and the next
1466
+ request reads the new settings. No deploy.
744
1467
 
745
1468
  **Site share links.** barakoCMS creates, lists and revokes them; anyone who may update the `site` type
746
1469
  can. A client is given `{site Url}/_share#{key}`. The key is in the fragment, so no server log, proxy
@@ -834,9 +1557,197 @@ stylesheet named in `theme.fontSources` when the face is not loaded from there,
834
1557
  They are not a way to compose a post out of arbitrary sections. Anything that has to sit between two
835
1558
  paragraphs belongs to the block model, which is issue #6, because only the body knows where it goes.
836
1559
 
1560
+ **A theme that draws the page itself.** A deployment that ports an existing design registers its own
1561
+ blocks under the built-in type names and ships its own stylesheet. Two things would still be the
1562
+ engine's, and neither can be undone from a stylesheet because both are inline: the page frame
1563
+ (`main`, its padding, the title and the breadcrumbs) and the gap between blocks. So:
1564
+
1565
+ ```tsx
1566
+ export default createPage(config, blocks, { bare: true }); // only the blocks
1567
+ ```
1568
+
1569
+ ```css
1570
+ body { --bp-gap: 0; } /* every block list, at every level */
1571
+ ```
1572
+
1573
+ `bare` is also taken by `createHome` and `createViewerPage`. `--bp-gap` falls back to
1574
+ `theme.space.lg`, so a site that sets neither renders as it did.
1575
+
1576
+ ### Tokens and tones
1577
+
1578
+ The theme's slots are the engine's roles. A design has its own palette on top of them: a colour per
1579
+ product, a tint for a badge, a length the design repeats. Those are `Tokens` and `Tones` in the site
1580
+ settings, or `theme.tokens` and `theme.tones` in `defineConfig`. A tenant's entries merge over the
1581
+ configured ones name by name.
1582
+
1583
+ ```json
1584
+ "Tokens": { "accent": "#E4572E", "cms-ink": "#1D3A8A", "cms-bg": "#E8EEFD", "gutter": "24px", "serif": "'Zilla Slab', Georgia, serif" },
1585
+ "Tones": { "cms": { "ink": "cms-ink", "bg": "cms-bg", "edge": "#B9C8F5" } }
1586
+ ```
1587
+
1588
+ A token is a name and one value: a colour (checked as `Colors` is), a length or a `clamp()` of three
1589
+ (checked as `Text` is), or a font stack of plain or quoted family names. A name is a letter, then
1590
+ letters, digits and hyphens, up to 40. Each token is emitted on the root as `--t-<name>` by
1591
+ `createSiteLayout` (through `themeVariablesCss`), so a site's stylesheet and its blocks write
1592
+ `var(--t-accent)` instead of a hex value. A name or a value that fails its check is dropped and the
1593
+ rest are kept. Up to 200.
1594
+
1595
+ A tone is a name and three colours: `ink`, `bg` and `edge` (hairlines and borders). Each is a token
1596
+ name, a `Colors` slot name, or a colour written out, looked up in that order when the tone is drawn,
1597
+ so changing a token changes every tone that names it. Text of every kind on the tone is the `ink`,
1598
+ and a filled accent is the `ink` with the `bg` as its text. A name is lower case letters, digits and
1599
+ hyphens; a built-in name is refused, since those follow `Colors`. A tone with a colour that resolves
1600
+ to nothing is dropped. Up to 40.
1601
+
1602
+ Every block field that picks a tone offers the site's tones after the built-in six, and a block
1603
+ stores `"tone": "cms"` the way it stores `"tone": "accent"`. `HeaderTone` and `FooterTone` take them
1604
+ too. `/api/blocks` lists them on every tone field: a build-time site's own, and on a request-time
1605
+ site the requesting tenant's, when the route is mounted as `createBlockSchemaRoute(config, blocks)`.
1606
+
1607
+ A site that sets neither gets no `--t-` property and no new option anywhere, and renders byte for
1608
+ byte as it did.
1609
+
837
1610
  Read time is derived from the body at 200 words a minute, with fenced code blocks excluded, so there
838
1611
  is no field to fill in and nothing to keep in sync.
839
1612
 
1613
+ ### Style recipes
1614
+
1615
+ A primitive draws itself from tokens, which keeps every page one design and also means a designed
1616
+ section cannot be built from primitives: a card with its own padding, border, corner and shadow is
1617
+ not something any token names. A recipe is that card, said once in the site settings as
1618
+ `StyleRecipes` (or `theme.recipes` in `defineConfig`), and a block wears it with `recipe`.
1619
+
1620
+ ```json
1621
+ "StyleRecipes": {
1622
+ "card": {
1623
+ "class": "lift",
1624
+ "style": {
1625
+ "padding": "22px 24px",
1626
+ "background": "{colors.surface}",
1627
+ "border": "1px solid {colors.hairline}",
1628
+ "border-radius": "16px",
1629
+ "box-shadow": "0 1px 2px rgba(16,18,35,.04)",
1630
+ "display": "flex",
1631
+ "flex-direction": "column",
1632
+ "gap": "{space.sm}"
1633
+ }
1634
+ },
1635
+ "eyebrow": {
1636
+ "style": {
1637
+ "font-family": "{fonts.mono}",
1638
+ "font-size": "11px",
1639
+ "letter-spacing": ".16em",
1640
+ "text-transform": "uppercase",
1641
+ "color": "{colors.muted}"
1642
+ }
1643
+ }
1644
+ }
1645
+ ```
1646
+
1647
+ ```json
1648
+ { "type": "panel", "props": { "recipe": "card", "content": [[
1649
+ { "type": "text", "props": { "value": "01 Products", "recipe": "eyebrow" } }
1650
+ ]] } }
1651
+ ```
1652
+
1653
+ A recipe is a name and two keys, both optional but not both empty:
1654
+
1655
+ - `class`: class names, space separated, put on the element beside the style. A style attribute
1656
+ cannot say `:hover`, a focus ring or a media query, so those go in the site's own stylesheet under
1657
+ this class. Up to 8, each a letter or underscore, then letters, digits, `_` and `-`.
1658
+ - `style`: CSS property names, written as a stylesheet writes them, to values. Up to 40.
1659
+
1660
+ A value is CSS text, and `{name}` in it stands for a theme value: `{accent}` is the token of that
1661
+ name from `Tokens`, and `{colors.<slot>}`, `{space.<step>}`, `{radii.<name>}`, `{text.<role>}`,
1662
+ `{fonts.<role>}` and `{layout.<name>}` are the theme's own. References are resolved when the block
1663
+ draws, against the requesting tenant's theme, so changing a token changes every recipe that names
1664
+ it. A declaration whose reference does not resolve is left out and the rest of the recipe draws.
1665
+
1666
+ The properties a recipe may set:
1667
+
1668
+ | Group | Properties |
1669
+ | --- | --- |
1670
+ | box | `display`, `position` (`static` or `relative` only), `box-sizing`, `width`, `min-width`, `max-width`, `height`, `min-height`, `max-height`, `aspect-ratio`, `overflow`, `overflow-x`, `overflow-y`, `vertical-align`, `opacity` |
1671
+ | spacing | `margin`, `padding` and their four sides, `margin-block`, `margin-inline`, `padding-block`, `padding-inline`, `gap`, `row-gap`, `column-gap` |
1672
+ | typography | `font-family`, `font-size`, `font-weight`, `font-style`, `font-variant-numeric`, `line-height`, `letter-spacing`, `text-align`, `text-transform`, `text-decoration`, `text-underline-offset`, `text-wrap`, `text-overflow`, `white-space`, `overflow-wrap`, `word-break` |
1673
+ | colour | `color`, `background`, `background-color` |
1674
+ | border | `border`, `border-top`, `border-right`, `border-bottom`, `border-left`, `border-color`, `border-style`, `border-width` |
1675
+ | radius | `border-radius` |
1676
+ | shadow | `box-shadow`, `text-shadow` |
1677
+ | grid | `grid-template-columns`, `grid-template-rows`, `grid-auto-flow`, `grid-auto-rows`, `grid-column`, `grid-row`, `justify-items`, `place-items`, `place-content` |
1678
+ | flex | `flex`, `flex-direction`, `flex-wrap`, `flex-grow`, `flex-shrink`, `flex-basis`, `align-items`, `align-content`, `align-self`, `justify-content`, `justify-self`, `order` |
1679
+ | engine | `--bp-ink`, `--bp-ink-soft`, `--bp-muted`, `--bp-hairline`, `--bp-accent`, `--bp-on-accent` (the tone the blocks inside read), `--bp-gap`, `--bp-list` (a block list's gap and display), `--bp-code-ink`, `--bp-code-bg`, `--bp-code-size`, `--bp-code-pad`, `--bp-code-radius` (inline code in a text block) |
1680
+
1681
+ The list is also exported as `RECIPE_PROPERTY_GROUPS`, for an editor.
1682
+
1683
+ The values reach a style attribute, which React writes by joining `name:value;` with no CSS
1684
+ escaping, so a value is held to a narrow shape rather than cleaned. It is letters, digits, spaces
1685
+ and `# % . , ( ) / + * -`, up to 240 characters, with quotes only around a plain family name
1686
+ (`'JetBrains Mono'`). Parentheses balance, and a function is one of `calc`, `min`, `max`, `clamp`,
1687
+ `minmax`, `repeat`, `fit-content`, `var` (naming a custom property and nothing else), the colour
1688
+ functions (`rgb`, `rgba`, `hsl`, `hsla`, `hwb`, `lab`, `lch`, `oklab`, `oklch`, `color-mix`) and the
1689
+ gradients. So `;`, `:`, braces, angle brackets, a backslash, `!important`, `@`, a comment, `url()`,
1690
+ `expression()`, `image-set()` and `attr()` are all refused: a recipe cannot load anything and
1691
+ cannot leave its own declaration. A property off the list, or a value that fails, is dropped and
1692
+ the rest of the recipe kept, the way `Colors` drops one bad colour. The check runs again when the
1693
+ block draws, on the resolved value, so a theme built by hand gets it too. A name is lower case
1694
+ letters, digits and hyphens, up to 40. Up to 400 recipes, merged over the configured ones name by
1695
+ name: barakocms.com's pages are about three hundred looks, one per element its design styles.
1696
+
1697
+ What wearing one does to a block:
1698
+
1699
+ - The block is its own cell. Every block in a list sits in a wrapper, and under a recipe the wrapper
1700
+ is `display: contents`, as a transparent block's is, so the recipe's element is what its parent
1701
+ lays out: a lede that takes `flex: 1 1 420px` beside a claim, a card that is the grid's item. A
1702
+ name the site has no recipe for keeps the wrapper along with the block's own look.
1703
+ That holds for an inline element too. A `link` or a `span` text wearing `display: inline-block`
1704
+ in a list, which is a column, is that column's flex item and is stretched to its width like any
1705
+ other; without a recipe it sits inside a block wrapper at its own width. A recipe that wants its
1706
+ own width says so: `align-self: flex-start`, or `width: fit-content`.
1707
+ - The recipe replaces the block's own inline look on its outer element outright. It is not merged
1708
+ over it: the card's own padding under a recipe that only set the corner is a look nobody drew.
1709
+ The block's token props for that element (`padding`, `radius`, `border` and so on) are not
1710
+ applied.
1711
+ - What makes the block work stays: a `stickyBar` keeps `position: sticky`, a `flow` keeps its cells
1712
+ (`--bp-list: contents`), a `row` and a `tabGroup` stay wrapping rows, a `list` keeps its marker, a
1713
+ `comparisonTable` keeps its own horizontal scroll, an `embed` keeps its width, shape and no
1714
+ border, a `figure` keeps no margin, and a `rotatingText` keeps the box its words stack in.
1715
+ - So does the layout the block's own props ask for: a `grid` or `flow` with `columns` keeps its
1716
+ grid and track list, and `align` and `justify` on a `stack`, `row` or `flow` are kept. A prop left
1717
+ unset leaves the recipe to say it, so a `flow` with no `columns` takes its grid from the recipe.
1718
+ - A `tone` named beside a recipe still sets the tone the blocks inside read. Without one, a recipe
1719
+ that changes the background sets `--bp-ink` and its neighbours itself.
1720
+ - A `tabPanel`'s recipe is its tab in the strip, and the open tab keeps its colours over it.
1721
+ - `recipe` is a text field, so a preset passes its own prop through, and a bound name picks a look
1722
+ per row: `"recipe": "card-{{item.Product}}"`.
1723
+ - A name the site has no recipe for draws the block's own look. A block that names none renders
1724
+ byte for byte as it did.
1725
+
1726
+ ### Inline marks in a text block
1727
+
1728
+ `text` takes `format`, `plain` (the default) or `inline`. Inline, its value is one line with marks
1729
+ in it: `` `code` ``, `*emphasis*`, `**strong**`, `[links](/docs)` and `==an accent==`, which is drawn
1730
+ as `<span class="bp-accent">`. Accents do not nest: `==` inside one ends it. Nothing that makes a
1731
+ block of its own is read, so a heading stays one element: `# x`, a list or a quote is the text it
1732
+ is, and an image keeps only its alt text. A value longer than 2000 characters is read as plain text,
1733
+ since emphasis parsing is quadratic at worst and the value may be bound from content.
1734
+
1735
+ It goes through the same safe renderer as a body (`renderInlineMarkdown` in `barakopress/markdown`):
1736
+ raw HTML is escaped, a link must be a path, an anchor, http, https or mailto or it keeps its words
1737
+ and loses the link, and every attribute is escaped. A path starting `//` or `/\` is another site
1738
+ to a browser, so it is refused here and in a body alike.
1739
+
1740
+ The marks are styled under `:where(.bp-inline)`, so each rule weighs no more than the element it
1741
+ names and a site's own `.lede code` wins. The accent and a link take the band's accent. Code is the
1742
+ mono face, and reads `--bp-code-ink`, `--bp-code-bg`, `--bp-code-size`, `--bp-code-pad` and
1743
+ `--bp-code-radius` first, so a recipe gives the code in one block a tint and a corner:
1744
+
1745
+ ```json
1746
+ "StyleRecipes": {
1747
+ "grabs-body": { "style": { "font-size": "14px", "--bp-code-bg": "#EEEBFD", "--bp-code-ink": "#4034A8", "--bp-code-pad": "1px 5px", "--bp-code-radius": "5px" } }
1748
+ }
1749
+ ```
1750
+
840
1751
  ### Assets used exactly as supplied
841
1752
 
842
1753
  Some marks come with an identity manual: never recoloured, never outlined, never put in a box, never
@@ -907,6 +1818,49 @@ Every read in `src/delivery.ts` is tagged with your `cacheTag`. `POST /api/reval
907
1818
  the moment barakoCMS says something changed, so a publish is live in one request and the steady state
908
1819
  is no database reads.
909
1820
 
1821
+ ### What a publish drops
1822
+
1823
+ A read carries a second, narrower tag beside the site's own: the entry's when it read one entry, the
1824
+ type's when it read a list, a search, the site settings or the page tree. barakoCMS names the content
1825
+ type and sends the entry's public data with every delivery, so a publish drops that type's tag and
1826
+ that entry's tag, and leaves every other entry of the type cached. Correcting one typo on a school
1827
+ with a thousand news posts re-renders that post and the lists it appears in, not the other 999.
1828
+
1829
+ A delivery that names no content type, or one this site renders nothing of, drops the site's own tag,
1830
+ which is every read it has cached. That is what every delivery did before this, so a workflow posting
1831
+ a body of its own keeps working.
1832
+
1833
+ A response barakoCMS marks `Cache-Control: no-store` is not cached: the class is remembered against
1834
+ that path and every later read of it asks the CMS uncached, carrying no tag. That is how a type that
1835
+ has to be fresh to the minute lives beside pages cached for hours.
1836
+
1837
+ ### Running more than one container
1838
+
1839
+ The kept answers, the host to tenant map, the webhook replay guard and the generation of each cache
1840
+ tag are in process by default, which is right for one container and wrong for two: a purge reaches
1841
+ one of them through the load balancer, and the others keep serving their own copies until the
1842
+ backstop runs out.
1843
+
1844
+ Pass a `store` on the config and they share it. It is three methods over anything the deployment
1845
+ already runs:
1846
+
1847
+ ```ts
1848
+ import type { PressStore } from "barakopress";
1849
+
1850
+ const store: PressStore = {
1851
+ async get(key) { return (await redis.get(key)) ?? null; },
1852
+ async set(key, value, ttlSeconds) { await (ttlSeconds ? redis.set(key, value, { EX: ttlSeconds }) : redis.set(key, value)); },
1853
+ async delete(key) { await redis.del(key); },
1854
+ // Only when the key is absent, atomically. This is what honours a replayed delivery once.
1855
+ async add(key, value, ttlSeconds) { return (await redis.set(key, value, { NX: true, ...(ttlSeconds ? { EX: ttlSeconds } : {}) })) !== null; },
1856
+ };
1857
+ ```
1858
+
1859
+ A purge one container receives then reaches the rest: the honoured delivery writes a generation
1860
+ against each tag it dropped, and a read carries the newest generation of its own tags in the URL it
1861
+ asks the CMS for, so a container that was never told is asking for something it has never cached.
1862
+ Next's own data cache stays per container; what crosses is the knowledge that it is out of date.
1863
+
910
1864
  Each read also carries a backstop, 300 seconds by default. That is not for correctness, it is for the
911
1865
  deployment where nobody ever created the webhook: without it their blog would be empty forever and
912
1866
  nothing would say why. With it, a missing webhook degrades publishing from instant to a few minutes.
@@ -1117,6 +2071,62 @@ start without that file, and refuses again if `PRESS_SECRET` and `REVALIDATE_SEC
1117
2071
  comes up with no secret can never be told that content changed. Only ports 80 and 443 are published:
1118
2072
  the API, the console and the site are reachable only through Caddy on the compose network.
1119
2073
 
2074
+ ### The site alone, on a host that already has a proxy
2075
+
2076
+ That stack brings its own Caddy, so on a machine already terminating TLS it fights the existing
2077
+ proxy for 80 and 443, and on a machine serving other sites that failure is somebody else's outage.
2078
+ `compose.site.yml` is the other case: one container on a loopback port, joined to the edge network
2079
+ the existing stack created, reached through the proxy the host already runs. The host supplies the
2080
+ server block and the certificate.
2081
+
2082
+ It is a different compose file and a different manifest, so it is a different registration. `--path`
2083
+ is the compose directory, which is where the `.env` lives, and `--file` is what stops BaryoVM
2084
+ reaching for `compose.yml` and starting Caddy:
2085
+
2086
+ ```bash
2087
+ baryovm stack add barakopress --vm oracle --sudo \
2088
+ --path /opt/barakopress \
2089
+ --file compose.site.yml \
2090
+ --release-file ./baryovm.site.json
2091
+ baryovm stack release barakopress
2092
+ ```
2093
+
2094
+ The manifest syncs to `/opt/barakopress-src`, not to the compose directory, because it syncs with
2095
+ `--delete` and that directory holds the `.env`. Its `.env` needs `SITE_PORT`, `CMS_URL` pointing at
2096
+ the API's container name on the shared network, `SITE_URL` and `PRESS_SECRET`; `SITE_DOMAIN`,
2097
+ `API_DOMAIN` and `CONSOLE_DOMAIN` belong to the full stack and are not read here.
2098
+
2099
+ A site deployed this way writes its identity at build time, so it keeps its config and its root
2100
+ routes under `deploy/<name>/` and the manifest passes `PRESS_DEPLOY=<name>` to the build. See
2101
+ `deploy/press.baryo.dev/`. The reason it cannot use the reference app as it stands: every page there
2102
+ lives under `app/%5Fpress/[site]`, which only the proxy's rewrite reaches, and the rewrite only fires
2103
+ when the config sets `sites`. With no tenant to resolve to, every page answers 404 while the feed and
2104
+ the sitemap still work.
2105
+
2106
+ A site with its own theme keeps the overlay in its own repository and passes the directory as a
2107
+ build context instead of copying it into `deploy/`:
2108
+
2109
+ ```bash
2110
+ docker buildx build \
2111
+ --build-context overlay=../barakocms-site/theme \
2112
+ --build-arg PRESS_TRAILING_SLASH=true \
2113
+ -t barako-press:barakocms .
2114
+ ```
2115
+
2116
+ In compose that is `build.additional_contexts: { overlay: ../barakocms-site/theme }`. The overlay is
2117
+ laid over the reference app the same way. If it ships its own `app/%5Fpress/`, it is a request-time
2118
+ site with its own layout and routes, and its tree replaces the reference one; otherwise the tenant
2119
+ tree is removed as above. `PRESS_TRAILING_SLASH=true` sets Next's `trailingSlash`, for a site whose
2120
+ URLs end in a slash; configure its webhook URL with the slash too (see the revalidate endpoint).
2121
+
2122
+ Extra blocks go in the same way, as a `plugins` build context: see
2123
+ [Plugin packages](#plugin-packages).
2124
+
2125
+ One thing to expect on a first release: the image prerenders during `docker build`, where the CMS is
2126
+ not reachable, so the index, the feed and the sitemap are built empty and correct themselves one
2127
+ revalidate window later. That is why the manifest's `verify` greps the page rather than reading the
2128
+ status code, and why it waits long enough to see it happen.
2129
+
1120
2130
  ## The look check
1121
2131
 
1122
2132
  Before a site's domain moves to this stack, the look check proves the rebuilt pages look like the