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,486 @@
1
+ /**
2
+ * The submissions store (M6 Decision 7): one JSON object per submission, its files
3
+ * beside it, behind one interface with three backends.
4
+ *
5
+ * - **Production** (`NODE_ENV=production`): the site's own submissions bucket,
6
+ * `SUBMISSIONS_S3_BUCKET` with `SUBMISSIONS_S3_ACCESS_KEY_ID` and
7
+ * `SUBMISSIONS_S3_SECRET_ACCESS_KEY`, at the media bucket's `S3_ENDPOINT` and
8
+ * `S3_REGION`. A **second** bucket: imgproxy holds the media bucket's keys and never
9
+ * gets these. **The production server never falls back to disk**: with any of them
10
+ * unset, the store is `unset` (the endpoint's 502, the inbox's sentence).
11
+ * - **Everywhere else**: `.caracms/submissions/` under the site's root (gitignored),
12
+ * **even with the bucket variables set**, so a laptop never lands a test enquiry in
13
+ * the client's inbox, nor copies their visitors' data down.
14
+ * - **`CARACMS_SUBMISSIONS=memory`**: in memory, one per process, for tests. Refused in
15
+ * production, as `CARACMS_MEDIA=memory` is.
16
+ *
17
+ * The data model is the template's (`forms.md` → Data model), `v: 1` without `onSubmit`:
18
+ * the key `<id>.json`, the files `<id>/<n>-<safe name>`. Ids sort in received order, so
19
+ * newest-first is a key sort; a page reads its own objects. `create()` writes the files,
20
+ * then the JSON, and removes what it wrote if anything fails. `delete()` removes the
21
+ * files, then the JSON, so a failure part-way leaves the submission listed. Every id is
22
+ * checked against the id pattern, and every stored key against `isFileKey`, before it
23
+ * becomes a key or a path.
24
+ *
25
+ * **The unread count** (Decision 9) is kept per store in this process: built on first
26
+ * use from every object, then kept by every write, which all go through this server
27
+ * (one replica). So the documents list and every inbox response carry it without
28
+ * reading the bucket each time.
29
+ */
30
+ import { createHash, randomInt } from 'node:crypto';
31
+ import { mkdir, readdir, readFile, rmdir, unlink, writeFile } from 'node:fs/promises';
32
+ import { dirname, join, resolve, sep } from 'node:path';
33
+ import { AwsClient } from 'aws4fetch';
34
+ import { isSubmissionId, shownFileName } from './rules.ts';
35
+
36
+ type Env = Record<string, string | undefined>;
37
+
38
+ export interface StoredFile {
39
+ /** The visitor's own name, for display only (control and bidi characters removed). */
40
+ name: string;
41
+ size: number;
42
+ /** The declared type, possibly blank. Never trusted: a download's type comes from the key. */
43
+ type: string;
44
+ /** `<id>/1-Site-plan.pdf`: where the bytes are. */
45
+ key: string;
46
+ }
47
+
48
+ export interface EmailOutcome {
49
+ status: 'sent' | 'failed' | 'skipped';
50
+ at: string;
51
+ to: string[];
52
+ /** Resend's message on failure, or why it was skipped. */
53
+ error?: string;
54
+ }
55
+
56
+ /** `tina-astro-template/docs/forms.md` → Data model, version 1, without `onSubmit`. */
57
+ export interface StoredSubmission {
58
+ v: 1;
59
+ id: string;
60
+ /** A form id from the config, never the raw hidden field. */
61
+ form: string;
62
+ /** ISO 8601 UTC. */
63
+ receivedAt: string;
64
+ /** Each field's trimmed value by name (`''` for an optional one left blank); not its files. */
65
+ fields: Record<string, string>;
66
+ /** The attachments in the order sent; absent when there are none. */
67
+ files?: StoredFile[];
68
+ /** Null: unread. */
69
+ readAt: string | null;
70
+ /** Absent: not attempted yet, or the server died mid-way (the inbox says "unknown"). */
71
+ email?: EmailOutcome;
72
+ }
73
+
74
+ export interface NewFile {
75
+ name: string;
76
+ type: string;
77
+ data: Uint8Array;
78
+ }
79
+
80
+ export interface SubmissionStore {
81
+ create(input: { form: string; receivedAt: string; fields: Record<string, string>; files?: NewFile[] }): Promise<StoredSubmission>;
82
+ get(id: string): Promise<StoredSubmission | null>;
83
+ getFile(id: string, n: number): Promise<{ file: StoredFile; body: Uint8Array } | null>;
84
+ /** Newest first, `limit` (default 50) after `before`; `older` is the cursor for the next page, or null. */
85
+ list(options?: { limit?: number; before?: string }): Promise<{ items: StoredSubmission[]; older: string | null }>;
86
+ /** Rewrite one (last write wins). Null when there's no such submission. */
87
+ update(id: string, patch: Partial<Pick<StoredSubmission, 'readAt' | 'email'>>): Promise<StoredSubmission | null>;
88
+ /** The files, then the JSON. False when there was no such submission. */
89
+ delete(id: string): Promise<boolean>;
90
+ unreadCount(): Promise<number>;
91
+ }
92
+
93
+ export const PAGE_SIZE = 50;
94
+ /** The longest a safe name gets, its extension included. */
95
+ export const SAFE_NAME_MAX = 100;
96
+ const NAME_MAX = 255;
97
+
98
+ /** Where a site's submissions live outside production, relative to its root (gitignored). */
99
+ export const LOCAL_SUBMISSIONS_FOLDER = '.caracms/submissions';
100
+
101
+ export const SUBMISSIONS_ENV = ['SUBMISSIONS_S3_BUCKET', 'SUBMISSIONS_S3_ACCESS_KEY_ID', 'SUBMISSIONS_S3_SECRET_ACCESS_KEY'] as const;
102
+ const SHARED_ENV = ['S3_ENDPOINT', 'S3_REGION'] as const;
103
+
104
+ export const SUBMISSIONS_MEMORY_IN_PRODUCTION = 'CARACMS_SUBMISSIONS=memory is for tests; a production server never keeps submissions in memory.';
105
+
106
+ /** A visitor's file name as kept for display: control characters and bidi overrides removed, trimmed, at most 255 characters. */
107
+ export function cleanFileName(name: unknown): string {
108
+ return String(name ?? '')
109
+ .replace(/[\u0000-\u001f\u007f-\u009f‎‏‪-‮⁦-⁩]/g, '')
110
+ .trim()
111
+ .slice(0, NAME_MAX);
112
+ }
113
+
114
+ /** A name's extension, lower case, no dot (`Plan.PDF` → `pdf`); '' when it has none. */
115
+ export function fileExtension(name: string): string {
116
+ const base = shownFileName(name);
117
+ const dot = base.lastIndexOf('.');
118
+ return dot === -1 ? '' : base.slice(dot + 1).toLowerCase();
119
+ }
120
+
121
+ /** The name a file is stored under: the basename, anything but letters, digits, `.`, `-` and `_` turned to `-`, at most 100 characters, the extension kept. */
122
+ export function safeFileName(name: string): string {
123
+ const base = shownFileName(name).replace(/[^A-Za-z0-9._-]/g, '-') || 'file';
124
+ if (base.length <= SAFE_NAME_MAX) return base;
125
+ const dot = base.lastIndexOf('.');
126
+ const ext = dot > 0 && base.length - dot <= 20 ? base.slice(dot) : '';
127
+ return base.slice(0, SAFE_NAME_MAX - ext.length) + ext;
128
+ }
129
+
130
+ /** `<id>/<n>-<safe name>`, n from 1. The `<n>-` prefix means no segment can be `.` or `..`. */
131
+ export const fileKey = (id: string, n: number, name: string): string => `${id}/${n}-${safeFileName(name)}`;
132
+
133
+ /** Is this a key `create()` would have made for this submission? Checked before every read or delete. */
134
+ export function isFileKey(id: string, key: unknown): key is string {
135
+ return isSubmissionId(id) && typeof key === 'string' && key.startsWith(`${id}/`) && /^\d{1,3}-[A-Za-z0-9._-]{1,100}$/.test(key.slice(id.length + 1));
136
+ }
137
+
138
+ /** `2026-09-25T09:14:02.123Z` → `2026-09-25T09-14-02-123Z_k3f9a2`. */
139
+ export function newSubmissionId(receivedAt: string): string {
140
+ const stamp = new Date(receivedAt);
141
+ if (Number.isNaN(stamp.getTime())) throw new Error(`submissions: receivedAt is not a date: ${receivedAt}`);
142
+ return `${stamp.toISOString().replace(/[:.]/g, '-')}_${randomInt(0, 36 ** 6).toString(36).padStart(6, '0')}`;
143
+ }
144
+
145
+ // ── Backends ─────────────────────────────────────────────────────────────────
146
+
147
+ /** What a backend does: objects by key, and the submissions' ids (the top-level `<id>.json` keys). */
148
+ export interface Backend {
149
+ put(key: string, body: Uint8Array, contentType: string): Promise<void>;
150
+ /** Null when there's no such object. */
151
+ get(key: string): Promise<Uint8Array | null>;
152
+ /** Removing a key that isn't there is fine. */
153
+ remove(key: string): Promise<void>;
154
+ ids(): Promise<string[]>;
155
+ /** Tidy an emptied `<id>/` (the folder backend). */
156
+ removeFolder?(id: string): Promise<void>;
157
+ }
158
+
159
+ /** In memory: one object map. `failOn` makes a test's chosen call fail (a bucket that refuses part-way). */
160
+ export class MemoryBackend implements Backend {
161
+ readonly objects = new Map<string, Uint8Array>();
162
+ /** Every call, in order (`put:<key>`, `get:<key>`, `remove:<key>`, `ids`). */
163
+ readonly calls: string[] = [];
164
+ failOn: ((call: string) => boolean) | null = null;
165
+ private check(call: string) {
166
+ this.calls.push(call);
167
+ if (this.failOn?.(call)) throw new Error(`memory submissions: ${call} failed (a test asked)`);
168
+ }
169
+ async put(key: string, body: Uint8Array, _contentType?: string) {
170
+ this.check(`put:${key}`);
171
+ this.objects.set(key, new Uint8Array(body));
172
+ }
173
+ async get(key: string) {
174
+ this.check(`get:${key}`);
175
+ const body = this.objects.get(key);
176
+ return body ? new Uint8Array(body) : null;
177
+ }
178
+ async remove(key: string) {
179
+ this.check(`remove:${key}`);
180
+ this.objects.delete(key);
181
+ }
182
+ async ids() {
183
+ this.check('ids');
184
+ return [...this.objects.keys()].filter((k) => !k.includes('/') && k.endsWith('.json')).map((k) => k.slice(0, -5));
185
+ }
186
+ clear() {
187
+ this.objects.clear();
188
+ this.calls.length = 0;
189
+ this.failOn = null;
190
+ }
191
+ }
192
+
193
+ /** The folder: `<dir>/<id>.json`, its files in `<dir>/<id>/`. A key that would land outside `dir` is refused too. */
194
+ export function folderBackend(dir: string): Backend {
195
+ const root = resolve(dir);
196
+ const pathOf = (key: string) => {
197
+ const target = resolve(root, ...key.split('/'));
198
+ if (!target.startsWith(root + sep)) throw new Error(`submissions: a key outside the store: ${key}`);
199
+ return target;
200
+ };
201
+ const missing = (error: unknown) => (error as NodeJS.ErrnoException)?.code === 'ENOENT';
202
+ return {
203
+ async put(key, body) {
204
+ const file = pathOf(key);
205
+ await mkdir(dirname(file), { recursive: true });
206
+ await writeFile(file, body);
207
+ },
208
+ async get(key) {
209
+ try {
210
+ return new Uint8Array(await readFile(pathOf(key)));
211
+ } catch (error) {
212
+ if (missing(error)) return null;
213
+ throw error;
214
+ }
215
+ },
216
+ async remove(key) {
217
+ await unlink(pathOf(key)).catch((error: unknown) => {
218
+ if (!missing(error)) throw error;
219
+ });
220
+ },
221
+ async ids() {
222
+ try {
223
+ return (await readdir(root)).filter((n) => n.endsWith('.json')).map((n) => n.slice(0, -5));
224
+ } catch (error) {
225
+ if (missing(error)) return [];
226
+ throw error;
227
+ }
228
+ },
229
+ async removeFolder(id) {
230
+ await rmdir(join(root, id)).catch(() => {});
231
+ },
232
+ };
233
+ }
234
+
235
+ export interface SubmissionsBucket {
236
+ endpoint: string;
237
+ region: string;
238
+ bucket: string;
239
+ accessKeyId: string;
240
+ secretAccessKey: string;
241
+ }
242
+
243
+ const S3_TIMEOUT_MS = 20_000;
244
+
245
+ /** The submissions bucket over S3's REST API, signed with aws4fetch (as the media bucket is), path-style. */
246
+ export function bucketBackend(settings: SubmissionsBucket, fetchImpl: typeof fetch = (...args) => globalThis.fetch(...args)): Backend {
247
+ const client = new AwsClient({ accessKeyId: settings.accessKeyId, secretAccessKey: settings.secretAccessKey, service: 's3', region: settings.region });
248
+ const base = `${settings.endpoint}/${encodeURIComponent(settings.bucket)}`;
249
+ const url = (key: string) => `${base}/${key.split('/').map(encodeURIComponent).join('/')}`;
250
+ const EMPTY = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
251
+ async function call(method: string, target: string, init: { headers?: Record<string, string>; body?: Uint8Array } = {}) {
252
+ const request = await client.sign(target, { method, headers: { 'X-Amz-Content-Sha256': init.body ? createHash('sha256').update(init.body).digest('hex') : EMPTY, ...init.headers }, body: init.body as BodyInit | undefined });
253
+ return fetchImpl(request, { signal: AbortSignal.timeout(S3_TIMEOUT_MS) });
254
+ }
255
+ const refused = async (what: string, res: Response) => new Error(`submissions bucket: ${what}: ${res.status} ${(await res.text().catch(() => '')).slice(0, 300)}`);
256
+ return {
257
+ async put(key, body, contentType) {
258
+ const res = await call('PUT', url(key), { headers: { 'Content-Type': contentType }, body });
259
+ if (!res.ok) throw await refused(`put ${key}`, res);
260
+ await res.body?.cancel().catch(() => {});
261
+ },
262
+ async get(key) {
263
+ const res = await call('GET', url(key));
264
+ if (res.status === 404) {
265
+ await res.body?.cancel().catch(() => {});
266
+ return null;
267
+ }
268
+ if (!res.ok) throw await refused(`get ${key}`, res);
269
+ return new Uint8Array(await res.arrayBuffer());
270
+ },
271
+ async remove(key) {
272
+ const res = await call('DELETE', url(key));
273
+ if (!res.ok && res.status !== 404) throw await refused(`delete ${key}`, res);
274
+ await res.body?.cancel().catch(() => {});
275
+ },
276
+ async ids() {
277
+ // `delimiter=/`: a submission's files come back as one CommonPrefix each, never as keys.
278
+ const ids: string[] = [];
279
+ let token: string | null = null;
280
+ for (let page = 0; page < 1000; page++) {
281
+ const query = new URLSearchParams({ 'list-type': '2', delimiter: '/' });
282
+ if (token) query.set('continuation-token', token);
283
+ const res = await call('GET', `${base}?${query}`);
284
+ const text = await res.text();
285
+ if (!res.ok) throw new Error(`submissions bucket: list: ${res.status} ${text.slice(0, 300)}`);
286
+ for (const m of text.matchAll(/<Contents>[\s\S]*?<Key>([^<]*)<\/Key>[\s\S]*?<\/Contents>/g)) {
287
+ const key = m[1]!.replace(/&amp;/g, '&');
288
+ if (!key.includes('/') && key.endsWith('.json')) ids.push(key.slice(0, -5));
289
+ }
290
+ const next = /<IsTruncated>true<\/IsTruncated>/.test(text) ? (/<NextContinuationToken>([^<]*)<\/NextContinuationToken>/.exec(text)?.[1] ?? null) : null;
291
+ if (!next || next === token) break;
292
+ token = next;
293
+ }
294
+ return ids;
295
+ },
296
+ };
297
+ }
298
+
299
+ // ── The store over a backend ─────────────────────────────────────────────────
300
+
301
+ const encoder = new TextEncoder();
302
+ const decoder = new TextDecoder();
303
+ const jsonKey = (id: string) => `${id}.json`;
304
+
305
+ export function storeOver(backend: Backend, options: { removeFolder?: boolean } = {}): SubmissionStore {
306
+ /** Unread ids, built on first use (`unreadCount`), then kept by every write here. */
307
+ let unread: Promise<Set<string>> | null = null;
308
+ const track = (fn: (set: Set<string>) => void) => {
309
+ // A rejection handler on the chain: if the first listing fails, `unreadCount` resets and rethrows to
310
+ // its own caller; this chain must not also end unhandled. The count rebuilds on the next read.
311
+ if (unread) {
312
+ const next = unread.then((set) => (fn(set), set));
313
+ next.catch(() => {});
314
+ unread = next;
315
+ }
316
+ };
317
+
318
+ async function get(id: string): Promise<StoredSubmission | null> {
319
+ if (!isSubmissionId(id)) return null;
320
+ const body = await backend.get(jsonKey(id));
321
+ if (!body) return null;
322
+ const value = JSON.parse(decoder.decode(body)) as StoredSubmission;
323
+ return value && typeof value === 'object' && value.id === id ? value : null;
324
+ }
325
+ const put = (record: StoredSubmission) => backend.put(jsonKey(record.id), encoder.encode(`${JSON.stringify(record, null, 2)}\n`), 'application/json');
326
+ /** Remove every key, each tried; throws the first failure after. */
327
+ async function removeAll(keys: string[]) {
328
+ let failure: unknown = null;
329
+ for (const key of keys) {
330
+ try {
331
+ await backend.remove(key);
332
+ } catch (error) {
333
+ failure ??= error;
334
+ }
335
+ }
336
+ if (failure) throw failure;
337
+ }
338
+ /** Read a list of ids, a few at a time. A submission deleted meanwhile is skipped. */
339
+ async function readAll(ids: string[]): Promise<StoredSubmission[]> {
340
+ const out: (StoredSubmission | null)[] = new Array(ids.length).fill(null);
341
+ let next = 0;
342
+ await Promise.all(
343
+ Array.from({ length: Math.min(16, ids.length) }, async () => {
344
+ while (next < ids.length) {
345
+ const i = next++;
346
+ out[i] = await get(ids[i]!);
347
+ }
348
+ }),
349
+ );
350
+ return out.filter((s): s is StoredSubmission => s !== null);
351
+ }
352
+
353
+ return {
354
+ async create({ form, receivedAt, fields, files }) {
355
+ const id = newSubmissionId(receivedAt);
356
+ const stored: StoredFile[] = (files ?? []).map((f, i) => ({ name: f.name, size: f.data.byteLength, type: f.type, key: fileKey(id, i + 1, f.name) }));
357
+ const record: StoredSubmission = { v: 1, id, form, receivedAt, fields, ...(stored.length ? { files: stored } : {}), readAt: null };
358
+ const written: string[] = [];
359
+ try {
360
+ // Files first, then the JSON: a submission is never listed with a file missing.
361
+ for (const [i, file] of stored.entries()) {
362
+ await backend.put(file.key, files![i]!.data, 'application/octet-stream');
363
+ written.push(file.key);
364
+ }
365
+ await put(record);
366
+ } catch (error) {
367
+ // Nothing is kept: what was written goes (best effort; the first failure is the one reported).
368
+ await removeAll(written).catch(() => {});
369
+ if (stored.length && options.removeFolder !== false) await backend.removeFolder?.(id).catch(() => {});
370
+ throw error;
371
+ }
372
+ track((set) => set.add(id));
373
+ return record;
374
+ },
375
+ get,
376
+ async getFile(id, n) {
377
+ const record = await get(id);
378
+ const file = Number.isInteger(n) && n >= 1 ? record?.files?.[n - 1] : undefined;
379
+ // Checked against the id asked for, not the JSON's own: a tampered key is never read.
380
+ if (!record || !file || !isFileKey(id, file.key)) return null;
381
+ const body = await backend.get(file.key);
382
+ return body ? { file, body } : null;
383
+ },
384
+ async list(opts = {}) {
385
+ const limit = Math.max(1, Math.min(500, opts.limit ?? PAGE_SIZE));
386
+ const sorted = (await backend.ids()).filter(isSubmissionId).sort().reverse();
387
+ const after = opts.before ? sorted.filter((id) => id < opts.before!) : sorted;
388
+ const page = after.slice(0, limit);
389
+ return { items: await readAll(page), older: after.length > limit ? page[page.length - 1]! : null };
390
+ },
391
+ async update(id, patch) {
392
+ const current = await get(id);
393
+ if (!current) return null;
394
+ const next: StoredSubmission = { ...current, ...patch, id: current.id, v: current.v };
395
+ await put(next);
396
+ track((set) => (next.readAt === null ? set.add(id) : set.delete(id)));
397
+ return next;
398
+ },
399
+ async delete(id) {
400
+ const record = await get(id);
401
+ if (!record) return false;
402
+ const keys = (record.files ?? []).map((f) => f.key).filter((key) => isFileKey(id, key));
403
+ // The files first: a failure stops here, the submission (the way to its files) still listed.
404
+ await removeAll(keys);
405
+ await backend.remove(jsonKey(id));
406
+ track((set) => set.delete(id));
407
+ if (keys.length) await backend.removeFolder?.(id).catch(() => {});
408
+ return true;
409
+ },
410
+ async unreadCount() {
411
+ unread ??= (async () => {
412
+ const all = await readAll((await backend.ids()).filter(isSubmissionId));
413
+ return new Set(all.filter((s) => s.readAt === null).map((s) => s.id));
414
+ })().catch((error: unknown) => {
415
+ unread = null;
416
+ throw error;
417
+ });
418
+ return (await unread).size;
419
+ },
420
+ };
421
+ }
422
+
423
+ // ── Which store ──────────────────────────────────────────────────────────────
424
+
425
+ export type SubmissionsConfig =
426
+ | { kind: 'memory' }
427
+ | { kind: 'folder'; dir: string }
428
+ | { kind: 'bucket'; bucket: SubmissionsBucket }
429
+ /** The production server without its bucket: `message` names what's missing (the endpoint's log, the inbox's sentence). */
430
+ | { kind: 'unset'; message: string; missing: string[] };
431
+
432
+ const list = (names: readonly string[]) => (names.length <= 1 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`);
433
+
434
+ /** Whether this is a production server, for the store: `NODE_ENV=production` (Decision 7). */
435
+ export const isProductionEnv = (env: Env): boolean => env['NODE_ENV'] === 'production';
436
+
437
+ /** Decision 7's rule, from the environment. Read on each call (cheap). */
438
+ export function readSubmissionsConfig(root: string, env: Env = process.env, production = isProductionEnv(env)): SubmissionsConfig {
439
+ const get = (name: string) => env[name]?.trim() || '';
440
+ const mode = get('CARACMS_SUBMISSIONS');
441
+ if (mode === 'memory') return production ? { kind: 'unset', message: SUBMISSIONS_MEMORY_IN_PRODUCTION, missing: [] } : { kind: 'memory' };
442
+ if (mode) return { kind: 'unset', message: `CARACMS_SUBMISSIONS must be "memory" or unset (got "${mode}").`, missing: [] };
443
+ if (!production) return { kind: 'folder', dir: join(root, LOCAL_SUBMISSIONS_FOLDER) };
444
+ const missing = [...SUBMISSIONS_ENV, ...SHARED_ENV].filter((name) => !get(name));
445
+ if (missing.length) {
446
+ return { kind: 'unset', message: `Form submissions aren't set up on this server: set ${list(missing)} to the site's submissions bucket.`, missing };
447
+ }
448
+ return {
449
+ kind: 'bucket',
450
+ bucket: {
451
+ bucket: get('SUBMISSIONS_S3_BUCKET'),
452
+ endpoint: get('S3_ENDPOINT').replace(/\/+$/, ''),
453
+ region: get('S3_REGION'),
454
+ accessKeyId: get('SUBMISSIONS_S3_ACCESS_KEY_ID'),
455
+ secretAccessKey: get('SUBMISSIONS_S3_SECRET_ACCESS_KEY'),
456
+ },
457
+ };
458
+ }
459
+
460
+ const SHARED = Symbol.for('caracms.submissions');
461
+ type Shared = { [SHARED]?: { stores: Map<string, SubmissionStore>; memory: MemoryBackend } };
462
+ const shared = () => ((globalThis as Shared)[SHARED] ??= { stores: new Map(), memory: new MemoryBackend() });
463
+
464
+ /** The in-memory backend behind `CARACMS_SUBMISSIONS=memory`, one per process (tests seed and inspect it). */
465
+ export const memorySubmissions = (): MemoryBackend => shared().memory;
466
+
467
+ /** Forget every store and empty the memory backend (tests). */
468
+ export function resetSubmissions(): void {
469
+ const s = shared();
470
+ s.stores.clear();
471
+ s.memory.clear();
472
+ }
473
+
474
+ /** The store for this config, one per process per backend (so the unread count is kept), or the `unset` config itself. */
475
+ export function submissionStore(config: SubmissionsConfig, fetchImpl?: typeof fetch): SubmissionStore | Extract<SubmissionsConfig, { kind: 'unset' }> {
476
+ if (config.kind === 'unset') return config;
477
+ const s = shared();
478
+ const id = config.kind === 'memory' ? 'memory' : config.kind === 'folder' ? `folder:${config.dir}` : `bucket:${config.bucket.endpoint}:${config.bucket.bucket}:${config.bucket.accessKeyId}`;
479
+ let store = s.stores.get(id);
480
+ if (!store) {
481
+ const backend = config.kind === 'memory' ? s.memory : config.kind === 'folder' ? folderBackend(config.dir) : bucketBackend(config.bucket, fetchImpl);
482
+ store = storeOver(backend);
483
+ s.stores.set(id, store);
484
+ }
485
+ return store;
486
+ }
package/src/head.ts ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The page's head (M6 Decisions 15 and 16; Contracts → The head): what `Head.astro`
3
+ * writes, worked out here so it can be tested without a render.
4
+ *
5
+ * - **Title:** the page's `seo.title` as written; otherwise, for the home page, Site
6
+ * Settings' `seo.title`, then the brand's name; otherwise `<document title> · <brand name>`,
7
+ * where ` · ` is the config's `titleSeparator` (default ` · `, M8 trial round 2). With the
8
+ * config's `titleFallback: 'site'` (M9), every page falls back as the home page does.
9
+ * - **Description and share image** fall back from the page's `seo` to Site Settings'.
10
+ * - **Canonical link and `og:url`** only with the site's address (`SITE_URL`), and never
11
+ * in a preview.
12
+ * - **The share image** is a signed imgproxy `rs:fill` at 1200 × 630 with the focal
13
+ * point's gravity (M5b's builder). No media set up: no `og:image`.
14
+ * - **The Google tag** is an ID, never a script: `GTM-…` loads Tag Manager, `G-…` loads
15
+ * gtag.js, anything else nothing. Only in a production build, never in a preview.
16
+ */
17
+ import { DEFAULT_TITLE_SEPARATOR, type TitleFallback } from './config.ts';
18
+ import { parseSettingsBrand, type PageDocument } from './document.ts';
19
+ import { imageUrl, isResizable, type MediaConfig } from './media.ts';
20
+ import { googleTagKind, parseGoogleTag, parseSeo, SHARE_IMAGE, type Seo, type SeoImage } from './seo.ts';
21
+
22
+ export interface HeadInput {
23
+ document: PageDocument;
24
+ /** Site Settings' text, from the file the build or the preview renders from; null when there's none. */
25
+ settingsText: string | null;
26
+ /** The page's path on the site (`/`, `/about`); null in a preview without one. */
27
+ path: string | null;
28
+ /** The site's address without a trailing slash, or null (no canonical, no `og:url`). */
29
+ siteUrl: string | null;
30
+ media: MediaConfig;
31
+ /** A production build's public page: the Google tag may go in. */
32
+ tagAllowed: boolean;
33
+ /** Posts and tags, Decision 16: the page of a paged listing; from 2, the title says " — page N". 1 by default. */
34
+ listPage?: number;
35
+ /** The config's `titleSeparator` (ResolvedSite), spaces included; ` · ` when left out. */
36
+ titleSeparator?: string;
37
+ /** The config's `titleFallback` (ResolvedSite); `'page-and-brand'` when left out. */
38
+ titleFallback?: TitleFallback;
39
+ }
40
+
41
+ export interface Head {
42
+ title: string;
43
+ description: string | null;
44
+ canonical: string | null;
45
+ image: { url: string; width?: number; height?: number } | null;
46
+ twitterCard: 'summary_large_image' | 'summary';
47
+ googleTag: { kind: 'gtm' | 'gtag'; id: string } | null;
48
+ }
49
+
50
+ function settingsOf(text: string | null): { brandName: string; seo: Seo; googleTag: string } {
51
+ let raw: Record<string, unknown> = {};
52
+ try {
53
+ const parsed = text ? (JSON.parse(text) as unknown) : null;
54
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) raw = parsed as Record<string, unknown>;
55
+ } catch {
56
+ /* a broken Site Settings file: no defaults */
57
+ }
58
+ return { brandName: parseSettingsBrand(raw.brand).name, seo: parseSeo(raw.seo), googleTag: parseGoogleTag(raw.googleTag) };
59
+ }
60
+
61
+ /** The share image's URL: `rs:fill` 1200 × 630, the focal point's gravity. An SVG or a GIF passes through. */
62
+ export function shareImage(image: SeoImage, media: MediaConfig): Head['image'] {
63
+ if (media.kind !== 'ready') return null;
64
+ const url = imageUrl(media.settings, image.key, { width: SHARE_IMAGE.width, height: SHARE_IMAGE.height, resize: 'fill', focal: image.focal ?? null });
65
+ return isResizable(image.key) ? { url, width: SHARE_IMAGE.width, height: SHARE_IMAGE.height } : { url };
66
+ }
67
+
68
+ /** `/about/` → `/about`; `/` stays. */
69
+ const cleanPath = (path: string) => (path.length > 1 ? path.replace(/\/+$/, '') : path);
70
+
71
+ export function headOf(input: HeadInput): Head {
72
+ const { document } = input;
73
+ const settings = settingsOf(input.settingsText);
74
+ const own = document.kind === 'shared' ? {} : parseSeo((document as { seo?: Seo }).seo);
75
+ const home = input.path !== null && cleanPath(input.path) === '/';
76
+ const docTitle = document.title.trim();
77
+ // Decision 16: " — page N" straight after the page's own part, before the brand, so ten pages don't share one title.
78
+ const paged = (text: string) => (input.listPage && input.listPage > 1 && text ? `${text} — page ${input.listPage}` : text);
79
+ const siteWide = home || input.titleFallback === 'site';
80
+ const fallback = siteWide ? paged(settings.seo.title || settings.brandName || docTitle) : [paged(docTitle), settings.brandName].filter(Boolean).join(input.titleSeparator ?? DEFAULT_TITLE_SEPARATOR);
81
+ const title = paged(own.title ?? '') || fallback;
82
+ const imageValue = own.image ?? settings.seo.image ?? null;
83
+ const image = imageValue ? shareImage(imageValue, input.media) : null;
84
+ const kind = input.tagAllowed ? googleTagKind(settings.googleTag) : null;
85
+ return {
86
+ title,
87
+ description: own.description ?? settings.seo.description ?? null,
88
+ canonical: input.siteUrl && input.path !== null ? `${input.siteUrl}${cleanPath(input.path)}` : null,
89
+ image,
90
+ twitterCard: image ? 'summary_large_image' : 'summary',
91
+ googleTag: kind ? { kind, id: settings.googleTag } : null,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * The tag's snippet. The ID has passed `googleTagKind` (letters, digits and one `-`), so
97
+ * it's safe in the script's text; JSON-encoded anyway.
98
+ */
99
+ export function googleTagSnippet(tag: NonNullable<Head['googleTag']>): string {
100
+ const id = JSON.stringify(tag.id);
101
+ if (tag.kind === 'gtm') {
102
+ return `<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s);j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer',${id});</script>`;
103
+ }
104
+ return `<script async src="https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(tag.id)}"></script><script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config',${id});</script>`;
105
+ }
106
+
107
+ /**
108
+ * PageProps' `path` (MEC trial item 2): the document's address, the same in a public render
109
+ * and in its preview. A preview's is `previewUrl`, its document's URL (content.ts →
110
+ * documentUrl; null when it has no page). A public render's is the address it's served at,
111
+ * decoded, with no trailing slash, and a paged listing's `/page/N` taken off (`listPage` > 1):
112
+ * `/journal/page/2` is `/journal`'s page 2.
113
+ */
114
+ export function documentPath(input: { preview: boolean; previewUrl: string | null; pathname: string; listPage: number }): string | null {
115
+ if (input.preview) return input.previewUrl === null ? null : cleanPath(input.previewUrl) || '/';
116
+ let path = input.pathname;
117
+ try {
118
+ path = decodeURI(path);
119
+ } catch {
120
+ // Not valid percent-encoding: kept as it came.
121
+ }
122
+ path = cleanPath(path) || '/';
123
+ if (input.listPage > 1) path = path.replace(new RegExp(`/page/${input.listPage}$`), '') || '/';
124
+ return path;
125
+ }