caracms 0.0.0-stage → 0.2.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 (281) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -2
  3. package/bin/caracms.mjs +174 -0
  4. package/bin/gate.mjs +275 -0
  5. package/bin/init.mjs +712 -0
  6. package/bin/media.mjs +662 -0
  7. package/bin/site-runner.mjs +61 -0
  8. package/bin/templates/Page.astro.txt +31 -0
  9. package/bin/templates/TextBlock.astro.txt +13 -0
  10. package/bin/templates/caracms.config.ts.txt +22 -0
  11. package/bin/templates/catch-all.astro.txt +25 -0
  12. package/bin/templates/health.ts.txt +6 -0
  13. package/bin/templates/railway.ts.txt +147 -0
  14. package/package.json +66 -4
  15. package/skills/caracms-adopt/SKILL.md +508 -0
  16. package/skills/caracms-adopt/look-same.mjs +957 -0
  17. package/skills/caracms-blocks/SKILL.md +376 -0
  18. package/skills/caracms-content/SKILL.md +201 -0
  19. package/skills/caracms-tina/SKILL.md +427 -0
  20. package/skills/caracms-tina/field-kinds.md +88 -0
  21. package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
  22. package/src/block-runs.ts +84 -0
  23. package/src/cards.ts +158 -0
  24. package/src/cli/schemas.ts +122 -0
  25. package/src/cli/site.ts +66 -0
  26. package/src/cli/validate.ts +471 -0
  27. package/src/code-routes-disk.ts +67 -0
  28. package/src/code-routes.ts +134 -0
  29. package/src/columns.ts +39 -0
  30. package/src/components/Block.astro +46 -0
  31. package/src/components/BlockRunTree.astro +45 -0
  32. package/src/components/Blocks.astro +139 -0
  33. package/src/components/Buttons.astro +116 -0
  34. package/src/components/Fonts.astro +14 -0
  35. package/src/components/Form.astro +221 -0
  36. package/src/components/Head.astro +62 -0
  37. package/src/components/Link.astro +68 -0
  38. package/src/components/Lockup.astro +116 -0
  39. package/src/components/Media.astro +124 -0
  40. package/src/components/MediaPlayer.astro +102 -0
  41. package/src/components/RenderDocument.astro +64 -0
  42. package/src/components/RichText.astro +59 -0
  43. package/src/components/Text.astro +81 -0
  44. package/src/components/attributes.ts +33 -0
  45. package/src/components/descriptor.ts +45 -0
  46. package/src/components/form-client.ts +182 -0
  47. package/src/components/media-player.ts +46 -0
  48. package/src/components/media-props.ts +35 -0
  49. package/src/components/sitemap.xml.ts +36 -0
  50. package/src/config.ts +825 -0
  51. package/src/content-schema.ts +166 -0
  52. package/src/content.ts +422 -0
  53. package/src/document.ts +579 -0
  54. package/src/editor/README.md +405 -0
  55. package/src/editor/addresses.ts +61 -0
  56. package/src/editor/brand.ts +118 -0
  57. package/src/editor/client/block-list/BlockList.tsx +342 -0
  58. package/src/editor/client/block-list/Picker.tsx +105 -0
  59. package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
  60. package/src/editor/client/block-list/insert-points.ts +56 -0
  61. package/src/editor/client/block-list/link.ts +71 -0
  62. package/src/editor/client/block-list/reorder.ts +105 -0
  63. package/src/editor/client/block-list/summary.ts +118 -0
  64. package/src/editor/client/collections/CollectionGrid.tsx +143 -0
  65. package/src/editor/client/collections/CollectionTable.tsx +406 -0
  66. package/src/editor/client/collections/table.ts +270 -0
  67. package/src/editor/client/context.tsx +27 -0
  68. package/src/editor/client/documents/DocMenu.tsx +154 -0
  69. package/src/editor/client/documents/actions.ts +192 -0
  70. package/src/editor/client/documents/dialogs.tsx +687 -0
  71. package/src/editor/client/editor.ts +995 -0
  72. package/src/editor/client/fields/ButtonsField.tsx +87 -0
  73. package/src/editor/client/fields/DateField.tsx +200 -0
  74. package/src/editor/client/fields/FieldControl.tsx +61 -0
  75. package/src/editor/client/fields/FieldFrame.tsx +109 -0
  76. package/src/editor/client/fields/GroupField.tsx +113 -0
  77. package/src/editor/client/fields/HiddenToggle.tsx +51 -0
  78. package/src/editor/client/fields/LinkField.tsx +307 -0
  79. package/src/editor/client/fields/ListField.tsx +642 -0
  80. package/src/editor/client/fields/LockupField.tsx +365 -0
  81. package/src/editor/client/fields/MediaField.tsx +823 -0
  82. package/src/editor/client/fields/PageFields.tsx +82 -0
  83. package/src/editor/client/fields/ReferenceField.tsx +209 -0
  84. package/src/editor/client/fields/RichTextField.tsx +306 -0
  85. package/src/editor/client/fields/SelectField.tsx +63 -0
  86. package/src/editor/client/fields/SeoGroup.tsx +282 -0
  87. package/src/editor/client/fields/SimpleFields.tsx +89 -0
  88. package/src/editor/client/fields/TextField.tsx +51 -0
  89. package/src/editor/client/fields/date.ts +73 -0
  90. package/src/editor/client/fields/host.tsx +172 -0
  91. package/src/editor/client/fields/list-summary.ts +85 -0
  92. package/src/editor/client/fields/lockup-rules.ts +181 -0
  93. package/src/editor/client/fields/parts.tsx +458 -0
  94. package/src/editor/client/fields/rich-text-commands.ts +183 -0
  95. package/src/editor/client/fields/rich-text-doc.ts +156 -0
  96. package/src/editor/client/fields/rich-text-schema.ts +224 -0
  97. package/src/editor/client/fields/stale.ts +58 -0
  98. package/src/editor/client/inbox/InboxStage.tsx +272 -0
  99. package/src/editor/client/inbox/store.ts +311 -0
  100. package/src/editor/client/live/format.ts +77 -0
  101. package/src/editor/client/live/marks.tsx +90 -0
  102. package/src/editor/client/live/notices.tsx +213 -0
  103. package/src/editor/client/live/publish.tsx +617 -0
  104. package/src/editor/client/live/session.tsx +162 -0
  105. package/src/editor/client/main.tsx +12 -0
  106. package/src/editor/client/media/LibraryStage.tsx +75 -0
  107. package/src/editor/client/media/ReplaceDialog.tsx +355 -0
  108. package/src/editor/client/media/store.ts +618 -0
  109. package/src/editor/client/nav/DocRow.tsx +122 -0
  110. package/src/editor/client/nav/search.ts +71 -0
  111. package/src/editor/client/nav/tree.ts +93 -0
  112. package/src/editor/client/overlays/OverlayMount.tsx +26 -0
  113. package/src/editor/client/overlays/editor-marks.ts +159 -0
  114. package/src/editor/client/overlays/index.ts +50 -0
  115. package/src/editor/client/overlays/layer.ts +437 -0
  116. package/src/editor/client/overlays/overlay-css.ts +177 -0
  117. package/src/editor/client/overlays/rich-text-bar.ts +717 -0
  118. package/src/editor/client/pane/Pane.tsx +91 -0
  119. package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
  120. package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
  121. package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
  122. package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
  123. package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
  124. package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
  125. package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
  126. package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
  127. package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
  128. package/src/editor/client/pane/parts.tsx +179 -0
  129. package/src/editor/client/pane/switcher.tsx +165 -0
  130. package/src/editor/client/preview/driver.ts +640 -0
  131. package/src/editor/client/preview/field-text.ts +71 -0
  132. package/src/editor/client/preview/follow-links.ts +234 -0
  133. package/src/editor/client/preview/layout.ts +37 -0
  134. package/src/editor/client/preview/link-target.ts +50 -0
  135. package/src/editor/client/preview/markers.ts +86 -0
  136. package/src/editor/client/preview/page-edit.ts +632 -0
  137. package/src/editor/client/preview/page-editor-registry.ts +63 -0
  138. package/src/editor/client/preview/page-editor.ts +235 -0
  139. package/src/editor/client/preview/render-sequencer.ts +113 -0
  140. package/src/editor/client/preview/selection.ts +116 -0
  141. package/src/editor/client/preview/warnings.ts +61 -0
  142. package/src/editor/client/shell/App.tsx +101 -0
  143. package/src/editor/client/shell/Bar.tsx +197 -0
  144. package/src/editor/client/shell/Brand.tsx +33 -0
  145. package/src/editor/client/shell/Icons.tsx +87 -0
  146. package/src/editor/client/shell/NarrowNote.tsx +47 -0
  147. package/src/editor/client/shell/Popovers.tsx +47 -0
  148. package/src/editor/client/shell/Stage.tsx +132 -0
  149. package/src/editor/client/shell/Toast.tsx +61 -0
  150. package/src/editor/client/state/document-store.ts +465 -0
  151. package/src/editor/client/state/keeper.ts +713 -0
  152. package/src/editor/client/state/live.ts +257 -0
  153. package/src/editor/client/state/schema.ts +42 -0
  154. package/src/editor/client/state/store.ts +39 -0
  155. package/src/editor/fonts/OFL-GeistMono.txt +93 -0
  156. package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
  157. package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
  158. package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
  159. package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
  160. package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
  161. package/src/editor/middleware.ts +62 -0
  162. package/src/editor/routes/_live.ts +26 -0
  163. package/src/editor/routes/admin.astro +80 -0
  164. package/src/editor/routes/create.ts +19 -0
  165. package/src/editor/routes/delete.ts +19 -0
  166. package/src/editor/routes/document.ts +39 -0
  167. package/src/editor/routes/documents.ts +30 -0
  168. package/src/editor/routes/github.ts +23 -0
  169. package/src/editor/routes/history.ts +9 -0
  170. package/src/editor/routes/lock.ts +9 -0
  171. package/src/editor/routes/login/assets.d.ts +10 -0
  172. package/src/editor/routes/login/login.ts +43 -0
  173. package/src/editor/routes/media.ts +16 -0
  174. package/src/editor/routes/move.ts +17 -0
  175. package/src/editor/routes/notice.ts +9 -0
  176. package/src/editor/routes/preview.astro +77 -0
  177. package/src/editor/routes/publish.ts +9 -0
  178. package/src/editor/routes/status.ts +9 -0
  179. package/src/editor/routes/submissions.ts +16 -0
  180. package/src/editor/routes/try-again.ts +9 -0
  181. package/src/editor/routes/write.ts +18 -0
  182. package/src/editor/server/admin-props.ts +151 -0
  183. package/src/editor/server/cards.ts +61 -0
  184. package/src/editor/server/create.ts +414 -0
  185. package/src/editor/server/drafts-store.ts +127 -0
  186. package/src/editor/server/media/alt.ts +107 -0
  187. package/src/editor/server/media/bucket.ts +295 -0
  188. package/src/editor/server/media/config.ts +78 -0
  189. package/src/editor/server/media/handler.ts +221 -0
  190. package/src/editor/server/media/library.ts +254 -0
  191. package/src/editor/server/media/memory.ts +184 -0
  192. package/src/editor/server/media/mux.ts +241 -0
  193. package/src/editor/server/media/sniff.ts +599 -0
  194. package/src/editor/server/media/upload.ts +175 -0
  195. package/src/editor/server/mode.ts +63 -0
  196. package/src/editor/server/move.ts +295 -0
  197. package/src/editor/server/render.tsx +9 -0
  198. package/src/editor/server/replace.ts +72 -0
  199. package/src/editor/server/settings.ts +35 -0
  200. package/src/editor/server/write.ts +366 -0
  201. package/src/editor/styles/editor.css +502 -0
  202. package/src/editor/styles/fonts.css +40 -0
  203. package/src/editor/styles/levels/README.md +4 -0
  204. package/src/editor/styles/levels/collections.css +99 -0
  205. package/src/editor/styles/levels/document-level.css +10 -0
  206. package/src/editor/styles/levels/documents.css +9 -0
  207. package/src/editor/styles/levels/fields.css +171 -0
  208. package/src/editor/styles/levels/files.css +95 -0
  209. package/src/editor/styles/levels/live.css +10 -0
  210. package/src/editor/styles/levels/m5a-documents.css +51 -0
  211. package/src/editor/styles/levels/m6.css +82 -0
  212. package/src/editor/styles/levels/media.css +175 -0
  213. package/src/editor/styles/levels/switcher.css +3 -0
  214. package/src/editor/styles/tokens.css +114 -0
  215. package/src/editor/tsconfig.json +16 -0
  216. package/src/editor/types.ts +881 -0
  217. package/src/editor/ui-state.ts +113 -0
  218. package/src/email.ts +67 -0
  219. package/src/fields.ts +1129 -0
  220. package/src/files-route.ts +201 -0
  221. package/src/files.ts +116 -0
  222. package/src/forms/email.ts +149 -0
  223. package/src/forms/endpoint.ts +347 -0
  224. package/src/forms/inbox.ts +273 -0
  225. package/src/forms/rate-limit.ts +130 -0
  226. package/src/forms/route.ts +24 -0
  227. package/src/forms/rules.ts +278 -0
  228. package/src/forms/store.ts +486 -0
  229. package/src/head.ts +125 -0
  230. package/src/index.ts +434 -0
  231. package/src/json-source.ts +116 -0
  232. package/src/links.ts +121 -0
  233. package/src/live/README.md +43 -0
  234. package/src/live/auth/allow-rules.ts +70 -0
  235. package/src/live/auth/allow.ts +128 -0
  236. package/src/live/auth/auth.ts +155 -0
  237. package/src/live/auth/email.ts +84 -0
  238. package/src/live/auth/guard.ts +111 -0
  239. package/src/live/auth/index.ts +68 -0
  240. package/src/live/auth/links.ts +101 -0
  241. package/src/live/auth/login.ts +245 -0
  242. package/src/live/auth/middleware.ts +53 -0
  243. package/src/live/auth/request.ts +56 -0
  244. package/src/live/auth/routes.ts +9 -0
  245. package/src/live/auth/session.ts +181 -0
  246. package/src/live/auth/views.ts +355 -0
  247. package/src/live/autosaves.ts +306 -0
  248. package/src/live/backend.ts +652 -0
  249. package/src/live/branches.ts +19 -0
  250. package/src/live/drafts-store.ts +591 -0
  251. package/src/live/editor.ts +465 -0
  252. package/src/live/git.ts +1021 -0
  253. package/src/live/github.ts +271 -0
  254. package/src/live/locals.ts +24 -0
  255. package/src/live/locks.ts +273 -0
  256. package/src/live/merge.ts +197 -0
  257. package/src/live/messages.ts +151 -0
  258. package/src/live/mode.ts +210 -0
  259. package/src/live/redis.ts +533 -0
  260. package/src/live/runtime.ts +164 -0
  261. package/src/live/status.ts +109 -0
  262. package/src/live/sweep.ts +228 -0
  263. package/src/live/timings.ts +47 -0
  264. package/src/live/webhook.ts +39 -0
  265. package/src/lockup-css.ts +58 -0
  266. package/src/measure.ts +145 -0
  267. package/src/media-value.ts +239 -0
  268. package/src/media.ts +255 -0
  269. package/src/read.ts +256 -0
  270. package/src/redirect-middleware.ts +49 -0
  271. package/src/redirects.ts +76 -0
  272. package/src/relink.ts +293 -0
  273. package/src/reserved.ts +24 -0
  274. package/src/rich-text.ts +690 -0
  275. package/src/schema-key.ts +63 -0
  276. package/src/seo.ts +124 -0
  277. package/src/serialize.ts +697 -0
  278. package/src/skipped.ts +35 -0
  279. package/src/slug.ts +121 -0
  280. package/src/text-styles.ts +62 -0
  281. package/src/virtual.d.ts +28 -0
