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,130 @@
1
+ /**
2
+ * The form endpoint's rate limit (M6 Decision 5): 5 hits per visitor IP per 10 minutes.
3
+ *
4
+ * - **In Redis** whenever the server has `REDIS_URL` (live mode always does in
5
+ * production), as expiring counters, so the count survives a deploy. One window per
6
+ * IP starts with its first hit (sign-in's limits do the same, live/auth/links.ts).
7
+ * - **In memory** without it (local mode, tests).
8
+ * - **Redis out fails open**, with a log line: the limit is spam control, and a
9
+ * visitor's message matters more than a counter (the honeypot and Turnstile stand).
10
+ *
11
+ * `peek` only looks (the check before a large body is read); `hit` counts one and says
12
+ * whether that one is over the limit.
13
+ *
14
+ * **One limiter per use** (M6c Decision 6): each instance has its own key prefix, limit
15
+ * and window (`LimitSettings`), so the public file route's downloads (files-route.ts,
16
+ * `caracms:files:rl:`) never use up a visitor's contact-form allowance. The forms' own
17
+ * settings are `FORM_LIMIT`, unchanged.
18
+ */
19
+ import { createHash } from 'node:crypto';
20
+ import { connectRedis, type RedisClient } from '../live/redis.ts';
21
+
22
+ export const RATE_LIMIT = { max: 5, windowMs: 10 * 60 * 1000 } as const;
23
+
24
+ /** One limiter's settings: its Redis key prefix, its limit and window, and what its log line calls a hit. */
25
+ export interface LimitSettings {
26
+ /** Ends in `:`; the hashed IP follows. */
27
+ prefix: string;
28
+ max: number;
29
+ windowMs: number;
30
+ /** The log line's words: `forms` / `submission`. */
31
+ area: string;
32
+ hitName: string;
33
+ }
34
+
35
+ /** The forms' limiter (M6 Decision 5). */
36
+ export const FORM_LIMIT: LimitSettings = { prefix: 'caracms:forms:rl:', ...RATE_LIMIT, area: 'forms', hitName: 'submission' };
37
+
38
+ export interface RateLimiter {
39
+ /** Already at the limit? Records nothing. */
40
+ peek(ip: string): Promise<boolean>;
41
+ /** Count a hit: true when this one is over the limit. */
42
+ hit(ip: string): Promise<boolean>;
43
+ }
44
+
45
+ /** The forms' name for it, kept. */
46
+ export type FormRateLimiter = RateLimiter;
47
+
48
+ export function memoryLimiter(options: { now?: () => number; max?: number; windowMs?: number } = {}): RateLimiter {
49
+ const now = options.now ?? Date.now;
50
+ const max = options.max ?? FORM_LIMIT.max;
51
+ const windowMs = options.windowMs ?? FORM_LIMIT.windowMs;
52
+ const hits = new Map<string, number[]>();
53
+ const recent = (ip: string) => (hits.get(ip) ?? []).filter((t) => t > now() - windowMs);
54
+ return {
55
+ async peek(ip) {
56
+ return recent(ip).length >= max;
57
+ },
58
+ async hit(ip) {
59
+ if (hits.size > 1000) for (const [k] of hits) if (recent(k).length === 0) hits.delete(k);
60
+ const times = recent(ip);
61
+ times.push(now());
62
+ hits.set(ip, times);
63
+ return times.length > max;
64
+ },
65
+ };
66
+ }
67
+
68
+ /** `options` defaults to the forms' settings. */
69
+ export function redisLimiter(redis: RedisClient, log: (line: string) => void = (l) => console.error(l), options: Partial<LimitSettings> = {}): RateLimiter {
70
+ const { prefix, max, windowMs, area, hitName } = { ...FORM_LIMIT, ...options };
71
+ const key = (ip: string) => `${prefix}${createHash('sha256').update(ip).digest('hex')}`;
72
+ const failOpen = (error: unknown) => {
73
+ log(`[caracms] ${area}: couldn't reach Redis for the rate limit, so this ${hitName} wasn't counted: ${error instanceof Error ? error.message : String(error)}`);
74
+ return false;
75
+ };
76
+ return {
77
+ async peek(ip) {
78
+ try {
79
+ return Number((await redis.get(key(ip))) ?? 0) >= max;
80
+ } catch (error) {
81
+ return failOpen(error);
82
+ }
83
+ },
84
+ async hit(ip) {
85
+ try {
86
+ const k = key(ip);
87
+ const count = await redis.incr(k);
88
+ // The first hit starts the window; a key left with no expiry (a crash between the calls) gets one now.
89
+ if (count === 1 || (await redis.pttl(k)) === -1) await redis.pexpire(k, windowMs);
90
+ return count > max;
91
+ } catch (error) {
92
+ return failOpen(error);
93
+ }
94
+ },
95
+ };
96
+ }
97
+
98
+ type Shared = Record<symbol, { url: string | null; limiter: RateLimiter } | undefined>;
99
+
100
+ /**
101
+ * The process's limiter for one use, kept on `globalThis` under `name` (a `Symbol.for`
102
+ * key, so Vite's module copies share it): Redis at `REDIS_URL` when it's set, else memory.
103
+ */
104
+ export function processLimiter(name: string, settings: LimitSettings, env: Record<string, string | undefined> = process.env): RateLimiter {
105
+ const slot = Symbol.for(name);
106
+ const url = env['REDIS_URL']?.trim() || null;
107
+ const g = globalThis as Shared;
108
+ const held = g[slot];
109
+ if (held && held.url === url) return held.limiter;
110
+ const limiter = url ? redisLimiter(connectRedis(url), undefined, settings) : memoryLimiter({ max: settings.max, windowMs: settings.windowMs });
111
+ g[slot] = { url, limiter };
112
+ return limiter;
113
+ }
114
+
115
+ /** Forget one use's process limiter (tests). */
116
+ export function resetProcessLimiter(name: string): void {
117
+ delete (globalThis as Shared)[Symbol.for(name)];
118
+ }
119
+
120
+ const FORMS = 'caracms.formLimiter';
121
+
122
+ /** The forms' process limiter. */
123
+ export function formLimiter(env: Record<string, string | undefined> = process.env): FormRateLimiter {
124
+ return processLimiter(FORMS, FORM_LIMIT, env);
125
+ }
126
+
127
+ /** Forget the forms' process limiter (tests). */
128
+ export function resetFormLimiter(): void {
129
+ resetProcessLimiter(FORMS);
130
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `POST /_caracms/form` (M6 Decision 5): the public forms' endpoint, on demand. Public in
3
+ * every mode: the guard lets it through and the unconfigured 503 exempts it. The rules
4
+ * are endpoint.ts's.
5
+ */
6
+ import type { APIRoute } from 'astro';
7
+ import { site } from 'virtual:caracms/config';
8
+ import { handleForm } from './endpoint.ts';
9
+
10
+ export const prerender = false;
11
+
12
+ export const ALL: APIRoute = (context) =>
13
+ handleForm(context.request, {
14
+ site,
15
+ astroSite: context.site,
16
+ // Astro's getter throws where the adapter gives no address (tests' containers).
17
+ clientAddress: () => {
18
+ try {
19
+ return context.clientAddress;
20
+ } catch {
21
+ return undefined;
22
+ }
23
+ },
24
+ });
@@ -0,0 +1,278 @@
1
+ /**
2
+ * The forms config's rules (M6 Decision 4), and what the endpoint, the email, the
3
+ * inbox and the public form share about a form: the honeypot's name, the hidden
4
+ * fields, the upload limits, the sender's name and the snippet.
5
+ *
6
+ * The template is the rulebook (`tina-astro-template/docs/forms.md` → The forms
7
+ * config, Files: the wire contract); this is its `form-rules.mjs` and the shared parts
8
+ * of `form-field-types.mjs`, with caracms's shape: forms are `defineSite({ forms })`,
9
+ * keyed by id, their fields keyed by name in the form's order.
10
+ *
11
+ * Browser-safe (no imports): the editor's inbox reads `senderName`, `formatBytes` and
12
+ * the id pattern too.
13
+ */
14
+
15
+ /** The six field types (Decision 4). The template's site-own types are deferred. */
16
+ export const FORM_FIELD_TYPES = ['text', 'email', 'tel', 'textarea', 'select', 'files'] as const;
17
+ export type FormFieldType = (typeof FORM_FIELD_TYPES)[number];
18
+
19
+ /** One field of a form, as the site's config writes it (Contracts → The forms config). */
20
+ export interface FormFieldConfig {
21
+ label: string;
22
+ /** Default `text`. */
23
+ type?: FormFieldType;
24
+ /** Required unless this is set: blank and absent are then both fine (stored as `''`). */
25
+ optional?: boolean;
26
+ /** A select's fixed choices; the endpoint accepts only their values. Select only. */
27
+ options?: { value: string; label: string }[];
28
+ /** A line under the field. */
29
+ help?: string;
30
+ /** The browser's hint, and the inbox's sender name (`name`, or `given-name` + `family-name`). */
31
+ autocomplete?: string;
32
+ /** The line under the field when it's blank or wrong; blank uses the type's own wording. */
33
+ error?: string;
34
+ /**
35
+ * The input's hint text inside the box (`placeholder`): text, email, tel and textarea
36
+ * only. It never replaces the label, which stays in the markup for screen readers even
37
+ * when a site hides it. Plain text, trimmed; blank is the same as leaving it out.
38
+ */
39
+ placeholder?: string;
40
+ /**
41
+ * A short fixed text drawn before the input, like "$" on a price: text and tel only.
42
+ * Only for show: it isn't part of what's sent, stored, emailed or shown in the inbox.
43
+ * Plain text, trimmed; blank is the same as leaving it out.
44
+ */
45
+ prefix?: string;
46
+ /** A textarea's height in lines (`rows`), a whole number from 1 to 50. Default 6. Textarea only. */
47
+ rows?: number;
48
+ }
49
+
50
+ /** One form (Contracts → The forms config). Its key is its id: stored on submissions, keys `formRecipients`; never renamed once live. */
51
+ export interface FormConfig {
52
+ /** "Contact": the inbox, the email, Site Settings. */
53
+ label: string;
54
+ /** The email's subject starts with this: `<subject> — <brand name> website`. */
55
+ email: { subject: string };
56
+ /** The fields by name, in the form's order. */
57
+ fields: Record<string, FormFieldConfig>;
58
+ }
59
+
60
+ export interface ResolvedFormField {
61
+ name: string;
62
+ label: string;
63
+ type: FormFieldType;
64
+ optional: boolean;
65
+ options?: { value: string; label: string }[];
66
+ help?: string;
67
+ autocomplete?: string;
68
+ error?: string;
69
+ placeholder?: string;
70
+ prefix?: string;
71
+ rows?: number;
72
+ }
73
+
74
+ /** A form as the package uses it: its fields as a list, in order, each with its type. Plain JSON. */
75
+ export interface ResolvedForm {
76
+ id: string;
77
+ label: string;
78
+ email: { subject: string };
79
+ fields: ResolvedFormField[];
80
+ }
81
+
82
+ /**
83
+ * The honeypot's name (Decision 5): a hidden field only a bot fills in. `fax`, not
84
+ * `company`: a real form can have a Company field (the template's lesson). The form
85
+ * component renders it from here and the endpoint reads it from here.
86
+ */
87
+ export const HONEYPOT = 'fax';
88
+
89
+ /** The form's hidden fields: its id, the no-JS return page, and Turnstile's token. */
90
+ export const HIDDEN_FIELDS = ['form', 'return', 'cf-turnstile-response'] as const;
91
+
92
+ /** Names a form's field can't take. */
93
+ export const RESERVED_FIELD_NAMES: readonly string[] = [HONEYPOT, ...HIDDEN_FIELDS];
94
+
95
+ /** At most this many files on one submission. */
96
+ export const MAX_FILES = 5;
97
+ /** At most this many bytes of files in all, on one submission: 25 MB. */
98
+ export const MAX_FILE_BYTES = 25 * 1024 * 1024;
99
+
100
+ /**
101
+ * The extensions a files field takes, each with the declared types it may come with.
102
+ * **The extension decides the type**: a declared type must be blank,
103
+ * `application/octet-stream` or one listed here. The first is the type a download is
104
+ * served as.
105
+ */
106
+ export const FILE_TYPES: Readonly<Record<string, readonly string[]>> = {
107
+ jpg: ['image/jpeg', 'image/pjpeg'],
108
+ jpeg: ['image/jpeg', 'image/pjpeg'],
109
+ png: ['image/png'],
110
+ webp: ['image/webp'],
111
+ heic: ['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence'],
112
+ pdf: ['application/pdf'],
113
+ doc: ['application/msword'],
114
+ docx: ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
115
+ xls: ['application/vnd.ms-excel'],
116
+ xlsx: ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
117
+ };
118
+
119
+ /** The allowed kinds, as a visitor reads them (the endpoint's refusal names them). */
120
+ export const FILE_KINDS = 'JPEG, PNG, WebP, HEIC, PDF, Word or Excel';
121
+
122
+ /** The file input's `accept`. */
123
+ export const FILE_ACCEPT = Object.keys(FILE_TYPES)
124
+ .map((ext) => `.${ext}`)
125
+ .join(',');
126
+
127
+ /** The types that take a placeholder: the ones a visitor types into. */
128
+ export const PLACEHOLDER_TYPES: readonly FormFieldType[] = ['text', 'email', 'tel', 'textarea'];
129
+ /** The types that take a prefix: a one-line box where a fixed lead-in reads naturally ("$", "+353"). */
130
+ const PREFIX_TYPES: readonly FormFieldType[] = ['text', 'tel'];
131
+
132
+ const FORM_ID = /^[a-z][a-z0-9-]*$/;
133
+ /** A field's name goes into the form's markup and the stored JSON: letters, digits, `_` and `-`. */
134
+ const FIELD_NAME = /^[A-Za-z][A-Za-z0-9_-]*$/;
135
+
136
+ /**
137
+ * Check the site's forms (Decision 4: `forms.md` → The forms config's `checkForms()`
138
+ * list) and resolve them. `fail` throws the sentence, which names the form and the
139
+ * field. Refused: the honeypot's name or a hidden field's, a duplicate, no `email`
140
+ * field, more than one `files` field, a select without options, options on a field
141
+ * that isn't a select, and a type that isn't one of the six.
142
+ */
143
+ export function checkForms(forms: unknown, fail: (message: string) => never): Record<string, ResolvedForm> {
144
+ if (forms === undefined) return {};
145
+ if (!forms || typeof forms !== 'object' || Array.isArray(forms)) fail('forms must be an object of forms by id, like { contact: { label, email, fields } }.');
146
+ const out: Record<string, ResolvedForm> = {};
147
+ for (const [id, raw] of Object.entries(forms as Record<string, unknown>)) {
148
+ const where = `form "${id}"`;
149
+ if (!FORM_ID.test(id)) fail(`${where}: its id must be lowercase letters, digits and hyphens (it's stored on every submission).`);
150
+ const form = (raw ?? {}) as Partial<FormConfig>;
151
+ if (typeof form !== 'object' || Array.isArray(form)) fail(`${where} must be an object: { label, email: { subject }, fields }.`);
152
+ if (typeof form.label !== 'string' || !form.label.trim()) fail(`${where} needs a label (the inbox, the email and Site Settings name it by it).`);
153
+ if (typeof form.email?.subject !== 'string' || !form.email.subject.trim()) fail(`${where} needs email.subject, like "New contact enquiry".`);
154
+ if (!form.fields || typeof form.fields !== 'object' || Array.isArray(form.fields)) fail(`${where} needs fields, an object of fields by name.`);
155
+ const fields: ResolvedFormField[] = [];
156
+ const seen = new Set<string>();
157
+ let filesField: string | null = null;
158
+ for (const [name, rawField] of Object.entries(form.fields!)) {
159
+ const at = `${where}, field "${name}"`;
160
+ if (RESERVED_FIELD_NAMES.includes(name)) {
161
+ fail(`${at}: "${name}" is taken (${name === HONEYPOT ? 'the honeypot, a field only a bot fills in' : "one of the form's hidden fields"}); name the field something else.`);
162
+ }
163
+ if (!FIELD_NAME.test(name)) fail(`${at}: a field's name must start with a letter and hold only letters, digits, "_" and "-".`);
164
+ // Object keys can't repeat, but two spellings of one name would post as one field.
165
+ if (seen.has(name.toLowerCase())) fail(`${at} is listed twice (field names are compared without case).`);
166
+ seen.add(name.toLowerCase());
167
+ const field = (rawField ?? {}) as Partial<FormFieldConfig>;
168
+ if (typeof field.label !== 'string' || !field.label.trim()) fail(`${at} needs a label (what the visitor sees).`);
169
+ const type = field.type ?? 'text';
170
+ if (!(FORM_FIELD_TYPES as readonly string[]).includes(type)) fail(`${at}: type must be one of ${FORM_FIELD_TYPES.join(', ')} (got ${JSON.stringify(field.type)}).`);
171
+ if (type === 'select') {
172
+ if (!Array.isArray(field.options) || field.options.length === 0) fail(`${at}: a select needs options, like [{ value: 'branding', label: 'Branding' }].`);
173
+ const values = new Set<string>();
174
+ for (const option of field.options!) {
175
+ if (typeof option?.value !== 'string' || !option.value || typeof option.label !== 'string' || !option.label) fail(`${at}: every option needs a value and a label.`);
176
+ if (values.has(option.value)) fail(`${at}: the option "${option.value}" is listed twice.`);
177
+ values.add(option.value);
178
+ }
179
+ } else if (field.options !== undefined) {
180
+ fail(`${at}: only a select takes options (this one is ${type}).`);
181
+ }
182
+ if (type === 'files') {
183
+ if (filesField) fail(`${at}: a form has at most one files field ("${filesField}" is the first).`);
184
+ filesField = name;
185
+ }
186
+ if (field.optional !== undefined && typeof field.optional !== 'boolean') fail(`${at}: optional must be true or left out.`);
187
+ for (const key of ['help', 'autocomplete', 'error', 'placeholder', 'prefix'] as const) {
188
+ if (field[key] !== undefined && typeof field[key] !== 'string') fail(`${at}: ${key} must be text.`);
189
+ }
190
+ const placeholder = field.placeholder?.trim() ?? '';
191
+ if (placeholder && !PLACEHOLDER_TYPES.includes(type)) fail(`${at}: a ${type} field has no box to type in, so it can't take a placeholder (only ${PLACEHOLDER_TYPES.join(', ')} can).`);
192
+ const prefix = field.prefix?.trim() ?? '';
193
+ if (prefix && !PREFIX_TYPES.includes(type)) fail(`${at}: only a text or tel field takes a prefix (this one is ${type}).`);
194
+ if (field.rows !== undefined) {
195
+ if (type !== 'textarea') fail(`${at}: only a textarea takes rows (this one is ${type}).`);
196
+ if (!Number.isInteger(field.rows) || field.rows < 1 || field.rows > 50) fail(`${at}: rows must be a whole number from 1 to 50 (got ${JSON.stringify(field.rows)}).`);
197
+ }
198
+ fields.push({
199
+ name,
200
+ label: field.label!.trim(),
201
+ type,
202
+ optional: field.optional === true,
203
+ ...(type === 'select' ? { options: field.options!.map((o) => ({ value: o.value, label: o.label })) } : {}),
204
+ ...(field.help ? { help: field.help } : {}),
205
+ ...(field.autocomplete ? { autocomplete: field.autocomplete } : {}),
206
+ ...(field.error ? { error: field.error } : {}),
207
+ ...(placeholder ? { placeholder } : {}),
208
+ ...(prefix ? { prefix } : {}),
209
+ ...(field.rows !== undefined ? { rows: field.rows } : {}),
210
+ });
211
+ }
212
+ const email = fields.find((f) => f.name === 'email');
213
+ if (!email) fail(`${where} needs a field named "email", with type 'email': the visitor's address, which the email's Reply-To uses.`);
214
+ if (email!.type !== 'email') fail(`${where}, field "email": its type must be 'email' (it's the visitor's address, the email's Reply-To).`);
215
+ out[id] = { id, label: form.label!.trim(), email: { subject: form.email!.subject.trim() }, fields };
216
+ }
217
+ return out;
218
+ }
219
+
220
+ const RETURN_PATH = /^\/[a-z0-9/-]*$/;
221
+
222
+ /** The no-JS return page (Decision 5): a path on this site matching `^/[a-z0-9/-]*$` and not `//…`, else `/`. */
223
+ export function cleanReturn(value: string): string {
224
+ return RETURN_PATH.test(value) && !value.startsWith('//') ? value : '/';
225
+ }
226
+
227
+ /** Whether any form takes files: the endpoint's upload cap is there only then (Decision 5). */
228
+ export const formsAcceptFiles = (forms: Readonly<Record<string, Pick<ResolvedForm, 'fields'>>>): boolean =>
229
+ Object.values(forms).some((form) => form.fields.some((field) => field.type === 'files'));
230
+
231
+ // Deliberately loose: an over-strict pattern refuses real addresses. The real check is the reply.
232
+ export const looksLikeEmail = (value: string): boolean => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
233
+
234
+ /** A size as people read it: "812 bytes", "240 KB", "2.4 MB" (1 KB = 1024 bytes). */
235
+ export function formatBytes(bytes: number): string {
236
+ const n = Number.isFinite(bytes) && bytes > 0 ? bytes : 0;
237
+ if (n < 1024) return `${n} ${n === 1 ? 'byte' : 'bytes'}`;
238
+ if (Math.round(n / 1024) < 1024) return `${Math.round(n / 1024)} KB`;
239
+ const mb = n / 1024 / 1024;
240
+ return `${mb < 10 ? mb.toFixed(1) : Math.round(mb)} MB`;
241
+ }
242
+
243
+ /** The last part of a name, whatever slashes a browser or a bot sent: `../../x.pdf` → `x.pdf`. */
244
+ export const shownFileName = (name: string): string => name.split(/[\\/]/).pop() ?? '';
245
+
246
+ /**
247
+ * A submission's id: the receive time with `:` and `.` swapped for `-`, then six
248
+ * random base-36 characters (`2026-09-25T09-14-02-123Z_k3f9a2`), so ids sort in
249
+ * received order. Every id from a URL is checked against this before it becomes a
250
+ * key or a path (the path-traversal guard).
251
+ */
252
+ export const SUBMISSION_ID = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z_[a-z0-9]{6}$/;
253
+ export const isSubmissionId = (id: unknown): id is string => typeof id === 'string' && SUBMISSION_ID.test(id);
254
+
255
+ /** A submission's address in the editor (the email's link). */
256
+ export const submissionAddress = (id: string): string => `/admin/submissions/${encodeURIComponent(id)}`;
257
+
258
+ /**
259
+ * Who sent it (Decision 9, `forms.md` → The forms config): the field marked
260
+ * `autocomplete: 'name'`, else the `given-name` and `family-name` fields together,
261
+ * else the email address. A form no longer in the config: the address.
262
+ */
263
+ export function senderName(form: Pick<ResolvedForm, 'fields'> | null, fields: Readonly<Record<string, unknown>>): string {
264
+ const value = (name: string | undefined) => (name && typeof fields[name] === 'string' ? (fields[name] as string).trim() : '');
265
+ const by = (hint: string) => form?.fields.find((f) => f.autocomplete === hint)?.name;
266
+ const full = value(by('name'));
267
+ if (full) return full;
268
+ const both = [value(by('given-name')), value(by('family-name'))].filter(Boolean).join(' ');
269
+ if (both) return both;
270
+ return value('email');
271
+ }
272
+
273
+ /** A row's one-line snippet (Decision 9): the form's first `textarea`, its lines joined, cut at 140 characters. '' when there's none. */
274
+ export function submissionSnippet(form: Pick<ResolvedForm, 'fields'> | null, fields: Readonly<Record<string, unknown>>): string {
275
+ const name = form?.fields.find((f) => f.type === 'textarea')?.name;
276
+ const text = name && typeof fields[name] === 'string' ? (fields[name] as string).replace(/\s+/g, ' ').trim() : '';
277
+ return text.length > 140 ? `${text.slice(0, 139).trimEnd()}…` : text;
278
+ }