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.
- package/LICENSE +21 -0
- package/README.md +57 -2
- package/bin/caracms.mjs +174 -0
- package/bin/gate.mjs +275 -0
- package/bin/init.mjs +712 -0
- package/bin/media.mjs +662 -0
- package/bin/site-runner.mjs +61 -0
- package/bin/templates/Page.astro.txt +31 -0
- package/bin/templates/TextBlock.astro.txt +13 -0
- package/bin/templates/caracms.config.ts.txt +22 -0
- package/bin/templates/catch-all.astro.txt +25 -0
- package/bin/templates/health.ts.txt +6 -0
- package/bin/templates/railway.ts.txt +147 -0
- package/package.json +66 -4
- package/skills/caracms-adopt/SKILL.md +508 -0
- package/skills/caracms-adopt/look-same.mjs +957 -0
- package/skills/caracms-blocks/SKILL.md +376 -0
- package/skills/caracms-content/SKILL.md +201 -0
- package/skills/caracms-tina/SKILL.md +427 -0
- package/skills/caracms-tina/field-kinds.md +88 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
- package/src/block-runs.ts +84 -0
- package/src/cards.ts +158 -0
- package/src/cli/schemas.ts +122 -0
- package/src/cli/site.ts +66 -0
- package/src/cli/validate.ts +471 -0
- package/src/code-routes-disk.ts +67 -0
- package/src/code-routes.ts +134 -0
- package/src/columns.ts +39 -0
- package/src/components/Block.astro +46 -0
- package/src/components/BlockRunTree.astro +45 -0
- package/src/components/Blocks.astro +139 -0
- package/src/components/Buttons.astro +116 -0
- package/src/components/Fonts.astro +14 -0
- package/src/components/Form.astro +221 -0
- package/src/components/Head.astro +62 -0
- package/src/components/Link.astro +68 -0
- package/src/components/Lockup.astro +116 -0
- package/src/components/Media.astro +124 -0
- package/src/components/MediaPlayer.astro +102 -0
- package/src/components/RenderDocument.astro +64 -0
- package/src/components/RichText.astro +59 -0
- package/src/components/Text.astro +81 -0
- package/src/components/attributes.ts +33 -0
- package/src/components/descriptor.ts +45 -0
- package/src/components/form-client.ts +182 -0
- package/src/components/media-player.ts +46 -0
- package/src/components/media-props.ts +35 -0
- package/src/components/sitemap.xml.ts +36 -0
- package/src/config.ts +825 -0
- package/src/content-schema.ts +166 -0
- package/src/content.ts +422 -0
- package/src/document.ts +579 -0
- package/src/editor/README.md +405 -0
- package/src/editor/addresses.ts +61 -0
- package/src/editor/brand.ts +118 -0
- package/src/editor/client/block-list/BlockList.tsx +342 -0
- package/src/editor/client/block-list/Picker.tsx +105 -0
- package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
- package/src/editor/client/block-list/insert-points.ts +56 -0
- package/src/editor/client/block-list/link.ts +71 -0
- package/src/editor/client/block-list/reorder.ts +105 -0
- package/src/editor/client/block-list/summary.ts +118 -0
- package/src/editor/client/collections/CollectionGrid.tsx +143 -0
- package/src/editor/client/collections/CollectionTable.tsx +406 -0
- package/src/editor/client/collections/table.ts +270 -0
- package/src/editor/client/context.tsx +27 -0
- package/src/editor/client/documents/DocMenu.tsx +154 -0
- package/src/editor/client/documents/actions.ts +192 -0
- package/src/editor/client/documents/dialogs.tsx +687 -0
- package/src/editor/client/editor.ts +995 -0
- package/src/editor/client/fields/ButtonsField.tsx +87 -0
- package/src/editor/client/fields/DateField.tsx +200 -0
- package/src/editor/client/fields/FieldControl.tsx +61 -0
- package/src/editor/client/fields/FieldFrame.tsx +109 -0
- package/src/editor/client/fields/GroupField.tsx +113 -0
- package/src/editor/client/fields/HiddenToggle.tsx +51 -0
- package/src/editor/client/fields/LinkField.tsx +307 -0
- package/src/editor/client/fields/ListField.tsx +642 -0
- package/src/editor/client/fields/LockupField.tsx +365 -0
- package/src/editor/client/fields/MediaField.tsx +823 -0
- package/src/editor/client/fields/PageFields.tsx +82 -0
- package/src/editor/client/fields/ReferenceField.tsx +209 -0
- package/src/editor/client/fields/RichTextField.tsx +306 -0
- package/src/editor/client/fields/SelectField.tsx +63 -0
- package/src/editor/client/fields/SeoGroup.tsx +282 -0
- package/src/editor/client/fields/SimpleFields.tsx +89 -0
- package/src/editor/client/fields/TextField.tsx +51 -0
- package/src/editor/client/fields/date.ts +73 -0
- package/src/editor/client/fields/host.tsx +172 -0
- package/src/editor/client/fields/list-summary.ts +85 -0
- package/src/editor/client/fields/lockup-rules.ts +181 -0
- package/src/editor/client/fields/parts.tsx +458 -0
- package/src/editor/client/fields/rich-text-commands.ts +183 -0
- package/src/editor/client/fields/rich-text-doc.ts +156 -0
- package/src/editor/client/fields/rich-text-schema.ts +224 -0
- package/src/editor/client/fields/stale.ts +58 -0
- package/src/editor/client/inbox/InboxStage.tsx +272 -0
- package/src/editor/client/inbox/store.ts +311 -0
- package/src/editor/client/live/format.ts +77 -0
- package/src/editor/client/live/marks.tsx +90 -0
- package/src/editor/client/live/notices.tsx +213 -0
- package/src/editor/client/live/publish.tsx +617 -0
- package/src/editor/client/live/session.tsx +162 -0
- package/src/editor/client/main.tsx +12 -0
- package/src/editor/client/media/LibraryStage.tsx +75 -0
- package/src/editor/client/media/ReplaceDialog.tsx +355 -0
- package/src/editor/client/media/store.ts +618 -0
- package/src/editor/client/nav/DocRow.tsx +122 -0
- package/src/editor/client/nav/search.ts +71 -0
- package/src/editor/client/nav/tree.ts +93 -0
- package/src/editor/client/overlays/OverlayMount.tsx +26 -0
- package/src/editor/client/overlays/editor-marks.ts +159 -0
- package/src/editor/client/overlays/index.ts +50 -0
- package/src/editor/client/overlays/layer.ts +437 -0
- package/src/editor/client/overlays/overlay-css.ts +177 -0
- package/src/editor/client/overlays/rich-text-bar.ts +717 -0
- package/src/editor/client/pane/Pane.tsx +91 -0
- package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
- package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
- package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
- package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
- package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
- package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
- package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
- package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
- package/src/editor/client/pane/parts.tsx +179 -0
- package/src/editor/client/pane/switcher.tsx +165 -0
- package/src/editor/client/preview/driver.ts +640 -0
- package/src/editor/client/preview/field-text.ts +71 -0
- package/src/editor/client/preview/follow-links.ts +234 -0
- package/src/editor/client/preview/layout.ts +37 -0
- package/src/editor/client/preview/link-target.ts +50 -0
- package/src/editor/client/preview/markers.ts +86 -0
- package/src/editor/client/preview/page-edit.ts +632 -0
- package/src/editor/client/preview/page-editor-registry.ts +63 -0
- package/src/editor/client/preview/page-editor.ts +235 -0
- package/src/editor/client/preview/render-sequencer.ts +113 -0
- package/src/editor/client/preview/selection.ts +116 -0
- package/src/editor/client/preview/warnings.ts +61 -0
- package/src/editor/client/shell/App.tsx +101 -0
- package/src/editor/client/shell/Bar.tsx +197 -0
- package/src/editor/client/shell/Brand.tsx +33 -0
- package/src/editor/client/shell/Icons.tsx +87 -0
- package/src/editor/client/shell/NarrowNote.tsx +47 -0
- package/src/editor/client/shell/Popovers.tsx +47 -0
- package/src/editor/client/shell/Stage.tsx +132 -0
- package/src/editor/client/shell/Toast.tsx +61 -0
- package/src/editor/client/state/document-store.ts +465 -0
- package/src/editor/client/state/keeper.ts +713 -0
- package/src/editor/client/state/live.ts +257 -0
- package/src/editor/client/state/schema.ts +42 -0
- package/src/editor/client/state/store.ts +39 -0
- package/src/editor/fonts/OFL-GeistMono.txt +93 -0
- package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
- package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
- package/src/editor/middleware.ts +62 -0
- package/src/editor/routes/_live.ts +26 -0
- package/src/editor/routes/admin.astro +80 -0
- package/src/editor/routes/create.ts +19 -0
- package/src/editor/routes/delete.ts +19 -0
- package/src/editor/routes/document.ts +39 -0
- package/src/editor/routes/documents.ts +30 -0
- package/src/editor/routes/github.ts +23 -0
- package/src/editor/routes/history.ts +9 -0
- package/src/editor/routes/lock.ts +9 -0
- package/src/editor/routes/login/assets.d.ts +10 -0
- package/src/editor/routes/login/login.ts +43 -0
- package/src/editor/routes/media.ts +16 -0
- package/src/editor/routes/move.ts +17 -0
- package/src/editor/routes/notice.ts +9 -0
- package/src/editor/routes/preview.astro +77 -0
- package/src/editor/routes/publish.ts +9 -0
- package/src/editor/routes/status.ts +9 -0
- package/src/editor/routes/submissions.ts +16 -0
- package/src/editor/routes/try-again.ts +9 -0
- package/src/editor/routes/write.ts +18 -0
- package/src/editor/server/admin-props.ts +151 -0
- package/src/editor/server/cards.ts +61 -0
- package/src/editor/server/create.ts +414 -0
- package/src/editor/server/drafts-store.ts +127 -0
- package/src/editor/server/media/alt.ts +107 -0
- package/src/editor/server/media/bucket.ts +295 -0
- package/src/editor/server/media/config.ts +78 -0
- package/src/editor/server/media/handler.ts +221 -0
- package/src/editor/server/media/library.ts +254 -0
- package/src/editor/server/media/memory.ts +184 -0
- package/src/editor/server/media/mux.ts +241 -0
- package/src/editor/server/media/sniff.ts +599 -0
- package/src/editor/server/media/upload.ts +175 -0
- package/src/editor/server/mode.ts +63 -0
- package/src/editor/server/move.ts +295 -0
- package/src/editor/server/render.tsx +9 -0
- package/src/editor/server/replace.ts +72 -0
- package/src/editor/server/settings.ts +35 -0
- package/src/editor/server/write.ts +366 -0
- package/src/editor/styles/editor.css +502 -0
- package/src/editor/styles/fonts.css +40 -0
- package/src/editor/styles/levels/README.md +4 -0
- package/src/editor/styles/levels/collections.css +99 -0
- package/src/editor/styles/levels/document-level.css +10 -0
- package/src/editor/styles/levels/documents.css +9 -0
- package/src/editor/styles/levels/fields.css +171 -0
- package/src/editor/styles/levels/files.css +95 -0
- package/src/editor/styles/levels/live.css +10 -0
- package/src/editor/styles/levels/m5a-documents.css +51 -0
- package/src/editor/styles/levels/m6.css +82 -0
- package/src/editor/styles/levels/media.css +175 -0
- package/src/editor/styles/levels/switcher.css +3 -0
- package/src/editor/styles/tokens.css +114 -0
- package/src/editor/tsconfig.json +16 -0
- package/src/editor/types.ts +881 -0
- package/src/editor/ui-state.ts +113 -0
- package/src/email.ts +67 -0
- package/src/fields.ts +1129 -0
- package/src/files-route.ts +201 -0
- package/src/files.ts +116 -0
- package/src/forms/email.ts +149 -0
- package/src/forms/endpoint.ts +347 -0
- package/src/forms/inbox.ts +273 -0
- package/src/forms/rate-limit.ts +130 -0
- package/src/forms/route.ts +24 -0
- package/src/forms/rules.ts +278 -0
- package/src/forms/store.ts +486 -0
- package/src/head.ts +125 -0
- package/src/index.ts +434 -0
- package/src/json-source.ts +116 -0
- package/src/links.ts +121 -0
- package/src/live/README.md +43 -0
- package/src/live/auth/allow-rules.ts +70 -0
- package/src/live/auth/allow.ts +128 -0
- package/src/live/auth/auth.ts +155 -0
- package/src/live/auth/email.ts +84 -0
- package/src/live/auth/guard.ts +111 -0
- package/src/live/auth/index.ts +68 -0
- package/src/live/auth/links.ts +101 -0
- package/src/live/auth/login.ts +245 -0
- package/src/live/auth/middleware.ts +53 -0
- package/src/live/auth/request.ts +56 -0
- package/src/live/auth/routes.ts +9 -0
- package/src/live/auth/session.ts +181 -0
- package/src/live/auth/views.ts +355 -0
- package/src/live/autosaves.ts +306 -0
- package/src/live/backend.ts +652 -0
- package/src/live/branches.ts +19 -0
- package/src/live/drafts-store.ts +591 -0
- package/src/live/editor.ts +465 -0
- package/src/live/git.ts +1021 -0
- package/src/live/github.ts +271 -0
- package/src/live/locals.ts +24 -0
- package/src/live/locks.ts +273 -0
- package/src/live/merge.ts +197 -0
- package/src/live/messages.ts +151 -0
- package/src/live/mode.ts +210 -0
- package/src/live/redis.ts +533 -0
- package/src/live/runtime.ts +164 -0
- package/src/live/status.ts +109 -0
- package/src/live/sweep.ts +228 -0
- package/src/live/timings.ts +47 -0
- package/src/live/webhook.ts +39 -0
- package/src/lockup-css.ts +58 -0
- package/src/measure.ts +145 -0
- package/src/media-value.ts +239 -0
- package/src/media.ts +255 -0
- package/src/read.ts +256 -0
- package/src/redirect-middleware.ts +49 -0
- package/src/redirects.ts +76 -0
- package/src/relink.ts +293 -0
- package/src/reserved.ts +24 -0
- package/src/rich-text.ts +690 -0
- package/src/schema-key.ts +63 -0
- package/src/seo.ts +124 -0
- package/src/serialize.ts +697 -0
- package/src/skipped.ts +35 -0
- package/src/slug.ts +121 -0
- package/src/text-styles.ts +62 -0
- 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
|
+
|