@@ -0,0 +1,405 @@
1
+ # The editor (`/admin`)
2
+
3
+ M3's editor: React, server-rendered, dev only. Spec: `notes/2026-10-14-m3-editor.md`
4
+ (Decisions 2, 4, 5, 6, 13, 19, 20); design: `notes/mockups/m2-editor.html`.
5
+
6
+ **In dev and in the build (M4 Decision 1, reversing M3 Decision 4).** The integration
7
+ (`../index.ts`) injects these routes for every command; the mode (local, live or
8
+ unconfigured) is read at run time (`server/mode.ts` → `editorMode()`), and an
9
+ unconfigured server answers 503 on all of them (`middleware.ts`). Local mode is M3's,
10
+ on disk. Live mode (`../live/editor.ts`) needs a session, a tab header and Redis, and
11
+ adds `status`, `lock`, `publish`, `history`, `try-again` and `notice` (shapes:
12
+ `types.ts`, after `ENDPOINTS`):
13
+
14
+ | Route | Takes | Gives |
15
+ |---|---|---|
16
+ | `GET /admin`, `/admin/<collection>/<path>` | the address (Decision 19), the `caracms-ui` and `caracms-unsent-<site>` cookies | the shell, server-rendered, plus `#caracms-props` (JSON) to hydrate from |
17
+ | `GET /_caracms/preview?collection=&path=` | a document | the page from its file, with preview markers; `X-Preview-Version: 0` |
18
+ | `POST /_caracms/preview` | `{ version, collection, document }` | the page from that document; `X-Preview-Version: <version>` |
19
+ | `GET /_caracms/document?collection=&path=` | a document | `{ entry, text, document, mtimeMs, version }` (`text`: the serializer's base) |
20
+ | `GET /_caracms/documents` | | the list, with warnings and `mtimeMs` |
21
+ | `POST /_caracms/write` | `{ file, base, document }`, JSON | `{ ok, file, changed, mtimeMs, version }`, or `{ error }` with 400/403/404/405/413/415 |
22
+ | `POST /_caracms/create` | `{ collection, path, title, blockType? }`, JSON (M5a) | `{ ok, collection, path, file, url, version }`, or `{ error, code }` (`types.ts` → `CreateDeleteRefusal`) |
23
+ | `POST /_caracms/delete` | `{ collection, path }`, JSON (M5a) | `{ ok, collection, path, file }`, or `{ error, code }` |
24
+
25
+ **M5b Phase 2: the media routes**, `/_caracms/media/*` (one injected route, `routes/media.ts`,
26
+ over `server/media/handler.ts`), in both modes and with no lock: `POST upload` (an image's bytes,
27
+ its name in `X-Caracms-File-Name`), `GET images` (the library with signed thumbnails, where-used
28
+ and an alt already used), `GET thumbnail?key=&width=` (a 302 to the signed URL), and Mux's
29
+ `POST video-upload`, `GET video-upload/<id>`, `GET clips`, `GET clips/<id>`, `GET playback/<id>`.
30
+ Every shape is in `types.ts` → `MEDIA_ENDPOINTS`. `server/media/`: `sniff.ts` (the type and size
31
+ from the bytes), `upload.ts` (the 40 MB cap, the key rule, no overwrite), `bucket.ts` (S3 through
32
+ aws4fetch, and the in-memory bucket), `library.ts` (the 60 s listing, metadata per key,
33
+ where-used), `mux.ts` (the template's Mux rules), `alt.ts` (`WaitingView.missingAlt`),
34
+ `config.ts` (503s, images and clips apart) and `memory.ts` (`CARACMS_MEDIA=memory`: `memoryMedia()`
35
+ to seed the bucket and the fake Mux in tests).
36
+
37
+ **M5b Phase 3: the media field and the library in the editor.** `client/media/store.ts` →
38
+ `mediaFor(editor)`: the library (`images`, `clips`, last-known while a refetch runs), `upload(files,
39
+ slot?)` (images through the upload route with XHR progress; clips straight to Mux with UpChunk,
40
+ then polled until `ready`), `place(slot, entry)` (Decision 3's value through `pickMedia`, written
41
+ with `doc.setField(scope, path, …)`), `showUnused` (localStorage), `slotNotes` (a refusal at a
42
+ slot) and `placed` (the last placement, for Phase 4's ask). `fields/MediaField.tsx`: the field
43
+ (`MediaField`) and its body (`MediaSlot`, also a gallery row's body in `ListField.tsx`); the slot's
44
+ wrapper is `[data-path="<path>"]` and its alt input `[data-alt="<path>"]`. The library is the
45
+ pane level `media` (`pane/levels/MediaLevel.tsx`), opened with `editor.openMedia({ pick?, item?,
46
+ tab? })`: browse from Site-wide → Media, or a slot's picker (`view.media.pick`, the block kept
47
+ selected and the slot ringed: `overlays/editor-marks.ts`). Browsing dims the stage, which becomes
48
+ the drop target (`media/LibraryStage.tsx`). `editor.mediaSetup` (from `AdminProps.media`) holds the
49
+ 503 sentences, so an unconfigured server's fields say so in the first paint. Thumbnails are only
50
+ ever the server's signed URLs (`thumbnailSrc` → `GET thumbnail`, or the listing's).
51
+
52
+ The write route is M3's security boundary: `server/write.ts` says what it refuses
53
+ and why. Every route reads and writes through the drafts store
54
+ (`server/drafts-store.ts`: `list`, `read`, `write`, and M5a's `source`, `create` and
55
+ `delete`), the seam M4 swaps.
56
+
57
+ **M5a Phase 3: creating and deleting** (spec: `notes/2026-10-15-m5a-fields-collections.md`,
58
+ Decisions 11 and 12). Two routes, a security boundary like the write route:
59
+ `server/create.ts` has the rules (the same in both stores, over the store's own
60
+ listing), the disk half (an exclusive create; a delete that removes the folders it
61
+ empties, up to the collection's) and why. Addresses come from names with `../slug.ts`
62
+ (`slugify`, `isSlug`, `addressProblem`; browser-safe, for the New page dialog). In
63
+ live mode a create is the creator's autosave with the lock, a delete a "gone" autosave
64
+ (`../live/drafts-store.ts`); the poll's `waiting[].change` says `new`, `deleted` or
65
+ `changed`, and `open.deleted` tells a viewer who deleted the document they have open.
66
+ The screens are Phase 5's (below).
67
+
68
+ **M5a Phase 2: every kind of document** (spec: `notes/2026-10-15-m5a-fields-collections.md`).
69
+ The routes above take and give all four kinds: a section builder's `{ title, blocks }`,
70
+ a list-of-fields document `{ kind: 'fields', title, data }`, a shared block
71
+ `{ kind: 'shared', title, block }` and Site Settings `{ kind: 'settings', title, data }`
72
+ (`types.ts` → `AnyOpenedDocument`). The reserved collections `shared` and `settings`
73
+ are collections like any other in `site.collections` (after the site's own), so the
74
+ listing, `read`, `write`, the confinement and live mode's copy all take them. The
75
+ listing gives each document's `kind` and public `url` (null for the reserved two), a
76
+ page's shared entries (`uses`), and a shared block's `blockType` and `usedOn`. The
77
+ preview renders any kind but Site Settings, and reads other documents (shared blocks, a
78
+ case-study list) from `store.source()`. The brand's name and accent are Site Settings'
79
+ as committed (`server/settings.ts`, `brand.ts` → `brandFrom`); `editor.brand` is a
80
+ store the live poll keeps current.
81
+
82
+ **M5a Phase 5: the screens.** `EditorSite.collections` is every collection, of every
83
+ kind (`kind`, `fields`, `urlBase` null for `shared` and `settings`); `allCollections`
84
+ is the same list. `/admin/shared/<name>` and `/admin/settings` open.
85
+ - **The document store holds all four kinds as blocks** (`state/document-store.ts`):
86
+ a section builder its blocks; a fields document and Site Settings one block, the
87
+ form (`type` '', `known`, `data` = the document's data; `formKey(session)` is its
88
+ key); a shared block one block, itself. `document.kind` says which; `wire()` gives
89
+ each back in its own shape. So `setField(key, path, …)`, `removeItem`, Undo, the
90
+ keeper (`holdsEdits` runs the kind's own writer, `applyDocumentEdits`) and the write
91
+ path work the same for every kind. `addShared(name, at)` inserts `{ "shared": name }`.
92
+ - **A form's fields are picked at the document level**: `openBlock(key, field)` on a
93
+ form goes to `level: 'document'` with `view.block` the form's key;
94
+ `editor.fieldsOpen(view)` says whether fields are open (the block level, or a form's
95
+ document level). Esc from a case study or a shared block goes up to its collection.
96
+ - **Preview markers** (`preview/markers.ts`): a fields document's root
97
+ (`data-cms-document`) is block 0, so click to edit, the overlays, typing on the page,
98
+ the lockup's warnings and the caret all find it with no block index.
99
+ - **Levels:** `pane/levels/FormLevel.tsx` (a fields document, a shared block, Site
100
+ Settings; `DocumentLevel.tsx` picks by kind). Site Settings has no preview: the
101
+ driver renders nothing for it and the stage dims the last page with "Back to …"
102
+ (`shell/Stage.tsx`).
103
+ - **Creating and deleting** (`client/documents/`): `actions.ts` (the routes, then the
104
+ list read again and the new document opened, or after a delete the level it was
105
+ listed in), `dialogs.tsx` (New page with Inside and New group…, + New, New shared
106
+ block, Delete and its variants, a shared block still in use), `DocMenu.tsx` (the
107
+ head's menu: View on the live site, Delete …). A group row's menu creates a page at
108
+ the group's address.
109
+ - **A page's shared entry** that renders is named by its shared block and marked
110
+ Shared (`block-list/summary.ts → sharedOf`); its block level offers "Edit Footer
111
+ (used on N pages)". Only a missing one, or one of an unknown type, is Skipped.
112
+ - The document list is read again when the poll's waiting files change (another tab
113
+ created or deleted something, a Publish) and after this tab's changes are kept.
114
+
115
+ ## Where things are
116
+
117
+ ```
118
+ routes/ the injected routes
119
+ server/ server-only: the write route's logic, the drafts store, the admin props, the shell's render
120
+ client/ the browser (and the shell's server render)
121
+ editor.ts createEditor(): the stores and actions everything below uses
122
+ context.tsx useEditor(), useStore()
123
+ state/ document-store.ts (editing), keeper.ts (keeping), live.ts (M4: the poll and the server calls), store.ts
124
+ live/ M4's places, live mode only: publish.tsx (the bar's waiting count and Publish, their popovers,
125
+ history), session.tsx (the avatar and Sign out, signed out, the offline extras), notices.tsx
126
+ (the lock notes, Edit here, Use mine / Discard mine, the merge and published notices), marks.tsx
127
+ (presence, locks and waiting dots in navigation), format.ts (people, times in the site's zone)
128
+ preview/ driver.ts (iframes, morph, latest-wins), selection.ts, warnings.ts, layout.ts, render-sequencer.ts,
129
+ page-edit.ts (click to edit, typing on the page), field-text.ts
130
+ overlays/ the overlay layer (layer.ts), its CSS, the editor's marks, OverlayMount
131
+ fields/ the block level's fields: FieldFrame, Text, Select, Lockup, lockup-rules.ts
132
+ shell/ App, Bar, Stage, Popovers, Toast, NarrowNote, Icons
133
+ pane/ Pane.tsx (the frame), parts.tsx (shared pieces), switcher.tsx (Phase 4's seam), levels/*
134
+ styles/ tokens.css (the mockup's :root, as is), editor.css (the mockup's chrome CSS), fonts.css,
135
+ levels/*.css (a level's own CSS, loaded by the admin route)
136
+ fonts/ Instrument Sans and Geist Mono (OFL, licences beside them)
137
+ ```
138
+
139
+ ## Extension points (Phases 3 and 4)
140
+
141
+ Phase 3 replaces `client/pane/levels/BlockLevel.tsx`; Phase 4 replaces
142
+ `DocumentsLevel.tsx`, `CollectionLevel.tsx`, `DocumentLevel.tsx` and
143
+ `client/pane/switcher.tsx`. The mockup's CSS for every component (block rows, the
144
+ field frame, the lockup field, the picker, popovers…) is already in
145
+ `styles/editor.css`; CSS a phase needs beyond it goes in its own file in
146
+ `styles/levels/` (the admin route loads them all), not in a shared one. Neither phase
147
+ needs to touch the shell, the pane frame, the stores or the driver.
148
+
149
+ **The editor** (`client/editor.ts`), from `useEditor()`:
150
+
151
+ ```ts
152
+ editor.site // EditorSite: collections, blocks (schemas), textStyles, brand, home, host
153
+ editor.doc // DocumentStore (below)
154
+ editor.view // Store<View>: { level, collection, block, field, picked, width, pop, opening }
155
+ editor.documents // Store<ListedDocument[]>; editor.refreshDocuments(): Promise<void>
156
+ editor.ui // Store<UiState>: { open, recent } (cookie); editor.toggleGroup(key, open?)
157
+ editor.keep // Store<KeepStatus>: { state: 'kept' | 'keeping' | 'offline' | 'refused' | 'held' | 'signedout', error, unsent?, retryAt?, held? }
158
+ editor.preview // PreviewDriver | null (null in the server render)
159
+ editor.previewStatus // Store<PreviewStatus>: { painted, failed, showing, opening, warnings }
160
+ editor.stageLayout // Store<StageLayout | null>: each frame's box and scale
161
+ editor.openDocument(ref: DocRef, { history?: 'push' | 'replace' | 'none' }): Promise<void>
162
+ editor.openBlock(key: string, field?: string | null, { picked?: boolean }): void
163
+ editor.openCollection(name: string): void
164
+ editor.goTo(level: 'documents' | 'collection' | 'document' | 'block'): void
165
+ editor.goUp(): boolean // Esc
166
+ editor.setWidth(width: 'desktop' | 'tablet' | 'phone'): void
167
+ editor.setPop(pop: string | null): void // 'switcher' (Phase 4), 'preview-trouble', or a new one
168
+ editor.announce(text: string): void
169
+ editor.addressOf(ref: DocRef): string
170
+
171
+ // M4, live mode (local mode: mode 'local', access always editable, marks empty, these no-ops)
172
+ editor.mode // 'local' | 'live' (AdminProps.live: null in local mode, which never polls)
173
+ editor.you // Person | null
174
+ editor.liveSession // LiveSession | null (state/live.ts): poll(), publish(), tryAgain(), history(), take(file)…
175
+ editor.liveState // Store<LiveState>: { status: StatusResponse | null, reachable, signedOut, polled, seq }
176
+ editor.access // Store<Access>: { lock, holder, held, signedOut, readOnly, veil } for the open document
177
+ editor.marks // Store<Map<file, DocMarks>>: { editing, present, waiting, kept }
178
+ editor.editHere() / useMine() / discardMine() / publish() / tryAgain() / dismissNotice(id) / signOut() / signInAgain()
179
+ // publish() and tryAgain(): { ok, error? }; a failed Publish is said in its popover
180
+ editor.toast(text, icon?) // a message in the toast (shell/Toast.tsx), e.g. a lock lapsing; editor.message is its store
181
+ ```
182
+
183
+ **Read-only** (`access.readOnly`: someone else's lock, your own in another tab, held
184
+ edits, signed out): the document store refuses every change (`canEdit`), the block
185
+ level's fields sit in a disabled fieldset, the block list has no grips, menus or Add
186
+ block, the page takes no clicks or typing (page-edit.ts), and the overlays draw
187
+ nothing; a lock (`access.veil`) draws the veil over the whole page instead
188
+ (overlays/index.ts → `mode`). The lock lapsing, or Edit here, makes it editable again
189
+ without a reload, with the document read again (editor.ts → refreshOpen).
190
+
191
+ Read a store in a component with `useStore(store, selector?)`; a selector must
192
+ return a stable value (a primitive, or part of the state as stored).
193
+
194
+ **The document store** (`client/state/document-store.ts`), `editor.doc`. Blocks are
195
+ addressed by `key` (stable through moves, never written); `source` follows the
196
+ serializer's model.
197
+
198
+ ```ts
199
+ doc.get(): DocumentSession // { ref, entry, base, document: { title, blocks: EditorBlock[] }, revision, undo }
200
+ doc.state: Store<DocumentSession>
201
+ doc.subscribe((session, change: DocumentChange) => void): () => void
202
+ doc.block(key) / doc.indexOf(key) / doc.keyAt(index)
203
+ doc.setField(key, field, value, { origin?: 'sidebar' | 'page', undoLabel?: string }): void
204
+ doc.setTitle(title): void
205
+ doc.addBlock(type, index?): string // the new block's key
206
+ doc.removeBlock(key, { label? }): UndoEntry | null
207
+ doc.moveBlock(key, toIndex): void
208
+ doc.restoreBlock(block, index): void
209
+ doc.undo(): UndoEntry | null // Decision 18; the shell's toast and ⌘Z call it
210
+ doc.wire(): SiteDocument
211
+ ```
212
+
213
+ Every change is written about a second later (the keeper) and rendered into the
214
+ preview (the driver). `origin: 'page'` is for text typed on the page: the driver
215
+ mirrors it into the other iframes instead of rendering, as M1 did. `undoLabel` puts
216
+ the old value on the Undo stack under that label (dropping a lockup's phone lines:
217
+ "Phone uses the main lines again").
218
+
219
+ **The preview driver** (`client/preview/driver.ts`), `editor.preview`:
220
+
221
+ ```ts
222
+ preview.frames(): PreviewFrame[] // { width, trueWidth, iframe, document, window }
223
+ preview.frame(width): PreviewFrame
224
+ preview.onLoad((frame) => void): () => void
225
+ preview.onMorph((event: { version, opened, frames }) => void): () => void
226
+ preview.registerFrameHook((frame, reason: 'load' | 'morph') => void | (() => void)): () => void
227
+ preview.mirrorText(blockIndex, field, text, except?: Document | null): void
228
+ preview.retry(): void
229
+ preview.status // = editor.previewStatus
230
+ ```
231
+
232
+ A frame hook runs for each frame when it loads and after every morph; what it
233
+ returns runs before its next run for that frame. Overlays (Phase 3) and the block
234
+ list's hover link (Phase 4) go there. The phone's lockup sizes are in
235
+ `previewStatus.warnings: LockupWarning[]` (`{ block, field, set, px, minPx, tooLong, uncovered }`).
236
+ In the iframes, `data-cms-block` is the block's index in the rendered document.
237
+
238
+ **Pane pieces** (`client/pane/parts.tsx`): `Trail`, `LevelTab`, `DocumentsTab`,
239
+ `CurrentHead`, `GlobalNotes`, `RecentStrip`, `parentLabel`. A level is
240
+ `<><Trail>…</Trail><div className="side-body"><GlobalNotes />…</div><RecentStrip /></>`.
241
+ Icons: `<Icon name="grip" />` (`shell/Icons.tsx`, the mockup's sprite).
242
+
243
+ ## Overlays
244
+
245
+ What the editor draws over the preview (M2 Decision 6, M2 → Rules → Overlays):
246
+ `client/overlays/`. One shared layer per editor, inside each of the three iframes.
247
+ Phase 3's own marks and any other module's go through the same small API.
248
+
249
+ - **Preview renders only.** /admin creates the layer at run time in its own iframes.
250
+ It's one `#cms-overlays` element appended to `<html>` (outside `<body>`, so the
251
+ driver's body morph never touches it) and one `<style>` in `<head>`. Nothing of it
252
+ is in any render, so the public build can't have it (Decision 17).
253
+ - **Positioning.** The layer is in document coordinates, so it scrolls with the
254
+ page, and has `pointer-events: none`.
255
+ - **Constant size on screen.** `--k` is 1 / the frame's scale, and every size in
256
+ the CSS (`overlays/overlay-css.ts`) is `calc(var(--k) * Npx)`.
257
+ - **Colours** come from the `--ov*` tokens, copied onto the layer as `--cms-*`.
258
+ - **Kept children.** A redraw replaces the layer's children, except one marked
259
+ `data-cms-keep` (`KEEP` in layer.ts), which draws itself and stays on top: the
260
+ formatting bar of rich text typed on the page (`overlays/rich-text-bar.ts`), whose
261
+ link popover's search would lose focus if it were taken out.
262
+
263
+ ```ts
264
+ import { overlaysFor } from '../overlays/index.ts';
265
+ const overlays = overlaysFor(editor); // Overlays | null (null on the server)
266
+
267
+ overlays.set(channel: string, marks: readonly Mark[]): void // replace that channel's marks
268
+ overlays.clear(channel: string): void
269
+ overlays.addPainter(painter: OverlayPainter): () => void // your own elements; returns a remove
270
+ overlays.redraw(): void // next frame (a painter's state changed)
271
+ overlays.flush(): void // now
272
+ overlays.layerOf(frame: PreviewFrame): HTMLElement | null
273
+
274
+ interface Mark {
275
+ kind: 'hover' | 'selected' | 'field-hover' | 'picked' | 'typing' | 'warn' | 'skipped';
276
+ block: number; // data-cms-block (the editor's index: editor.doc.indexOf(key))
277
+ field?: string | null; // data-cms-field in it; the whole block when absent
278
+ label?: string; // tag / chip / pill text
279
+ tone?: 'phone'; // teal
280
+ frames?: readonly Width[]; // default: all three
281
+ onClick?: (e: MouseEvent) => void; // makes the label a button
282
+ }
283
+
284
+ type OverlayPainter = (ctx: OverlayContext) => Node | Node[] | null | void;
285
+ interface OverlayContext {
286
+ frame, width, visible /* the frame on screen */, doc, k,
287
+ block(i): HTMLElement | null, field(i, name): HTMLElement | null, blocks(): HTMLElement[],
288
+ rect(el, pad?): Rect // document coordinates, pad in on-screen px
289
+ place(rect): string // "left:…;top:…;width:…;height:…"
290
+ el(tag, className?, style?) // an element in the frame's document
291
+ }
292
+ ```
293
+
294
+ **Redraws.** The layer redraws on its own:
295
+
296
+ - when a frame loads and after every morph;
297
+ - when a frame's page changes size, or its fonts load;
298
+ - on input in a frame;
299
+ - when the stage's scale changes;
300
+ - when the width on screen changes.
301
+
302
+ Painters run on every redraw, once per frame. Call `redraw()` when state a painter
303
+ reads changes. What they draw goes above the block marks (skipped, hover, selected)
304
+ and under the field marks (field hover, picked, typing, warn), as the mockup layers
305
+ them.
306
+
307
+ **Drawing your own elements.** Painters build them with the CSS classes in
308
+ `overlay-css.ts`, for example:
309
+
310
+ - `cms-empty` with `cms-add`: the empty page's invitation.
311
+ - `cms-box` with `cms-hov`, `cms-sel` or `cms-pick`.
312
+
313
+ Only buttons take the pointer (`pointer-events: auto`). Put your own listeners on
314
+ the elements you create.
315
+
316
+ **The editor's own marks** (`overlays/editor-marks.ts`, the `editor` channel) are
317
+ drawn from the editor's state. There are none while the preview shows another
318
+ document.
319
+
320
+ | Mark | When | Where |
321
+ |---|---|---|
322
+ | `selected` | a block is open in the block level, or selected in the block list | that block |
323
+ | `hover` | `blockHover(editor)` holds a block (`block-list/link.ts`) | that block, unless it's the selected one |
324
+ | `field-hover` | the pointer is over a field of the open block | that field |
325
+ | `picked` | a field is picked | that field, ringed with its name; teal "· Phone lines" in the phone frame when the lockup has phone lines |
326
+ | `typing` | a field is being typed on the page | that field: "Typing on the page" |
327
+ | `warn` | a lockup is too long for mobile | phone frame only |
328
+ | `skipped` | a block is unknown | at its placeholder; the pill opens the block |
329
+
330
+ `blockHover` is set by the block list's rows (Phase 4), and also by hovering a
331
+ block on the page. So a hovered row outlines its block, and a hovered block lights
332
+ up its row.
333
+
334
+ **Editing from the page** (`client/preview/page-edit.ts`, `pageEditFor(editor)`):
335
+
336
+ - **Click to edit**, in the frame on screen. It opens the block and picks the field
337
+ (`openBlock(key, field, { picked: true })`). A lockup also focuses its sidebar
338
+ field.
339
+ - **Typing on the page.** Plain text fields only, with `origin: 'page'`. One-line
340
+ fields take no breaks.
341
+ - **Esc in the page** leaves the field, then goes up a level.
342
+ - **Following the sidebar.** A block or field picked in the sidebar scrolls into
343
+ view.
344
+ - **Stores:** `typing` (the field being typed) and `hover` (block and field under
345
+ the pointer).
346
+
347
+ **Skipped blocks.** In preview renders only, `Blocks.astro` leaves a comment,
348
+ `<!--cms-skipped i type=…-->` (`src/skipped.ts`), where an unknown block would be, and no
349
+ element, so a site's sibling CSS (`+`, `:has(+ …)`) matches across it as on the public
350
+ page. `data-cms-block` indexes are still the editor's block indexes, unknown blocks
351
+ included (a skipped index just has no element). The overlay layer draws its amber line
352
+ between the elements either side of the comment (`preview/markers.ts` → `skippedTop`).
353
+
354
+ ## The look (Phase 5)
355
+
356
+ The chrome is the mockup's CSS (`styles/editor.css`, lifted as is) on its tokens
357
+ (`styles/tokens.css`), so how it looks comes from markup: the same elements, in the
358
+ same order, with the same classes as the mockup. Two checks keep it so:
359
+
360
+ - `node --experimental-strip-types packages/caracms/test/mockup-diff.ts [state…] [--all]`
361
+ compares every M3 state of the mockup with /admin by computed style, and lists the
362
+ accepted differences with their reasons (also `test/mockup-m3.visual.test.ts`, and
363
+ `-m4`/`-m5a` for the later states).
364
+ - `test/chrome.visual.test.ts` (live mode: `chrome-live-contrast` and `chrome-live`):
365
+ contrast with both sample accents, the accent only in its slots, no side rails,
366
+ nothing moving under reduced motion, focus rings.
367
+ `test/chrome.test.ts`: no raw colour outside `tokens.css`.
368
+
369
+ **The brand slot** (`shell/Brand.tsx`): Site Settings' logo (a signed URL, M5b Decision
370
+ 11; set in Site Settings' Logo and Tab icon slots, `FormLevel.tsx → BrandSlot`), or, with
371
+ none, its initial on its accent (ink and edge by contrast, `brand.ts`, which reads its
372
+ inks from `tokens.css`); the tab icon (`EditorBrand.tabIcon`) is the icon, else the logo,
373
+ else the same initial, and follows the poll (`editor.ts`). **"Not in the preview yet"**
374
+ (`fields/stale.ts`): a field changed since the last render, shown while the preview
375
+ can't render.
376
+
377
+ ## Keeping (Decisions 5 and 6; M4 Decisions 4, 9 and 11)
378
+
379
+ `client/state/keeper.ts`. In live mode every write carries `X-Caracms-Tab` and a rising
380
+ `X-Caracms-Seq` (the server never lets a lower one replace that tab's newer autosave), the closing
381
+ tab's last write `X-Caracms-Release: 1` (and a tab with nothing unsent sends a keepalive
382
+ release of its locks), a 409 `locked` is `held`, a 401 is `signedout`, and a retry timer
383
+ runs only while offline (the countdown, Try now, the unsent count). Whether held edits
384
+ are re-sent is the server's call: `verdict()` uses the document's `lastChange` (another
385
+ editor's change since holds them for Use mine / Discard mine). The rest is M3's: A second after the last change the whole document goes to
386
+ the write route with its base. The bar: Kept, Keeping, "can't reach the server"
387
+ (a network failure or a 5xx) until a write succeeds, or "Not kept" with the server's
388
+ own message in the pane's note when it answers with a 4xx (the edits don't fit their
389
+ base, the file was renamed or deleted while open); either way the edits stay held,
390
+ and the next change or a reload retries (no timer). Unsent
391
+ edits are held in `localStorage` per document (`caracms:unsent:<site>:<collection>/<path>`:
392
+ `{ file, base, document, fileVersion }`) until the file has them, and sent with
393
+ keepalive on `pagehide`. **On load, a held copy is dropped only when the file already
394
+ holds those edits**: if the file is still at the version they were made against
395
+ (`fileVersion`, a hash of the file's text) they're re-sent; if it has moved on, the
396
+ keeper runs the serializer in the browser (`applyEdits(base, document)`, as the write
397
+ route does) and compares with the file's text: equal, the copy is dropped; otherwise
398
+ it's re-sent (the keepalive write never updates `fileVersion` and may have been
399
+ refused, and an earlier write can land after a later one). A cookie (`caracms-unsent-<site>`) tells the server some are
400
+ held, so the bar paints as Keeping from the first frame after a reload.
401
+
402
+ ## Left for later
403
+
404
+ - M4 Phase 5: the pixel match of the M4 states against the mockup.
405
+ - M5a Phase 6: the look of the M5a states against the mockup (`mockup-diff`).
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Every document has an address in /admin (Decision 19): `/admin` opens Home, and
3
+ * `/admin/<collection>/<path>` opens a document (`/admin/pages/services/branding`,
4
+ * `/admin/journal/<slug>`, `/admin/case-studies/<slug>`). The server's admin route and
5
+ * the browser share this.
6
+ *
7
+ * The reserved collections (M5a Decision 8): a shared block is `/admin/shared/<name>`
8
+ * (the same rule), and Site Settings, one document, is `/admin/settings`.
9
+ */
10
+ import { isSubmissionId } from '../forms/rules.ts';
11
+ import { SETTINGS_PATH } from '../reserved.ts';
12
+ import { sameDoc, type DocRef } from './types.ts';
13
+
14
+ export const ADMIN = '/admin';
15
+ /** Site Settings' address (M5a). */
16
+ export const SETTINGS_ADDRESS = `${ADMIN}/settings`;
17
+ /** The inbox's address (M6 Decision 9); a submission is `/admin/submissions/<id>` (the email's link). */
18
+ export const SUBMISSIONS_ADDRESS = `${ADMIN}/submissions`;
19
+
20
+ /**
21
+ * The inbox an address names: `{ id: null }` for `/admin/submissions`, `{ id }` for
22
+ * `/admin/submissions/<id>` (an id the store mints), `'bad'` for anything else under it,
23
+ * null for an address that isn't the inbox's.
24
+ */
25
+ export function inboxFromAddress(pathname: string): { id: string | null } | 'bad' | null {
26
+ const trimmed = pathname.replace(/\/+$/, '');
27
+ if (trimmed === SUBMISSIONS_ADDRESS) return { id: null };
28
+ if (!trimmed.startsWith(`${SUBMISSIONS_ADDRESS}/`)) return null;
29
+ const rest = trimmed.slice(SUBMISSIONS_ADDRESS.length + 1);
30
+ return isSubmissionId(rest) ? { id: rest } : 'bad';
31
+ }
32
+
33
+ export const inboxAddress = (id: string | null): string => (id ? `${SUBMISSIONS_ADDRESS}/${encodeURIComponent(id)}` : SUBMISSIONS_ADDRESS);
34
+
35
+ export function addressOf(ref: DocRef, home: DocRef): string {
36
+ if (sameDoc(ref, home)) return ADMIN;
37
+ if (ref.collection === 'settings') return SETTINGS_ADDRESS;
38
+ return `${ADMIN}/${encodeURIComponent(ref.collection)}/${ref.path.split('/').map(encodeURIComponent).join('/')}`;
39
+ }
40
+
41
+ /**
42
+ * The document an address names: `home` for `/admin`, a ref for
43
+ * `/admin/<collection>/<path>` (one of `collections`, or `shared`), Site Settings for
44
+ * `/admin/settings`, or null when the address isn't one (an unknown collection, no
45
+ * path). Whether the document exists is the reader's to say.
46
+ */
47
+ export function refFromAddress(pathname: string, collections: readonly string[], home: DocRef): DocRef | null {
48
+ const trimmed = pathname.replace(/\/+$/, '');
49
+ if (trimmed === ADMIN || trimmed === '') return home;
50
+ if (trimmed === SETTINGS_ADDRESS) return { collection: 'settings', path: SETTINGS_PATH };
51
+ if (!trimmed.startsWith(`${ADMIN}/`)) return null;
52
+ let segments: string[];
53
+ try {
54
+ segments = trimmed.slice(ADMIN.length + 1).split('/').map(decodeURIComponent);
55
+ } catch {
56
+ return null;
57
+ }
58
+ const [collection, ...rest] = segments;
59
+ if (!collection || !(collections.includes(collection) || collection === 'shared') || rest.length === 0) return null;
60
+ return { collection, path: rest.join('/') };
61
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The brand accent's ink and edge, computed by contrast (M2 Decision 5; the
3
+ * mockup's applyBrand): text on the accent is light or dark, whichever contrasts
4
+ * more, and a pale accent gets a darker edge so it still reads on white.
5
+ * The light and dark inks and the page are the tokens --on-fill, --text and
6
+ * --surface, read from styles/tokens.css (never retyped here: M2 Decision 7).
7
+ * Server-side only (the admin route's props, the sign-in pages).
8
+ */
9
+ import { readFileSync } from 'node:fs';
10
+ import type { Brand, ResolvedSite } from '../config.ts';
11
+ import { parseSettingsBrand } from '../document.ts';
12
+ import { brandImageUrl, readMediaConfig, type MediaConfig } from '../media.ts';
13
+ import type { EditorBrand } from './types.ts';
14
+
15
+ // In a Vite build this module is bundled away from styles/, so the integration inlines
16
+ // tokens.css as a define (M4: the editor is in the build); plain Node (tests) reads the file.
17
+ declare const __CARACMS_TOKENS_CSS__: string | undefined;
18
+ const TOKENS = typeof __CARACMS_TOKENS_CSS__ === 'string' ? __CARACMS_TOKENS_CSS__ : readFileSync(new URL('./styles/tokens.css', import.meta.url), 'utf8');
19
+ function token(name: string): string {
20
+ const found = new RegExp(`${name}:\\s*(#[0-9a-fA-F]{3,6})\\s*;`).exec(TOKENS);
21
+ if (!found) throw new Error(`caracms: the token ${name} isn't a hex colour in tokens.css`);
22
+ return found[1]!;
23
+ }
24
+ /** The editor's neutral accent: a site whose Site Settings name no accent (M5a Decision 10). */
25
+ export const DEFAULT_ACCENT = token('--accent');
26
+ const LIGHT = token('--on-fill');
27
+ const DARK = token('--text');
28
+ const PAGE = token('--surface');
29
+
30
+ const expand = (hex: string) => {
31
+ const h = hex.replace('#', '').toLowerCase();
32
+ return `#${h.length === 3 ? [...h].map((c) => c + c).join('') : h}`;
33
+ };
34
+ const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(expand(hex).slice(i, i + 2), 16) / 255);
35
+ const luminance = (hex: string) => {
36
+ const [r, g, b] = rgb(hex).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)) as [number, number, number];
37
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
38
+ };
39
+ /** WCAG contrast ratio between two hex colours. */
40
+ export const contrast = (a: string, b: string) => {
41
+ const x = luminance(a);
42
+ const y = luminance(b);
43
+ return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
44
+ };
45
+ const mix = (a: string, b: string, t: number) =>
46
+ `#${rgb(a)
47
+ .map((v, i) => Math.round((v * (1 - t) + rgb(b)[i]! * t) * 255).toString(16).padStart(2, '0'))
48
+ .join('')}`;
49
+
50
+ export function brandColours(accent: string): { accent: string; accentInk: string; accentEdge: string } {
51
+ const a = expand(accent);
52
+ return {
53
+ accent: a,
54
+ accentInk: contrast(a, LIGHT) >= contrast(a, DARK) ? LIGHT : DARK,
55
+ accentEdge: contrast(a, PAGE) < 3 ? mix(a, DARK, 0.5) : a,
56
+ };
57
+ }
58
+
59
+ /**
60
+ * The tab icon when a site names neither an icon nor a logo: its initial on its
61
+ * accent, as the logo slot draws it (an SVG data URL).
62
+ */
63
+ export function monogramIcon(name: string, accent: string): string {
64
+ const { accent: fill, accentInk: ink, accentEdge: edge } = brandColours(accent);
65
+ const initial = ([...name.trim()][0] ?? '').toLocaleUpperCase().replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
66
+ const svg =
67
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect x=".5" y=".5" width="31" height="31" rx="7" fill="${fill}" stroke="${edge}"/>` +
68
+ `<text x="16" y="22" text-anchor="middle" font-family="system-ui,sans-serif" font-size="18" font-weight="700" fill="${ink}">${initial}</text></svg>`;
69
+ return `data:image/svg+xml,${encodeURIComponent(svg)}`;
70
+ }
71
+
72
+ // ── The brand from Site Settings (M5a Decision 10) ────────────────────────────
73
+
74
+ /** The name the editor shows when Site Settings has none. */
75
+ export const DEFAULT_BRAND_NAME = 'Site';
76
+
77
+ const HEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
78
+
79
+ /**
80
+ * The slots' CSS heights (M5b Decision 11): the logo's largest slot is the sign-in
81
+ * pages' 28px (`.si-logo`; the bar's is 22px inside its 26px slot), the tab icon 32px.
82
+ * brandImageUrl asks for each at 2×.
83
+ */
84
+ export const LOGO_CSS_HEIGHT = 28;
85
+ export const ICON_CSS_HEIGHT = 32;
86
+
87
+ /**
88
+ * The brand from Site Settings' text (null: no file) and the config's logo and icon. Never throws.
89
+ *
90
+ * M5b Decision 11: Site Settings' `brand.logo` and `brand.icon` (image values), each as a
91
+ * signed fixed-mode URL, so the editor's client gets URLs, never keys. Without the media
92
+ * variables (or a value) there's none, and the chrome shows its neutral mark. (The
93
+ * config's logo and icon are refused by resolveSite, so `site.brand` is empty.)
94
+ */
95
+ export function brandFrom(settingsText: string | null, site: Pick<ResolvedSite, 'brand'>, media: MediaConfig = readMediaConfig()): Brand {
96
+ let raw: unknown = null;
97
+ try {
98
+ raw = settingsText === null ? null : (JSON.parse(settingsText) as { brand?: unknown } | null)?.brand;
99
+ } catch {
100
+ raw = null;
101
+ }
102
+ const { name, accent, logo, icon } = parseSettingsBrand(raw);
103
+ const brand: Brand = { ...site.brand, name: name || DEFAULT_BRAND_NAME, accent: HEX.test(accent) ? accent : DEFAULT_ACCENT };
104
+ const logoUrl = logo ? brandImageUrl(logo.key, LOGO_CSS_HEIGHT, media) : null;
105
+ const iconUrl = icon ? brandImageUrl(icon.key, ICON_CSS_HEIGHT, media) : null;
106
+ if (logoUrl) brand.logo = logoUrl;
107
+ if (iconUrl) brand.icon = iconUrl;
108
+ return brand;
109
+ }
110
+
111
+ /**
112
+ * The brand with the accent's ink and edge, as the editor's chrome takes it, and the tab's
113
+ * icon (M5b Decision 11): the icon, else the logo, else the neutral mark.
114
+ */
115
+ export function editorBrand(brand: Brand): EditorBrand {
116
+ return { ...brand, ...brandColours(brand.accent), tabIcon: brand.icon ?? brand.logo ?? monogramIcon(brand.name, brand.accent) };
117
+ }
118
+