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,109 @@
1
+ /**
2
+ * What's waiting, and whether a publish went live (M4 Decisions 5, 8, 12), as pure
3
+ * functions over facts read from git and GitHub, so they can be tested from
4
+ * fixtures. git.ts gathers the facts.
5
+ */
6
+ import type { CommitSummary } from './backend.ts';
7
+ import { parseMessage, type Person } from './messages.ts';
8
+
9
+ // ── Waiting ───────────────────────────────────────────────────────────────────
10
+
11
+ export interface WaitingFile {
12
+ /** Site-relative path. */
13
+ file: string;
14
+ /** Who changed it since the last publish, most recent first, each once. */
15
+ editors: { person: Person; at: string }[];
16
+ /** The newest of those changes (ISO); null when no commit names an editor (a push straight to drafts). */
17
+ changedAt: string | null;
18
+ /** A merge or a commit decided a key or the file for the draft: the merge notice's data (Decision 7). */
19
+ keptDraft: boolean;
20
+ }
21
+
22
+ /**
23
+ * The waiting list from git alone: `files` are the files that differ between `main`
24
+ * and `drafts`, `commits` the commits on `drafts` since `main` (oldest first), whose
25
+ * messages (messages.ts) say who changed what. Newest change first.
26
+ */
27
+ export function waitingFromCommits(files: Iterable<string>, commits: CommitSummary[]): WaitingFile[] {
28
+ const out = new Map<string, WaitingFile>();
29
+ for (const file of files) out.set(file, { file, editors: [], changedAt: null, keptDraft: false });
30
+ for (const commit of commits) {
31
+ const parsed = parseMessage(commit.message);
32
+ for (const edit of parsed.edits) {
33
+ for (const file of edit.files) {
34
+ const w = out.get(file);
35
+ if (!w) continue;
36
+ const email = edit.person.email.toLowerCase();
37
+ w.editors = [{ person: edit.person, at: commit.date }, ...w.editors.filter((e) => e.person.email.toLowerCase() !== email)];
38
+ if (!w.changedAt || commit.date >= w.changedAt) w.changedAt = commit.date;
39
+ }
40
+ }
41
+ for (const file of parsed.keptDraft) {
42
+ const w = out.get(file);
43
+ if (w) w.keptDraft = true;
44
+ }
45
+ }
46
+ return [...out.values()].sort((a, b) => (b.changedAt ?? '').localeCompare(a.changedAt ?? '') || a.file.localeCompare(b.file));
47
+ }
48
+
49
+ // ── Publish status ────────────────────────────────────────────────────────────
50
+
51
+ /** The newest publish, kept in Redis so a server replaced mid-publish still knows it (Watch-outs). */
52
+ export interface PublishRecord {
53
+ /** The squashed commit on `main`. */
54
+ commit: string;
55
+ by: Person;
56
+ /** ISO. */
57
+ at: string;
58
+ /** Site-relative paths published. */
59
+ files: string[];
60
+ titles: string[];
61
+ /** When Try again last redeployed (ISO): deployments older than this don't count. */
62
+ tryAgainAt: string | null;
63
+ }
64
+
65
+ export type PublishState = 'publishing' | 'live' | 'failed';
66
+
67
+ /** One GitHub Deployment and its newest status (null: no status yet). */
68
+ export interface DeploymentFact {
69
+ sha: string;
70
+ createdAt: string;
71
+ state: string | null;
72
+ }
73
+
74
+ /** Statuses that are an answer. `in_progress`, `queued` and `pending` aren't: Railway leaves skipped deploys `in_progress` for good. */
75
+ const FINAL = new Set(['success', 'failure', 'error', 'inactive']);
76
+
77
+ export interface PublishFacts {
78
+ record: PublishRecord;
79
+ /** The running server's commit (RAILWAY_GIT_COMMIT_SHA / CARACMS_RUNNING_COMMIT), or null with no deploy pipeline. */
80
+ running: string | null;
81
+ /** Is `running` the publish commit or a descendant of it? null when not known (not asked). */
82
+ runningIncludesPublish: boolean | null;
83
+ /** Commits on `main` after the publish commit. */
84
+ laterOnMain: ReadonlySet<string>;
85
+ /** The repo's deployments, any order. */
86
+ deployments: DeploymentFact[];
87
+ }
88
+
89
+ /**
90
+ * Decision 12:
91
+ * - **live**: the running server's commit is the publish commit or after it; or, on a
92
+ * server with no running commit at all (no deploy pipeline), once the refs moved,
93
+ * which they had for a publish record to exist.
94
+ * - **failed**: the newest deployment that has *answered*, for the publish commit or
95
+ * a later commit on `main` (since the last Try again), says `failure` or `error`.
96
+ * A deployment stuck `in_progress` is never read as finished or failed.
97
+ * - **publishing**: otherwise.
98
+ */
99
+ export function publishState(f: PublishFacts): { state: PublishState; assumed: boolean } {
100
+ if (f.running === null) return { state: 'live', assumed: true };
101
+ if (f.running === f.record.commit || f.runningIncludesPublish) return { state: 'live', assumed: false };
102
+ const since = f.record.tryAgainAt ?? '';
103
+ const answered = f.deployments
104
+ .filter((d) => (d.sha === f.record.commit || f.laterOnMain.has(d.sha)) && d.createdAt >= since && d.state !== null && FINAL.has(d.state))
105
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
106
+ const newest = answered[0];
107
+ if (newest && (newest.state === 'failure' || newest.state === 'error')) return { state: 'failed', assumed: false };
108
+ return { state: 'publishing', assumed: false };
109
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * The sweep (M4 Decisions 5 and 6): it carries autosaves from Redis into `drafts`.
3
+ *
4
+ * - **A document's autosave is committed once it has been idle for `sweepIdleMs`**, and
5
+ * at least every `sweepCapMs` while someone keeps typing (measured from the first
6
+ * write since its last commit). **One marked `sweepAtOnce`** (Site Settings', M5a
7
+ * Decision 10) is ready on the next tick, with no idle wait.
8
+ * - **One commit per tick for everything ready** (`git.commitDrafts`), never one per
9
+ * editor or per document (M1 → G4). Each autosave is merged with `drafts` (Decision
10
+ * 6); its ancestor is the session's last commit, else its base (autosaves.ts).
11
+ * - **It ticks every `sweepTickMs`, but only while something is pending**: from the
12
+ * first autosave this server sees until nothing is left. **With nothing pending it
13
+ * makes no Redis call at all**, so Railway's Redis can sleep (Decision 17, M1 → R1).
14
+ * One check at startup (`start`) picks up what a previous server left behind; the
15
+ * status route `poke`s it when a poll sees autosaves, in case they came in on the
16
+ * other server during a deploy and that server went away.
17
+ * - **Each tick takes a short Redis lock first**: during a deploy two servers sweep.
18
+ * - `commitAll` commits everything pending now, idle or not: Publish's step 1
19
+ * (Decision 10) through `git.commitPending`, and Decision 8's merge.
20
+ * - After a commit an autosave is deleted only if no newer write replaced it (a
21
+ * compare-and-delete); a newer one stays, with its cap restarted. Then a lock whose
22
+ * only reason to hold was that autosave is dropped (Decision 9: commit, then release).
23
+ * - **New and deleted documents** (M5a) are autosaves like any other: a new one's text
24
+ * is committed as an added file (its ancestor is what `drafts` had there, autosaves.ts
25
+ * → `ancestorFor`), a "gone" one as a removed file (git.ts → `commitDrafts`). A newer
26
+ * write that came in while a new document was being committed is no longer a new
27
+ * document: its `created` mark is dropped (retried if a write beats the swap), so its ancestor is that commit.
28
+ */
29
+ import { randomBytes } from 'node:crypto';
30
+ import type { DraftEntry, LiveGit } from './git.ts';
31
+ import type { Autosaves, Read, Autosave } from './autosaves.ts';
32
+ import type { Locks } from './locks.ts';
33
+ import type { RedisClient } from './redis.ts';
34
+ import type { LiveTimings } from './timings.ts';
35
+
36
+ export type Schedule = (fn: () => void, ms: number) => () => void;
37
+
38
+ const defaultSchedule: Schedule = (fn, ms) => {
39
+ const t = setTimeout(fn, ms);
40
+ t.unref?.();
41
+ return () => clearTimeout(t);
42
+ };
43
+
44
+ export interface SweepOptions {
45
+ redis: RedisClient;
46
+ git: Pick<LiveGit, 'commitDrafts'>;
47
+ autosaves: Autosaves;
48
+ locks: Locks;
49
+ timings: LiveTimings;
50
+ now: () => number;
51
+ schedule?: Schedule;
52
+ sleep?: (ms: number) => Promise<void>;
53
+ log?: (line: string) => void;
54
+ prefix?: string;
55
+ }
56
+
57
+ export interface SweepResult {
58
+ commit: string | null;
59
+ /** Files committed (or found already in `drafts`). */
60
+ files: string[];
61
+ /** False when another server held the sweep's lock and this tick did nothing. */
62
+ ran: boolean;
63
+ }
64
+
65
+ type Select = 'ready' | 'all' | { files: string[] };
66
+
67
+ /** Is an autosave ready for a tick: marked to go at once, idle long enough, or past the cap? */
68
+ export function ready(a: Autosave, now: number, timings: Pick<LiveTimings, 'sweepIdleMs' | 'sweepCapMs'>): boolean {
69
+ return a.sweepAtOnce === true || now - a.updatedAt >= timings.sweepIdleMs || now - a.firstAt >= timings.sweepCapMs;
70
+ }
71
+
72
+ export class Sweep {
73
+ /** Ticking: something is (or may be) pending. */
74
+ active = false;
75
+ /** Bumped by every poke, so a run that found nothing doesn't stop a sweep a write started meanwhile. */
76
+ private pokes = 0;
77
+ private cancel: (() => void) | null = null;
78
+ private running: Promise<unknown> = Promise.resolve();
79
+ private readonly o: SweepOptions;
80
+ private readonly schedule: Schedule;
81
+ private readonly sleep: (ms: number) => Promise<void>;
82
+ private readonly log: (line: string) => void;
83
+ private readonly lockKey: string;
84
+
85
+ constructor(options: SweepOptions) {
86
+ this.o = options;
87
+ this.schedule = options.schedule ?? defaultSchedule;
88
+ this.sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
89
+ this.log = options.log ?? ((line) => console.log(`[caracms] ${line}`));
90
+ this.lockKey = `${options.prefix ?? 'caracms:'}sweep:lock`;
91
+ }
92
+
93
+ /** The startup check: anything a previous server left behind starts the ticking. */
94
+ async start(): Promise<void> {
95
+ const pending = await this.o.autosaves.all();
96
+ if (pending.length) {
97
+ this.log(`sweep: ${pending.length} autosave(s) waiting from before this server started`);
98
+ this.poke();
99
+ }
100
+ }
101
+
102
+ /** An autosave arrived (or was seen): tick until nothing is pending. */
103
+ poke(): void {
104
+ this.pokes++;
105
+ if (this.active) return;
106
+ this.active = true;
107
+ this.arm();
108
+ }
109
+
110
+ /** Stop ticking (tests, shutdown). */
111
+ stop(): void {
112
+ this.cancel?.();
113
+ this.cancel = null;
114
+ this.active = false;
115
+ }
116
+
117
+ private arm() {
118
+ this.cancel?.();
119
+ this.cancel = this.schedule(() => {
120
+ this.cancel = null;
121
+ void this.tick().catch((error: unknown) => this.log(`sweep: ${error instanceof Error ? error.message : String(error)}`));
122
+ }, this.o.timings.sweepTickMs);
123
+ }
124
+
125
+ /** One tick: commit what's ready. Keeps ticking while anything is left; goes quiet when nothing is. */
126
+ async tick(): Promise<SweepResult> {
127
+ let left = true;
128
+ const pokes = this.pokes;
129
+ try {
130
+ const r = await this.run('ready');
131
+ left = r.left;
132
+ return r.result;
133
+ } finally {
134
+ if (this.active) {
135
+ if (left || this.pokes !== pokes) this.arm();
136
+ else this.stop();
137
+ }
138
+ }
139
+ }
140
+
141
+ /** Commit every pending autosave now (Publish's step 1, Decision 8's merge). */
142
+ async commitAll(): Promise<SweepResult> {
143
+ const pokes = this.pokes;
144
+ const r = await this.run('all');
145
+ if (!r.left && this.active && this.pokes === pokes) this.stop();
146
+ return r.result;
147
+ }
148
+
149
+ /** Commit these files' autosaves now (a lock moved under an autosave from another tab). */
150
+ async commitFiles(files: string[]): Promise<SweepResult> {
151
+ return (await this.run({ files })).result;
152
+ }
153
+
154
+ /** Runs one at a time in this process; the Redis lock keeps two servers apart. */
155
+ private run(select: Select): Promise<{ result: SweepResult; left: boolean }> {
156
+ const next = this.running.then(
157
+ () => this.once(select),
158
+ () => this.once(select),
159
+ );
160
+ this.running = next.catch(() => {});
161
+ return next;
162
+ }
163
+
164
+ private async once(select: Select): Promise<{ result: SweepResult; left: boolean }> {
165
+ const { redis, autosaves, timings, now } = this.o;
166
+ const token = randomBytes(9).toString('base64url');
167
+ const lockMs = Math.max(30_000, timings.sweepTickMs);
168
+ let locked = await redis.set(this.lockKey, token, { nx: true, px: lockMs });
169
+ if (!locked) {
170
+ // Another server is sweeping. A tick leaves it to them; a commit-everything waits briefly, then goes anyway (commits are compare-and-swap).
171
+ if (select === 'ready') return { result: { commit: null, files: [], ran: false }, left: true };
172
+ for (let i = 0; i < 20 && !locked; i++) {
173
+ await this.sleep(250);
174
+ locked = await redis.set(this.lockKey, token, { nx: true, px: lockMs });
175
+ }
176
+ }
177
+ try {
178
+ const all = await autosaves.all();
179
+ const t = now();
180
+ const chosen =
181
+ select === 'all'
182
+ ? all
183
+ : select === 'ready'
184
+ ? all.filter((a) => ready(a.value, t, timings))
185
+ : all.filter((a) => select.files.includes(a.value.file));
186
+ if (chosen.length === 0) return { result: { commit: null, files: [], ran: true }, left: all.length > 0 };
187
+ const result = await this.commit(chosen);
188
+ return { result, left: all.length > result.removed };
189
+ } finally {
190
+ if (locked) await redis.compareAndDelete(this.lockKey, token).catch(() => false);
191
+ }
192
+ }
193
+
194
+ private async commit(chosen: Read<Autosave>[]): Promise<SweepResult & { removed: number }> {
195
+ const { autosaves, locks, git, now } = this.o;
196
+ // (M5a) `text` is null for a deletion, which commitDrafts commits as a removed file.
197
+ const entries: DraftEntry[] = [];
198
+ for (const a of chosen) {
199
+ const ancestor = await autosaves.ancestorFor(a.value);
200
+ // Worth a line when an editing session's record exists but wasn't used whole: the "kept draft" notice turns on it.
201
+ if (ancestor.from !== 'session' && ancestor.from !== 'base') this.log(`sweep: ${a.value.file} merged against ${ancestor.from}`);
202
+ entries.push({ file: a.value.file, base: ancestor.text, text: a.value.text, editor: a.value.editor });
203
+ }
204
+ const r = await git.commitDrafts(entries);
205
+ let removed = 0;
206
+ const at = now();
207
+ for (const a of chosen) {
208
+ const { file, tab, base, text, editor } = a.value;
209
+ await autosaves.setCommitted(file, { tab, base, text, at, editor: editor.email.toLowerCase() });
210
+ if (await autosaves.removeIf(a)) {
211
+ removed++;
212
+ await locks.dropIfFree(file);
213
+ continue;
214
+ }
215
+ // A newer write came in while committing: it stays, and its cap starts again from this commit.
216
+ // A document this commit created isn't new any more (M5a): the commit is the newer write's ancestor
217
+ // (so is any `ancestor` it carried from before the commit).
218
+ // A write can land between the get and the replaceIf (it copies `created` forward again), so look again until it takes or the autosave is gone.
219
+ for (let attempt = 0; attempt < 8; attempt++) {
220
+ const newer = await autosaves.get(file);
221
+ if (!newer) break;
222
+ const { created: _created, ancestor: _ancestor, ...rest } = newer.value;
223
+ if (await autosaves.replaceIf(newer, { ...rest, firstAt: at })) break;
224
+ }
225
+ }
226
+ return { commit: r.commit, files: chosen.map((a) => a.value.file), ran: true, removed };
227
+ }
228
+ }
@@ -0,0 +1,47 @@
1
+ /** Live mode's timings (M4 Phase 1). */
2
+
3
+ /**
4
+ * Every timing live mode uses, in one place so tests shorten them (Decision 2). The
5
+ * sweep's and the locks' are Phase 2's; they're defined here so the runtime carries one set.
6
+ */
7
+ export interface LiveTimings {
8
+ /** Decision 8: status reads check `main` at most this often. */
9
+ mainCheckMs: number;
10
+ /** Decision 13: the deployments are read at most this often while a publish is in flight. */
11
+ deploymentReadMs: number;
12
+ /** Decision 12: history is refreshed at most this often. */
13
+ historyMs: number;
14
+ /** How long the Publish lock lasts if its holder dies (Decision 10: one Publish at a time). */
15
+ publishLockMs: number;
16
+ /** Phase 2 (Decision 5): the sweep's tick. */
17
+ sweepTickMs: number;
18
+ /** Phase 2 (Decision 5): an autosave is committed once its document has been idle this long. */
19
+ sweepIdleMs: number;
20
+ /** Phase 2 (Decision 5): …and at least this often while someone keeps typing. */
21
+ sweepCapMs: number;
22
+ /** Phase 2 (Decision 9): a lock lapses this long after the last heartbeat (once committed). */
23
+ lockLapseMs: number;
24
+ /** Take over (2026-10-15): a tab renews its lock only while someone typed, clicked or scrolled in it this recently. */
25
+ lockIdleMs: number;
26
+ /** Phase 2 (Decision 9): someone is "on" a document while they polled with it this recently. */
27
+ presenceMs: number;
28
+ /** Decision 4: autosaves expire after this long, as a backstop. */
29
+ autosaveTtlMs: number;
30
+ /** Decision 13: each tab polls the status this often (the editor's POLL_MS; /admin passes it to the page). */
31
+ pollMs: number;
32
+ }
33
+
34
+ export const DEFAULT_TIMINGS: LiveTimings = {
35
+ mainCheckMs: 60_000,
36
+ deploymentReadMs: 10_000,
37
+ historyMs: 60_000,
38
+ publishLockMs: 120_000,
39
+ sweepTickMs: 30_000,
40
+ sweepIdleMs: 60_000,
41
+ sweepCapMs: 5 * 60_000,
42
+ lockLapseMs: 2 * 60_000,
43
+ lockIdleMs: 10 * 60_000,
44
+ presenceMs: 15_000,
45
+ autosaveTtlMs: 7 * 24 * 60 * 60_000,
46
+ pollMs: 5000,
47
+ };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * GitHub's webhook, `POST /_caracms/github` (M4 Decisions 8 and 14). It takes no
3
+ * session; it checks GitHub's signature instead.
4
+ *
5
+ * - **No `GITHUB_WEBHOOK_SECRET`: 404**, as if the route didn't exist; the
6
+ * once-a-minute check does the job.
7
+ * - **`X-Hub-Signature-256` must be the body's HMAC-SHA256 under the secret**,
8
+ * compared in constant time, or it's 401 and nothing happens.
9
+ * - **Nothing in the body is trusted or even read**: a good signature only prompts
10
+ * Decision 8's check, which asks GitHub itself what moved.
11
+ */
12
+ import { createHmac, timingSafeEqual } from 'node:crypto';
13
+
14
+ /** Largest body read. GitHub caps payloads at 25 MB; a push is far smaller, and it's never parsed. */
15
+ const MAX_BODY_BYTES = 25 * 1024 * 1024;
16
+
17
+ export function signatureMatches(secret: string, body: Buffer, header: string | null): boolean {
18
+ if (!header || !header.startsWith('sha256=')) return false;
19
+ const expected = Buffer.from(`sha256=${createHmac('sha256', secret).update(body).digest('hex')}`);
20
+ const given = Buffer.from(header.trim());
21
+ return given.length === expected.length && timingSafeEqual(given, expected);
22
+ }
23
+
24
+ const text = (status: number, body: string) => new Response(body, { status, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' } });
25
+
26
+ /**
27
+ * The whole route. `trigger` starts the check and isn't awaited: GitHub wants an
28
+ * answer within ten seconds, and a merge can take longer.
29
+ */
30
+ export async function handleGithubWebhook(request: Request, options: { secret: string | null; trigger: () => Promise<unknown> }): Promise<Response> {
31
+ if (!options.secret) return text(404, 'Not found');
32
+ if (request.method !== 'POST') return new Response('The webhook only takes POST.', { status: 405, headers: { Allow: 'POST' } });
33
+ if (Number(request.headers.get('content-length') ?? 0) > MAX_BODY_BYTES) return text(413, 'Too large.');
34
+ const body = Buffer.from(await request.arrayBuffer());
35
+ if (body.length > MAX_BODY_BYTES) return text(413, 'Too large.');
36
+ if (!signatureMatches(options.secret, body, request.headers.get('x-hub-signature-256'))) return text(401, 'Bad signature.');
37
+ void options.trigger().catch((error: unknown) => console.error('[caracms] the check after a webhook failed:', error));
38
+ return text(202, 'Checking.');
39
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The lockup's CSS, generated per site because the phone-lines switch is a media
3
+ * query at the site's configured width (Decision 11), and a media query can't read
4
+ * a custom property. Served as `virtual:caracms/lockup.css`, which Lockup.astro
5
+ * imports, so any page that renders a lockup gets it (inlined or linked by Astro).
6
+ *
7
+ * Structure (Lockup.astro):
8
+ * <h1 class="cms-lockup [cms-lockup--has-phone]" style="--cms-size…"> one heading
9
+ * <span class="cms-lockup-set cms-lockup-main" style="--longest-line: …"> a size container
10
+ * <span class="cms-lockup-fit"> font-size: the fit
11
+ * <span class="cms-lockup-line">…</span> <span class="cms-lockup-line">…</span>
12
+ * <span class="cms-lockup-set cms-lockup-phone" style="--longest-line: …"> only with phone lines
13
+ *
14
+ * Each set is its own container with its own `--longest-line` (Decision 11; M1 →
15
+ * E5: `cqi` resolves against the nearest container ancestor, so the fit sits inside
16
+ * the set). The inactive set is `display: none`, so screen readers, copying and
17
+ * crawlers see one set of lines.
18
+ */
19
+ export function lockupCss(phoneLinesWidth: number): string {
20
+ return `/* caracms: lockups (Spec → Text). Phone lines below ${phoneLinesWidth}px (Decision 11). */
21
+ .cms-lockup {
22
+ margin: 0;
23
+ }
24
+ .cms-lockup-set {
25
+ display: block;
26
+ container-type: inline-size;
27
+ }
28
+ .cms-lockup-fit {
29
+ display: block;
30
+ font-family: var(--cms-family);
31
+ font-weight: var(--cms-weight);
32
+ font-variation-settings: var(--cms-variation);
33
+ font-optical-sizing: none;
34
+ font-kerning: normal;
35
+ letter-spacing: var(--cms-tracking);
36
+ line-height: var(--cms-leading);
37
+ text-transform: var(--cms-transform);
38
+ /* The fit. No floor, on purpose (Spec → Text). */
39
+ font-size: min(var(--cms-size), calc(100cqi / var(--longest-line)));
40
+ }
41
+ .cms-lockup-line {
42
+ display: block;
43
+ white-space: nowrap;
44
+ width: fit-content;
45
+ }
46
+ .cms-lockup-phone {
47
+ display: none;
48
+ }
49
+ @media (width < ${phoneLinesWidth}px) {
50
+ .cms-lockup-phone {
51
+ display: block;
52
+ }
53
+ .cms-lockup--has-phone > .cms-lockup-main {
54
+ display: none;
55
+ }
56
+ }
57
+ `;
58
+ }
package/src/measure.ts ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Measure a lockup line's width in ems, from the font file, as the browser will
3
+ * draw it (Spec → Text → "What's measured"; lifted from M1, E4: within 0.064% of Chrome).
4
+ *
5
+ * - Shaped with HarfBuzz (harfbuzzjs), the shaper Chrome and Firefox use, so
6
+ * kerning (GPOS) and substitutions (GSUB) match their defaults.
7
+ * - The style's text-transform is applied before shaping.
8
+ * - The style's variable axes are pinned (TextStyle.axes), as the CSS pins them.
9
+ * - Tracking is added once per grapheme cluster, including after the last one
10
+ * (browsers draw that trailing letter-space).
11
+ * - With non-zero tracking, optional ligatures (`liga`, `clig`) are off, as CSS asks.
12
+ * - **Uncovered characters** (Decision 15): a character the font has no glyph for
13
+ * shapes as `.notdef` (glyph 0) here, but the browser draws it from a system
14
+ * font at an unknown width, so the fit can overflow. They're reported, per line,
15
+ * as the graphemes as drawn (after the transform), so the editor can say
16
+ * "“✨” isn't in this font, so this line may not fit".
17
+ *
18
+ * Synchronous after module load (harfbuzzjs initialises its wasm with a top-level
19
+ * await), so it runs inside an Astro component's frontmatter during render.
20
+ */
21
+ import { readFileSync } from 'node:fs';
22
+ import type * as Harfbuzz from 'harfbuzzjs';
23
+ import type { TextStyle, TextTransform } from './text-styles.ts';
24
+ import { normaliseLines } from './fields.ts';
25
+
26
+ /** What measuring needs from a style: the measured font's absolute path, and how it's set. */
27
+ export type MeasureStyle = Pick<TextStyle, 'axes' | 'trackingEm' | 'textTransform'> & {
28
+ resolvedFontFile: { measure: string };
29
+ };
30
+
31
+ /**
32
+ * harfbuzzjs, loaded at runtime from where it's installed. The integration sets
33
+ * `__CARACMS_HARFBUZZ__` (a Vite `define`) to its entry's file URL, because a
34
+ * built site can't bundle it (it reads its .wasm beside itself) and, under pnpm,
35
+ * can't resolve it from the site's own folder either (it's the package's
36
+ * dependency, not the site's). Outside Vite (node:test) it's a plain import.
37
+ */
38
+ declare const __CARACMS_HARFBUZZ__: string | undefined;
39
+ const hb: typeof Harfbuzz = await import(
40
+ /* @vite-ignore */ typeof __CARACMS_HARFBUZZ__ === 'string' ? __CARACMS_HARFBUZZ__ : 'harfbuzzjs'
41
+ );
42
+
43
+ interface LoadedFont {
44
+ font: Harfbuzz.Font;
45
+ upem: number;
46
+ }
47
+
48
+ // A cache of shaping fonts by file and axes: immutable, so safe to share between renders.
49
+ const fonts = new Map<string, LoadedFont>();
50
+
51
+ function fontFor(style: MeasureStyle): LoadedFont {
52
+ const path = style.resolvedFontFile.measure;
53
+ const key = `${path}|${JSON.stringify(style.axes)}`;
54
+ let loaded = fonts.get(key);
55
+ if (!loaded) {
56
+ const face = new hb.Face(new hb.Blob(readFileSync(path)));
57
+ const font = new hb.Font(face);
58
+ const axes = Object.entries(style.axes);
59
+ if (axes.length > 0) font.setVariations(axes.map(([tag, value]) => new hb.Variation(tag, value)));
60
+ loaded = { font, upem: face.upem };
61
+ fonts.set(key, loaded);
62
+ }
63
+ return loaded;
64
+ }
65
+
66
+ export function applyTransform(text: string, transform: TextTransform): string {
67
+ switch (transform) {
68
+ case 'uppercase':
69
+ return text.toLocaleUpperCase('en');
70
+ case 'lowercase':
71
+ return text.toLocaleLowerCase('en');
72
+ default:
73
+ return text;
74
+ }
75
+ }
76
+
77
+ const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
78
+
79
+ export interface MeasuredLine {
80
+ text: string;
81
+ /** Width in em at the style's tracking, transform and axes. */
82
+ em: number;
83
+ /** Graphemes (as drawn, after the transform) the font has no glyph for, in order, without repeats. */
84
+ uncovered: string[];
85
+ }
86
+
87
+ /** Measure one line. Empty → 0 em. */
88
+ export function measureLineDetail(line: string, style: MeasureStyle): MeasuredLine {
89
+ const text = applyTransform(line, style.textTransform);
90
+ if (text.length === 0) return { text: line, em: 0, uncovered: [] };
91
+
92
+ const { font, upem } = fontFor(style);
93
+ const buffer = new hb.Buffer();
94
+ buffer.addText(text);
95
+ buffer.guessSegmentProperties();
96
+ const features = style.trackingEm !== 0 ? [new hb.Feature('liga', 0), new hb.Feature('clig', 0)] : [];
97
+ hb.shape(font, buffer, features);
98
+
99
+ let advance = 0;
100
+ for (const p of buffer.getGlyphPositions()) advance += p.xAdvance;
101
+
102
+ // Graphemes by UTF-16 offset; a glyph's cluster is the offset of its first code unit.
103
+ const graphemes = [...segmenter.segment(text)];
104
+ const uncovered: string[] = [];
105
+ for (const info of buffer.getGlyphInfos()) {
106
+ if (info.codepoint !== 0) continue; // after shaping, `codepoint` is the glyph id; 0 is .notdef
107
+ const g = graphemes.findLast((s) => s.index <= info.cluster)?.segment;
108
+ if (g && !/^\s+$/.test(g) && !uncovered.includes(g)) uncovered.push(g);
109
+ }
110
+
111
+ return { text: line, em: advance / upem + graphemes.length * style.trackingEm, uncovered };
112
+ }
113
+
114
+ /** The line's width in ems. */
115
+ export function measureLine(line: string, style: MeasureStyle): number {
116
+ return measureLineDetail(line, style).em;
117
+ }
118
+
119
+ /** Graphemes in a line the style's font doesn't cover (Decision 15). */
120
+ export function uncoveredCharacters(line: string, style: MeasureStyle): string[] {
121
+ return measureLineDetail(line, style).uncovered;
122
+ }
123
+
124
+ export interface MeasuredLockup {
125
+ lines: MeasuredLine[];
126
+ /** The longest line's em width; drives `--longest-line`. */
127
+ longest: number;
128
+ /** Every uncovered grapheme across the lines, without repeats. */
129
+ uncovered: string[];
130
+ }
131
+
132
+ /** Measure a set of lines as the lockup renders them: trimmed, blank lines dropped. */
133
+ export function measureLockup(lines: readonly string[], style: MeasureStyle): MeasuredLockup {
134
+ const measured = normaliseLines(lines).map((text) => measureLineDetail(text, style));
135
+ const uncovered = [...new Set(measured.flatMap((l) => l.uncovered))];
136
+ return { lines: measured, longest: Math.max(0, ...measured.map((l) => l.em)), uncovered };
137
+ }
138
+
139
+ /**
140
+ * The size a lockup computes at a given container width, in px, by the CSS fit:
141
+ * `min(size, width / longest)`. What the browser does; for tests and the demo's numbers.
142
+ */
143
+ export function fittedSizePx(longestEm: number, containerPx: number, sizePx: number): number {
144
+ return longestEm > 0 ? Math.min(sizePx, containerPx / longestEm) : sizePx;
145
+ }