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,533 @@
1
+ /**
2
+ * Redis for live mode (M4 Decision 17, M1 → R1/R2): one small interface, two
3
+ * implementations.
4
+ *
5
+ * - `connectRedis(url)`: the real client, over ioredis. Railway's Redis sleeps after
6
+ * a few minutes with no traffic and wakes on the first connection, which is
7
+ * refused for about a second meanwhile. So:
8
+ * - **Connect-phase errors and timeouts are retried**, backing off from 250 ms,
9
+ * doubling, capped at 2 s, **within a 30-second budget** per command. A command
10
+ * that waits for Redis to wake just takes about a second longer.
11
+ * - ioredis's own reconnects, retries and offline queue are off, so nothing
12
+ * reconnects behind our back and keeps Redis awake. `family: 0`, since Railway's
13
+ * private network is dual-stack.
14
+ * - **ioredis hides the real connect error** behind "Connection is closed."; the
15
+ * code (`ECONNREFUSED`…) arrives on the `error` event, so it's kept from there.
16
+ * - The handshake (ready check) has its own timeout: ioredis's `connectTimeout`
17
+ * only covers the TCP connect.
18
+ * - **The connection closes after 30 s idle**, so Redis can sleep.
19
+ * - A command that fails after it was sent (a timeout, or the connection dropping)
20
+ * is retried only when sending it twice is harmless (`get`, plain `set`…), never
21
+ * for `incr`, `getdel`, `set NX` or the compare-and-swap calls.
22
+ * - `memoryRedis()`: the in-memory stand-in for tests and dev without `REDIS_URL`
23
+ * (Core framing). Same interface and semantics, an injectable clock, and a call
24
+ * count, so a test can check that something made no Redis call at all.
25
+ *
26
+ * The interface is what M4 needs and no more: strings with expiry, SET NX PX,
27
+ * GETDEL, INCR and expiry for rate limits, hashes, sets, a key scan, and two
28
+ * compare-and-swap calls for locks. Values are strings; store JSON as text.
29
+ */
30
+ import IORedis from 'ioredis';
31
+
32
+ export interface SetOptions {
33
+ /** Expire after this many milliseconds. */
34
+ px?: number;
35
+ /** Only set when the key doesn't exist (SET NX). */
36
+ nx?: boolean;
37
+ }
38
+
39
+ export interface RedisClient {
40
+ get(key: string): Promise<string | null>;
41
+ mget(keys: string[]): Promise<(string | null)[]>;
42
+ /** SET. Returns false only when `nx` was asked for and the key already existed. */
43
+ set(key: string, value: string, options?: SetOptions): Promise<boolean>;
44
+ /** GETDEL: read and delete in one step (spending a sign-in link). */
45
+ getdel(key: string): Promise<string | null>;
46
+ /** How many of the keys existed. */
47
+ del(...keys: string[]): Promise<number>;
48
+ incr(key: string): Promise<number>;
49
+ /** False when the key doesn't exist. */
50
+ pexpire(key: string, ms: number): Promise<boolean>;
51
+ /** Milliseconds left; -1 with no expiry, -2 when missing (Redis's PTTL). */
52
+ pttl(key: string): Promise<number>;
53
+ hget(key: string, field: string): Promise<string | null>;
54
+ /** HSET: how many fields were new. */
55
+ hset(key: string, fields: Record<string, string>): Promise<number>;
56
+ hdel(key: string, ...fields: string[]): Promise<number>;
57
+ /** Every field; `{}` when the key is missing. */
58
+ hgetall(key: string): Promise<Record<string, string>>;
59
+ sadd(key: string, ...members: string[]): Promise<number>;
60
+ srem(key: string, ...members: string[]): Promise<number>;
61
+ smembers(key: string): Promise<string[]>;
62
+ /** Every key matching a glob pattern (`caracms:autosave:*`), via SCAN. Order unspecified. */
63
+ scan(pattern: string): Promise<string[]>;
64
+ /**
65
+ * Set `key` to `value` only if it currently holds `expected` (`null`: only if it's
66
+ * missing). Atomic. For moving a lock (Edit here) and renewing one.
67
+ */
68
+ compareAndSet(key: string, expected: string | null, value: string, options?: { px?: number }): Promise<boolean>;
69
+ /** Delete `key` only if it holds `expected`. Atomic. For releasing a lock you hold. */
70
+ compareAndDelete(key: string, expected: string): Promise<boolean>;
71
+ /** Close the connection, if any. The client stays usable: the next command reconnects. */
72
+ close(): Promise<void>;
73
+ }
74
+
75
+ /** Redis couldn't be reached within the budget, or refused a command. `code` is the real socket error's. */
76
+ export class RedisUnavailableError extends Error {
77
+ readonly code: string | null;
78
+ constructor(message: string, code: string | null) {
79
+ super(message);
80
+ this.name = 'RedisUnavailableError';
81
+ this.code = code;
82
+ }
83
+ }
84
+
85
+ export interface RedisOptions {
86
+ /** The whole wait for one command, retries included. Default 30 s (Decision 17). */
87
+ budgetMs?: number;
88
+ /** First backoff; it doubles up to `maxBackoffMs`. Defaults 250 ms and 2 s. */
89
+ backoffMs?: number;
90
+ maxBackoffMs?: number;
91
+ /** One attempt's TCP connect plus handshake. Default 5 s (never more than the budget left). */
92
+ handshakeTimeoutMs?: number;
93
+ /** One command's reply. Default 5 s. */
94
+ commandTimeoutMs?: number;
95
+ /** Close the connection after this long with nothing sent. Default 30 s. */
96
+ idleCloseMs?: number;
97
+ /** Where retries and closes are reported. Default: nothing. */
98
+ log?: (line: string) => void;
99
+ }
100
+
101
+ const CAS_SET = `local cur = redis.call('GET', KEYS[1])
102
+ local ok
103
+ if ARGV[1] == '1' then ok = (cur == false) else ok = (cur == ARGV[2]) end
104
+ if not ok then return 0 end
105
+ if ARGV[4] ~= '' then redis.call('SET', KEYS[1], ARGV[3], 'PX', ARGV[4]) else redis.call('SET', KEYS[1], ARGV[3]) end
106
+ return 1`;
107
+
108
+ const CAS_DEL = `if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end`;
109
+
110
+ /** Socket errors that mean "not reachable yet": what a sleeping Railway Redis gives (M1 → R2). */
111
+ const CONNECT_CODES = new Set(['ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN', 'ECONNRESET', 'EHOSTUNREACH', 'ENETUNREACH', 'EPIPE', 'ECONNABORTED', 'CARACMS_TIMEOUT']);
112
+
113
+ const errorCode = (error: unknown): string | null => {
114
+ const e = error as { code?: unknown; errors?: unknown[] } | null;
115
+ if (typeof e?.code === 'string') return e.code;
116
+ // Node's dual-stack connect fails with an AggregateError of one error per address.
117
+ if (Array.isArray(e?.errors)) for (const inner of e.errors) {
118
+ const code = errorCode(inner);
119
+ if (code) return code;
120
+ }
121
+ return null;
122
+ };
123
+
124
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
125
+
126
+ function withTimeout<T>(promise: Promise<T>, ms: number, what: string): Promise<T> {
127
+ let timer: NodeJS.Timeout | undefined;
128
+ const timeout = new Promise<never>((_, reject) => {
129
+ timer = setTimeout(() => reject(Object.assign(new Error(`${what} gave no answer in ${ms} ms`), { code: 'CARACMS_TIMEOUT' })), ms);
130
+ });
131
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
132
+ }
133
+
134
+ type Ioredis = InstanceType<typeof IORedis>;
135
+
136
+ /**
137
+ * The real client. Nothing connects until the first command, and the connection
138
+ * closes again after `idleCloseMs` with nothing sent.
139
+ */
140
+ export function connectRedis(url: string, options: RedisOptions = {}): RedisClient {
141
+ const budgetMs = options.budgetMs ?? 30_000;
142
+ const backoffMs = options.backoffMs ?? 250;
143
+ const maxBackoffMs = options.maxBackoffMs ?? 2_000;
144
+ const handshakeMs = options.handshakeTimeoutMs ?? 5_000;
145
+ const commandMs = options.commandTimeoutMs ?? 5_000;
146
+ const idleMs = options.idleCloseMs ?? 30_000;
147
+ const log = options.log ?? (() => {});
148
+
149
+ let client: Ioredis | null = null;
150
+ let connecting: Promise<Ioredis> | null = null;
151
+ let inFlight = 0;
152
+ let idleTimer: NodeJS.Timeout | null = null;
153
+
154
+ const drop = (c: Ioredis) => {
155
+ if (client === c) client = null;
156
+ try {
157
+ c.disconnect(false);
158
+ } catch {
159
+ /* already gone */
160
+ }
161
+ };
162
+
163
+ /** One connect attempt. Rejects with the real socket error when there is one. */
164
+ const attemptConnect = async (timeoutMs: number): Promise<Ioredis> => {
165
+ const c = new IORedis(url, {
166
+ family: 0,
167
+ lazyConnect: true,
168
+ connectTimeout: timeoutMs,
169
+ commandTimeout: commandMs,
170
+ enableOfflineQueue: false,
171
+ maxRetriesPerRequest: 0,
172
+ retryStrategy: () => null,
173
+ reconnectOnError: () => false,
174
+ enableReadyCheck: true,
175
+ autoResubscribe: false,
176
+ autoResendUnfulfilledCommands: false,
177
+ });
178
+ const socketErrors: unknown[] = [];
179
+ c.on('error', (error: unknown) => socketErrors.push(error));
180
+ c.on('end', () => {
181
+ if (client === c) client = null;
182
+ });
183
+ try {
184
+ await withTimeout(c.connect(), timeoutMs, 'Redis connect and handshake');
185
+ return c;
186
+ } catch (error) {
187
+ drop(c);
188
+ // "Connection is closed." hides the cause; the socket error has the code.
189
+ const real = socketErrors.find((e) => errorCode(e)) ?? error;
190
+ throw real;
191
+ }
192
+ };
193
+
194
+ const connect = (deadline: number): Promise<Ioredis> => {
195
+ if (client && client.status === 'ready') return Promise.resolve(client);
196
+ if (client) drop(client);
197
+ connecting ??= (async () => {
198
+ let delay = backoffMs;
199
+ for (let attempt = 1; ; attempt++) {
200
+ const left = deadline - Date.now();
201
+ try {
202
+ const c = await attemptConnect(Math.max(250, Math.min(handshakeMs, left)));
203
+ if (attempt > 1) log(`[caracms] Redis connected after ${attempt} attempts`);
204
+ client = c;
205
+ return c;
206
+ } catch (error) {
207
+ const code = errorCode(error);
208
+ const retryable = code ? CONNECT_CODES.has(code) : /connection is closed/i.test(String((error as Error)?.message));
209
+ if (!retryable) throw new RedisUnavailableError(`Redis refused the connection: ${(error as Error)?.message ?? error}`, code);
210
+ if (Date.now() + delay > deadline) {
211
+ throw new RedisUnavailableError(`Redis wasn't reachable within ${Math.round(budgetMs / 1000)} s (${code ?? (error as Error)?.message}).`, code);
212
+ }
213
+ await sleep(delay);
214
+ delay = Math.min(delay * 2, maxBackoffMs);
215
+ }
216
+ }
217
+ })().finally(() => {
218
+ connecting = null;
219
+ });
220
+ return connecting;
221
+ };
222
+
223
+ const armIdle = () => {
224
+ if (idleTimer) clearTimeout(idleTimer);
225
+ idleTimer = null;
226
+ if (inFlight > 0 || !client) return;
227
+ idleTimer = setTimeout(() => {
228
+ idleTimer = null;
229
+ if (inFlight === 0 && client) {
230
+ const c = client;
231
+ client = null;
232
+ log('[caracms] Redis idle: closing the connection so it can sleep');
233
+ void withTimeout(c.quit(), 2_000, 'QUIT').catch(() => {}).finally(() => drop(c));
234
+ }
235
+ }, idleMs);
236
+ idleTimer.unref?.();
237
+ };
238
+
239
+ /** Run one command: connect (with retries), send, and retry a harmless command that failed in flight. */
240
+ const run = async <T>(idempotent: boolean, send: (c: Ioredis) => Promise<T>): Promise<T> => {
241
+ inFlight++;
242
+ if (idleTimer) clearTimeout(idleTimer);
243
+ idleTimer = null;
244
+ const deadline = Date.now() + budgetMs;
245
+ try {
246
+ for (;;) {
247
+ const c = await connect(deadline);
248
+ try {
249
+ return await withTimeout(send(c), commandMs, 'Redis command');
250
+ } catch (error) {
251
+ const code = errorCode(error);
252
+ const lost = code === 'CARACMS_TIMEOUT' || /connection is closed|stream isn't writeable|command timed out/i.test(String((error as Error)?.message));
253
+ if (!lost) throw error; // Redis answered with an error: not ours to retry
254
+ drop(c);
255
+ if (!idempotent || Date.now() + backoffMs > deadline) {
256
+ throw new RedisUnavailableError(`Redis didn't answer (${(error as Error)?.message}).`, code);
257
+ }
258
+ await sleep(backoffMs);
259
+ }
260
+ }
261
+ } finally {
262
+ inFlight--;
263
+ armIdle();
264
+ }
265
+ };
266
+
267
+ return {
268
+ get: (key) => run(true, (c) => c.get(key)),
269
+ mget: (keys) => (keys.length ? run(true, (c) => c.mget(...keys)) : Promise.resolve([])),
270
+ set: (key, value, o = {}) =>
271
+ run(!o.nx, async (c) => {
272
+ const args: (string | number)[] = [];
273
+ if (o.px !== undefined) args.push('PX', Math.max(1, Math.round(o.px)));
274
+ if (o.nx) args.push('NX');
275
+ const reply = await (c.set as (...a: unknown[]) => Promise<string | null>)(key, value, ...args);
276
+ return reply === 'OK';
277
+ }),
278
+ getdel: (key) => run(false, (c) => c.getdel(key)),
279
+ del: (...keys) => (keys.length ? run(true, (c) => c.del(...keys)) : Promise.resolve(0)),
280
+ incr: (key) => run(false, (c) => c.incr(key)),
281
+ pexpire: (key, ms) => run(true, async (c) => (await c.pexpire(key, Math.max(1, Math.round(ms)))) === 1),
282
+ pttl: (key) => run(true, (c) => c.pttl(key)),
283
+ hget: (key, field) => run(true, (c) => c.hget(key, field)),
284
+ hset: (key, fields) => run(true, (c) => c.hset(key, fields)),
285
+ hdel: (key, ...fields) => (fields.length ? run(true, (c) => c.hdel(key, ...fields)) : Promise.resolve(0)),
286
+ hgetall: (key) => run(true, (c) => c.hgetall(key)),
287
+ sadd: (key, ...members) => (members.length ? run(true, (c) => c.sadd(key, ...members)) : Promise.resolve(0)),
288
+ srem: (key, ...members) => (members.length ? run(true, (c) => c.srem(key, ...members)) : Promise.resolve(0)),
289
+ smembers: (key) => run(true, (c) => c.smembers(key)),
290
+ scan: (pattern) =>
291
+ run(true, async (c) => {
292
+ const keys = new Set<string>();
293
+ let cursor = '0';
294
+ do {
295
+ const [next, batch] = await c.scan(cursor, 'MATCH', pattern, 'COUNT', 200);
296
+ cursor = next;
297
+ for (const k of batch) keys.add(k);
298
+ } while (cursor !== '0');
299
+ return [...keys];
300
+ }),
301
+ compareAndSet: (key, expected, value, o = {}) =>
302
+ run(false, async (c) =>
303
+ (await c.eval(CAS_SET, 1, key, expected === null ? '1' : '0', expected ?? '', value, o.px === undefined ? '' : String(Math.max(1, Math.round(o.px))))) === 1,
304
+ ),
305
+ compareAndDelete: (key, expected) => run(false, async (c) => (await c.eval(CAS_DEL, 1, key, expected)) === 1),
306
+ async close() {
307
+ if (idleTimer) clearTimeout(idleTimer);
308
+ idleTimer = null;
309
+ const c = client;
310
+ client = null;
311
+ if (c) await withTimeout(c.quit(), 2_000, 'QUIT').catch(() => {}).finally(() => drop(c));
312
+ },
313
+ };
314
+ }
315
+
316
+ // ── The in-memory stand-in ────────────────────────────────────────────────────
317
+
318
+ type Value = { kind: 'string'; value: string } | { kind: 'hash'; value: Map<string, string> } | { kind: 'set'; value: Set<string> };
319
+
320
+ export interface MemoryRedis extends RedisClient {
321
+ /** Every command run so far (a test resets it to 0 and checks it stays there). */
322
+ calls: number;
323
+ /** Empty it, as a Redis flush would. */
324
+ flush(): void;
325
+ }
326
+
327
+ class WrongType extends Error {
328
+ constructor() {
329
+ super('WRONGTYPE Operation against a key holding the wrong kind of value');
330
+ }
331
+ }
332
+
333
+ /** Redis's glob, for SCAN MATCH: `*`, `?` and `[...]`; `\` escapes. */
334
+ function globToRegExp(pattern: string): RegExp {
335
+ let out = '';
336
+ for (let i = 0; i < pattern.length; i++) {
337
+ const ch = pattern[i]!;
338
+ if (ch === '\\' && i + 1 < pattern.length) out += pattern[++i]!.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
339
+ else if (ch === '*') out += '.*';
340
+ else if (ch === '?') out += '.';
341
+ else if (ch === '[') {
342
+ const end = pattern.indexOf(']', i + 1);
343
+ if (end === -1) out += '\\[';
344
+ else {
345
+ out += `[${pattern.slice(i + 1, end).replace(/\\/g, '\\\\')}]`;
346
+ i = end;
347
+ }
348
+ } else out += ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
349
+ }
350
+ return new RegExp(`^${out}$`, 's');
351
+ }
352
+
353
+ /** The in-memory stand-in. `now` is the clock expiry is measured against (default Date.now). */
354
+ export function memoryRedis(options: { now?: () => number } = {}): MemoryRedis {
355
+ const now = options.now ?? Date.now;
356
+ const data = new Map<string, Value>();
357
+ const expires = new Map<string, number>();
358
+
359
+ const alive = (key: string): Value | undefined => {
360
+ const at = expires.get(key);
361
+ if (at !== undefined && at <= now()) {
362
+ data.delete(key);
363
+ expires.delete(key);
364
+ }
365
+ return data.get(key);
366
+ };
367
+ const str = (key: string) => {
368
+ const v = alive(key);
369
+ if (v && v.kind !== 'string') throw new WrongType();
370
+ return v?.value ?? null;
371
+ };
372
+ const hash = (key: string, create: boolean) => {
373
+ const v = alive(key);
374
+ if (v && v.kind !== 'hash') throw new WrongType();
375
+ if (!v && create) {
376
+ const h = { kind: 'hash' as const, value: new Map<string, string>() };
377
+ data.set(key, h);
378
+ return h.value;
379
+ }
380
+ return v?.value ?? null;
381
+ };
382
+ const set = (key: string, create: boolean) => {
383
+ const v = alive(key);
384
+ if (v && v.kind !== 'set') throw new WrongType();
385
+ if (!v && create) {
386
+ const s = { kind: 'set' as const, value: new Set<string>() };
387
+ data.set(key, s);
388
+ return s.value;
389
+ }
390
+ return v?.value ?? null;
391
+ };
392
+ const putString = (key: string, value: string, px?: number) => {
393
+ data.set(key, { kind: 'string', value });
394
+ if (px === undefined) expires.delete(key);
395
+ else expires.set(key, now() + Math.max(1, Math.round(px)));
396
+ };
397
+ const remove = (key: string) => {
398
+ const had = alive(key) !== undefined;
399
+ data.delete(key);
400
+ expires.delete(key);
401
+ return had;
402
+ };
403
+ const dropIfEmpty = (key: string) => {
404
+ const v = data.get(key);
405
+ if (v && v.kind !== 'string' && v.value.size === 0) remove(key);
406
+ };
407
+
408
+ const r: MemoryRedis = {
409
+ calls: 0,
410
+ flush() {
411
+ data.clear();
412
+ expires.clear();
413
+ },
414
+ async get(key) {
415
+ r.calls++;
416
+ return str(key);
417
+ },
418
+ async mget(keys) {
419
+ r.calls++;
420
+ return keys.map((k) => {
421
+ const v = alive(k);
422
+ return v?.kind === 'string' ? v.value : null;
423
+ });
424
+ },
425
+ async set(key, value, o = {}) {
426
+ r.calls++;
427
+ if (o.nx && alive(key) !== undefined) return false;
428
+ putString(key, value, o.px);
429
+ return true;
430
+ },
431
+ async getdel(key) {
432
+ r.calls++;
433
+ const v = str(key);
434
+ if (v !== null) remove(key);
435
+ return v;
436
+ },
437
+ async del(...keys) {
438
+ r.calls++;
439
+ return keys.filter(remove).length;
440
+ },
441
+ async incr(key) {
442
+ r.calls++;
443
+ const v = str(key);
444
+ const n = v === null ? 0 : Number(v);
445
+ if (!Number.isSafeInteger(n)) throw new Error('ERR value is not an integer or out of range');
446
+ const at = expires.get(key);
447
+ data.set(key, { kind: 'string', value: String(n + 1) });
448
+ if (at === undefined) expires.delete(key); // INCR keeps an existing expiry
449
+ return n + 1;
450
+ },
451
+ async pexpire(key, ms) {
452
+ r.calls++;
453
+ if (alive(key) === undefined) return false;
454
+ expires.set(key, now() + Math.max(1, Math.round(ms)));
455
+ return true;
456
+ },
457
+ async pttl(key) {
458
+ r.calls++;
459
+ if (alive(key) === undefined) return -2;
460
+ const at = expires.get(key);
461
+ return at === undefined ? -1 : Math.max(0, at - now());
462
+ },
463
+ async hget(key, field) {
464
+ r.calls++;
465
+ return hash(key, false)?.get(field) ?? null;
466
+ },
467
+ async hset(key, fields) {
468
+ r.calls++;
469
+ const h = hash(key, true)!;
470
+ let added = 0;
471
+ for (const [f, v] of Object.entries(fields)) {
472
+ if (!h.has(f)) added++;
473
+ h.set(f, v);
474
+ }
475
+ return added;
476
+ },
477
+ async hdel(key, ...fields) {
478
+ r.calls++;
479
+ const h = hash(key, false);
480
+ if (!h) return 0;
481
+ const n = fields.filter((f) => h.delete(f)).length;
482
+ dropIfEmpty(key);
483
+ return n;
484
+ },
485
+ async hgetall(key) {
486
+ r.calls++;
487
+ return Object.fromEntries(hash(key, false) ?? []);
488
+ },
489
+ async sadd(key, ...members) {
490
+ r.calls++;
491
+ const s = set(key, true)!;
492
+ let added = 0;
493
+ for (const m of members) {
494
+ if (s.has(m)) continue;
495
+ s.add(m);
496
+ added++;
497
+ }
498
+ return added;
499
+ },
500
+ async srem(key, ...members) {
501
+ r.calls++;
502
+ const s = set(key, false);
503
+ if (!s) return 0;
504
+ const n = members.filter((m) => s.delete(m)).length;
505
+ dropIfEmpty(key);
506
+ return n;
507
+ },
508
+ async smembers(key) {
509
+ r.calls++;
510
+ return [...(set(key, false) ?? [])];
511
+ },
512
+ async scan(pattern) {
513
+ r.calls++;
514
+ const re = globToRegExp(pattern);
515
+ return [...data.keys()].filter((k) => alive(k) !== undefined && re.test(k));
516
+ },
517
+ async compareAndSet(key, expected, value, o = {}) {
518
+ r.calls++;
519
+ const cur = str(key);
520
+ if (expected === null ? cur !== null : cur !== expected) return false;
521
+ putString(key, value, o.px);
522
+ return true;
523
+ },
524
+ async compareAndDelete(key, expected) {
525
+ r.calls++;
526
+ if (str(key) !== expected) return false;
527
+ remove(key);
528
+ return true;
529
+ },
530
+ async close() {},
531
+ };
532
+ return r;
533
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The live runtime: one per process, built lazily on first use (M4 Phase 1). It holds
3
+ * the GitHub backend, the Redis client, the `drafts` copy and the operations on it
4
+ * (git.ts), and is what Phases 2 and 3 reach for:
5
+ *
6
+ * const rt = await liveRuntime(site, settings); // settings: mode.ts → readMode()
7
+ * rt.git.files().get('content/site-settings.json') // Phase 3's whitelist
8
+ * rt.git.contentSource() // content.ts over the copy
9
+ * rt.redis // autosaves, locks, sessions
10
+ * rt.git.commitDrafts([...]); rt.git.publish(person); rt.git.waiting(); rt.git.publishStatus()
11
+ * rt.git.commitPending = () => sweep.commitAll() // Phase 2 fills Publish's hook
12
+ *
13
+ * **The in-memory GitHub** (Decision 2): with `CARACMS_GITHUB=memory` the backend is a
14
+ * MemoryBackend seeded from the site's own files on disk as `main`, beside the Redis
15
+ * stand-in, so tests and local browser runs never touch a real repo. It's refused
16
+ * when `NODE_ENV` is `production`.
17
+ */
18
+ import { readdir, readFile } from 'node:fs/promises';
19
+ import { join, relative, sep } from 'node:path';
20
+ import { repoPrefix, type ResolvedSite } from '../config.ts';
21
+ import { GitHubBackend, MemoryBackend, type FileWrite, type GitBackend } from './backend.ts';
22
+ import { DEFAULT_BRANCHES } from './branches.ts';
23
+ import { GitHubClient, Tracker } from './github.ts';
24
+ import { LiveGit, SITE_SETTINGS_FILE } from './git.ts';
25
+ import type { LiveSettings } from './mode.ts';
26
+ import { connectRedis, memoryRedis, type RedisClient } from './redis.ts';
27
+ import type { LiveTimings } from './timings.ts';
28
+
29
+ export interface LiveRuntime {
30
+ readonly site: ResolvedSite;
31
+ readonly settings: LiveSettings;
32
+ readonly backend: GitBackend;
33
+ readonly redis: RedisClient;
34
+ /** The copy and every git operation. */
35
+ readonly git: LiveGit;
36
+ readonly timings: LiveTimings;
37
+ /** The clock everything here reads. */
38
+ readonly now: () => number;
39
+ close(): Promise<void>;
40
+ }
41
+
42
+ export interface RuntimeOptions {
43
+ site: ResolvedSite;
44
+ settings: LiveSettings;
45
+ /** Overrides for tests: a backend or Redis already set up, a fake clock. */
46
+ backend?: GitBackend;
47
+ redis?: RedisClient;
48
+ now?: () => number;
49
+ sleep?: (ms: number) => Promise<void>;
50
+ log?: (line: string) => void;
51
+ runningCommit?: () => string | null;
52
+ railwayFetch?: typeof fetch;
53
+ }
54
+
55
+ /** Build and start a runtime (not shared: tests make their own). */
56
+ export async function createLiveRuntime(options: RuntimeOptions): Promise<LiveRuntime> {
57
+ const { site, settings } = options;
58
+ const now = options.now ?? Date.now;
59
+ const log = options.log ?? ((line: string) => console.log(`[caracms] ${line}`));
60
+ let backend = options.backend;
61
+ if (!backend) {
62
+ if (settings.github.kind === 'memory') {
63
+ if (settings.production || process.env.NODE_ENV === 'production') {
64
+ throw new Error('caracms: the in-memory GitHub (CARACMS_GITHUB=memory) is refused in production.');
65
+ }
66
+ const memory = new MemoryBackend(new Tracker(now), { now, branches: settings.branches });
67
+ memory.seed(await siteFiles(site));
68
+ log('live mode on the in-memory GitHub, seeded from the site\'s files on disk (CARACMS_GITHUB=memory)');
69
+ backend = memory;
70
+ } else {
71
+ const tracker = new Tracker(now);
72
+ const { appId, privateKey, owner, repo } = settings.github;
73
+ backend = new GitHubBackend(new GitHubClient({ appId, privateKey, owner, repo, now }, tracker), settings.branches);
74
+ }
75
+ }
76
+ const { main, drafts } = backend.branches;
77
+ if (main !== DEFAULT_BRANCHES.main || drafts !== DEFAULT_BRANCHES.drafts) log(`publishing to ${main}, autosaving to ${drafts} (CARACMS_MAIN_BRANCH, CARACMS_DRAFTS_BRANCH)`);
78
+ const redis = options.redis ?? (settings.redisUrl ? connectRedis(settings.redisUrl, { log }) : memoryRedis({ now }));
79
+ if (!options.redis && !settings.redisUrl) log('no REDIS_URL: using the in-memory Redis stand-in (dev only)');
80
+ const git = new LiveGit({
81
+ backend,
82
+ site,
83
+ redis,
84
+ now,
85
+ sleep: options.sleep,
86
+ log,
87
+ runningCommit: options.runningCommit,
88
+ timings: settings.timings,
89
+ railway: settings.railway ? { ...settings.railway, fetch: options.railwayFetch } : null,
90
+ });
91
+ // The copy only: the startup check waits for the live editor (editor.ts), which fills
92
+ // `commitPending` first, so a merge from main never runs ahead of pending autosaves (Decision 8).
93
+ await git.start({ check: false });
94
+ return {
95
+ site,
96
+ settings,
97
+ backend,
98
+ redis,
99
+ git,
100
+ timings: git.timings,
101
+ now,
102
+ async close() {
103
+ await redis.close();
104
+ },
105
+ };
106
+ }
107
+
108
+ const SHARED = Symbol.for('caracms.liveRuntime');
109
+ type Shared = { [SHARED]?: Promise<LiveRuntime> };
110
+
111
+ /**
112
+ * The process's runtime, built on first call and shared after (on `globalThis`, so
113
+ * Vite loading a module twice in dev still gets one). A start that fails is
114
+ * forgotten, so the next request tries again.
115
+ */
116
+ export function liveRuntime(site: ResolvedSite, settings: LiveSettings): Promise<LiveRuntime> {
117
+ const g = globalThis as Shared;
118
+ g[SHARED] ??= createLiveRuntime({ site, settings }).catch((error: unknown) => {
119
+ delete g[SHARED];
120
+ throw error;
121
+ });
122
+ return g[SHARED];
123
+ }
124
+
125
+ /** The runtime if one has been built (or is being built), without starting one. */
126
+ export function existingLiveRuntime(): Promise<LiveRuntime> | null {
127
+ return (globalThis as Shared)[SHARED] ?? null;
128
+ }
129
+
130
+ /** Forget the shared runtime (tests). */
131
+ export async function resetLiveRuntime(): Promise<void> {
132
+ const g = globalThis as Shared;
133
+ const rt = g[SHARED];
134
+ delete g[SHARED];
135
+ await rt?.then((r) => r.close()).catch(() => {});
136
+ }
137
+
138
+ /**
139
+ * The site's files on disk as repo files: every `.json` under the collection folders,
140
+ * plus Site Settings, under the site's `repoPath`. What the in-memory GitHub's `main`
141
+ * starts as.
142
+ */
143
+ export async function siteFiles(site: Pick<ResolvedSite, 'root' | 'collections' | 'repoPath'>): Promise<FileWrite[]> {
144
+ const prefix = repoPrefix(site);
145
+ const out: FileWrite[] = [];
146
+ const rel = (abs: string) => relative(site.root, abs).split(sep).join('/');
147
+ for (const c of Object.values(site.collections)) {
148
+ if (c.kind === 'settings') continue; // its one file, below
149
+ let names: string[] = [];
150
+ try {
151
+ names = (await readdir(c.dir, { recursive: true })) as string[];
152
+ } catch (error) {
153
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
154
+ }
155
+ for (const name of names.sort()) {
156
+ if (!name.endsWith('.json')) continue;
157
+ const abs = join(c.dir, name);
158
+ out.push({ path: `${prefix}${rel(abs)}`, contents: await readFile(abs, 'utf8') });
159
+ }
160
+ }
161
+ const settings = await readFile(join(site.root, SITE_SETTINGS_FILE), 'utf8').catch(() => null);
162
+ if (settings !== null && !out.some((f) => f.path === `${prefix}${SITE_SETTINGS_FILE}`)) out.push({ path: `${prefix}${SITE_SETTINGS_FILE}`, contents: settings });
163
+ return out;
164
+ }