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,697 @@
1
+ /**
2
+ * Writing a document back to its content file (Decision 7).
3
+ *
4
+ * **The write starts from the raw file as read and applies the editor's changes
5
+ * field by field. A field the editor didn't touch is written back exactly as read,
6
+ * its value and its position; only a touched field takes the schema's form, and a
7
+ * touched block's keys take the schema's order, with unknown keys after in their
8
+ * original order.** A block of a type the schema no longer knows is written back as
9
+ * read. Two-space JSON with a trailing newline; an array of strings on one line.
10
+ *
11
+ * ## The API (Phases 2–4 call this)
12
+ *
13
+ * applyEdits(base: string | null, edited: SiteDocument, site): string
14
+ *
15
+ * - `base` is the content file's text **as the editor read it** when it opened the
16
+ * document (the same text whose `blocks` the editor's `source` indexes point
17
+ * into), or `null` for a new document. Keep it for the whole editing session and
18
+ * pass the same `base` on every write: the rules are stated against the file as
19
+ * read, so writing "the document as edited" over the same base again and again is
20
+ * correct and idempotent (a field changed and changed back is untouched again).
21
+ * Re-reading the file between writes would invalidate the `source` indexes after a
22
+ * reorder. If the file changes on disk meanwhile, the last write wins (M3 →
23
+ * Watch-outs, accepted for local dev).
24
+ * - `edited` is the whole document as the editor has it: `{ title, blocks }`, every
25
+ * block a DocumentBlock (document.ts). What the editor did is read from it:
26
+ * - **a field edit:** the block's `data[field]` differs from the file's parsed
27
+ * value. "Differs" compares parsed forms, so sending a value the file already
28
+ * holds in another spelling (a trailing space, a blank lockup line) is not an edit;
29
+ * - **reorder:** blocks appear in a new order, each keeping its `source`;
30
+ * - **add:** a block with `source: null` (a known type; start from `emptyBlock()`);
31
+ * - **remove:** a source index that no longer appears; **bring back** (Undo):
32
+ * the block reappears with its old `source`, and is written as read.
33
+ * An unknown block's `data` is ignored; it's always copied from the file. A block
34
+ * whose `type` the editor changed is written as a new block.
35
+ * - Returns the file's new text. With nothing touched, that's `base` itself, byte
36
+ * for byte. Throws on a block with no `source` and an unknown type, a `source`
37
+ * that isn't in the base, or a base that isn't a JSON object.
38
+ *
39
+ * Untouched blocks and untouched fields are copied as their exact source text, so
40
+ * a file written by hand in another layout keeps that layout where nothing changed.
41
+ */
42
+ import type { ResolvedCollection, ResolvedSite } from './config.ts';
43
+ import {
44
+ EDITORS_FIELD,
45
+ own,
46
+ parseBlock,
47
+ parseDocument,
48
+ parseSettingsBrand,
49
+ type AnyDocument,
50
+ type DocumentBlock,
51
+ type FieldsDocument,
52
+ type SettingsBrand,
53
+ type SettingsDocument,
54
+ type SharedDocument,
55
+ type SiteDocument,
56
+ } from './document.ts';
57
+ import { fieldToJson, isEmptyValue, parseField, type Field, type Fields } from './fields.ts';
58
+ import { mediaLine } from './media-value.ts';
59
+ import { parseRedirects } from './redirects.ts';
60
+ import { member, parseSource, slice, type ArrayNode, type JsonNode, type MemberNode, type ObjectNode, type ParsedSource } from './json-source.ts';
61
+ import { isEmptySeo, parseFormRecipients, parseGoogleTag, parseSeo, seoText, type Seo } from './seo.ts';
62
+ import { SCHEMA_KEY } from './schema-key.ts';
63
+
64
+ const INDENT = ' ';
65
+ const pad = (depth: number) => INDENT.repeat(depth);
66
+
67
+ /**
68
+ * Canonical JSON: two-space indent, one member per line, an array of strings on
69
+ * one line (`["Play Hard", "Work Hard"]`), `[]` and `{}` when empty. No trailing
70
+ * newline (applyEdits adds the file's).
71
+ */
72
+ export function toJson(value: unknown, depth = 0): string {
73
+ if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null);
74
+ if (Array.isArray(value)) {
75
+ if (value.length === 0) return '[]';
76
+ if (value.every((v) => typeof v === 'string')) return `[${value.map((v) => JSON.stringify(v)).join(', ')}]`;
77
+ return `[\n${value.map((v) => pad(depth + 1) + toJson(v, depth + 1)).join(',\n')}\n${pad(depth)}]`;
78
+ }
79
+ const entries = Object.entries(value).filter(([, v]) => v !== undefined);
80
+ if (entries.length === 0) return '{}';
81
+ return `{\n${entries.map(([k, v]) => `${pad(depth + 1)}${JSON.stringify(k)}: ${toJson(v, depth + 1)}`).join(',\n')}\n${pad(depth)}}`;
82
+ }
83
+
84
+ /** A member list written as an object at `depth`, each value already rendered as text. */
85
+ function objectText(members: [string, string][], depth: number): string {
86
+ if (members.length === 0) return '{}';
87
+ return `{\n${members.map(([k, v]) => `${pad(depth + 1)}${JSON.stringify(k)}: ${v}`).join(',\n')}\n${pad(depth)}}`;
88
+ }
89
+
90
+ function arrayText(items: string[], depth: number): string {
91
+ if (items.length === 0) return '[]';
92
+ return `[\n${items.map((v) => pad(depth + 1) + v).join(',\n')}\n${pad(depth)}]`;
93
+ }
94
+
95
+ const same = (a: unknown, b: unknown) => toJson(a) === toJson(b);
96
+
97
+ /**
98
+ * A field's value written as the file stores it, at `depth` (the depth of the
99
+ * member holding it). toJson's canonical form, plus two field rules:
100
+ * - **rich text: one block per line**, a list's items on its line
101
+ * (`{ "ul": ["First", "Second"] }`), M5a Decision 3;
102
+ * - **a list of several fields: each item an object on its own lines**, its keys
103
+ * in schema order and each value by its own field's rule (Decision 5). A list of
104
+ * one field is toJson's: a flat array of strings on one line.
105
+ * - **a group: an object on its own lines**, the same way as a list's item.
106
+ */
107
+ export function fieldText(field: Field, value: unknown, depth: number): string {
108
+ const stored = fieldToJson(field, value);
109
+ return storedText(field, stored, depth);
110
+ }
111
+
112
+ function storedText(field: Field, stored: unknown, depth: number): string {
113
+ if (field.kind === 'richText' && Array.isArray(stored)) {
114
+ const lines = stored.map((entry) => {
115
+ const [kind, v] = Object.entries(entry as Record<string, unknown>)[0]!;
116
+ return `{ ${JSON.stringify(kind)}: ${toJson(v)} }`;
117
+ });
118
+ return lines.length === 0 ? '[]' : arrayText(lines, depth);
119
+ }
120
+ // M5b: a media value on one line, as the demo writes it (media-value.ts → mediaLine).
121
+ if (field.kind === 'media') return mediaLine(stored);
122
+ if (field.kind === 'list' && Array.isArray(stored)) {
123
+ if (field.item) {
124
+ const item = field.item;
125
+ if (item.kind === 'media') return stored.length === 0 ? '[]' : arrayText(stored.map((v) => mediaLine(v)), depth);
126
+ if (item.kind !== 'richText') return toJson(stored, depth);
127
+ return stored.length === 0 ? '[]' : arrayText(stored.map((v) => storedText(item, v, depth + 1)), depth);
128
+ }
129
+ const fields = field.fields ?? {};
130
+ const items = stored.map((v) => {
131
+ const o = v as Record<string, unknown>;
132
+ // M7: the item's unknown keys (fieldToJson keeps them, after the schema's), written as read.
133
+ const extra = Object.keys(o).filter((key) => !Object.hasOwn(fields, key));
134
+ return objectText([...Object.entries(fields).map(([key, f]): [string, string] => [key, storedText(f, o[key], depth + 2)]), ...extra.map((key): [string, string] => [key, toJson(o[key], depth + 2)])], depth + 1);
135
+ });
136
+ return items.length === 0 ? '[]' : arrayText(items, depth);
137
+ }
138
+ // A group: an object on its own lines, its keys in schema order, each value by its own field's rule.
139
+ if (field.kind === 'group' && stored && typeof stored === 'object') {
140
+ const o = stored as Record<string, unknown>;
141
+ return objectText(Object.entries(field.fields).map(([key, f]): [string, string] => [key, storedText(f, o[key], depth + 1)]), depth);
142
+ }
143
+ return toJson(stored, depth);
144
+ }
145
+
146
+ /** The canonical text of a new document. */
147
+ export function serializeDocument(document: SiteDocument, site: Pick<ResolvedSite, 'blocks'>, pageFields: Fields | null = null): string {
148
+ return applyEdits(null, document, site, pageFields);
149
+ }
150
+
151
+ /**
152
+ * `pageFields`: the collection's page fields (M6c G2; ResolvedCollection → `fields`), or
153
+ * null. They're written per key, as a fields document's are: `edited.data` absent leaves
154
+ * the file's as they are; a key the edit changes is written in place, or, new to the
155
+ * file, after the key before it in schema order (`title` first, so before `blocks`). A
156
+ * new file has `title`, every page field with a value, `blocks`, then `seo`.
157
+ */
158
+ export function applyEdits(base: string | null, edited: SiteDocument, site: Pick<ResolvedSite, 'blocks'>, pageFields: Fields | null = null): string {
159
+ const fields = pageFields ?? {};
160
+ const values = edited.data;
161
+ if (base === null) {
162
+ const blocks = edited.blocks.map((b) => newBlockText(b, site, 2));
163
+ const seo = parseSeo(edited.seo);
164
+ const members: [string, string][] = [['title', JSON.stringify(edited.title)]];
165
+ for (const [key, field] of Object.entries(fields)) {
166
+ if (!values || !Object.hasOwn(values, key)) continue;
167
+ const value = parseField(field, values[key]);
168
+ if (!isEmptyValue(field, value)) members.push([key, fieldText(field, value, 1)]);
169
+ }
170
+ members.push(['blocks', arrayText(blocks, 1)]);
171
+ if (!isEmptySeo(seo)) members.push(['seo', seoText(seo, 1)]);
172
+ // M9 Decision 7: `hidden` right after `title` (where the Tina script writes it).
173
+ if (edited.hidden) members.splice(1, 0, ['hidden', 'true']);
174
+ return `${objectText(members, 0)}\n`;
175
+ }
176
+
177
+ const source = parseSource(base);
178
+ if (source.root.kind !== 'object') throw new Error('applyEdits: the base file is not a JSON object');
179
+ const root = source.root;
180
+ const read = parseDocument(source.value, site);
181
+ const blocksMember = member(root, 'blocks');
182
+ const rawBlocks: JsonNode[] = blocksMember?.value.kind === 'array' ? (blocksMember.value as ArrayNode).items : [];
183
+
184
+ // Blocks, each as text at depth 2 (inside the top-level `blocks` array).
185
+ const used = new Set<number>();
186
+ let blocksChanged = edited.blocks.length !== rawBlocks.length;
187
+ const blockTexts = edited.blocks.map((block, position) => {
188
+ const { text, changed } = blockText(block, position);
189
+ if (changed) blocksChanged = true;
190
+ return text;
191
+ });
192
+
193
+ function blockText(block: DocumentBlock, position: number): { text: string; changed: boolean } {
194
+ if (block.source === null || block.source === undefined) return { text: newBlockText(block, site, 2), changed: true };
195
+ const index = block.source;
196
+ if (!Number.isInteger(index) || index < 0 || index >= rawBlocks.length) {
197
+ throw new Error(`applyEdits: block ${position} has source ${index}, which isn't a block in the base file`);
198
+ }
199
+ if (used.has(index)) {
200
+ // The same source twice: the copy is a new block (an unknown one can't be copied).
201
+ return { text: newBlockText({ ...block, source: null }, site, 2), changed: true };
202
+ }
203
+ used.add(index);
204
+ const raw = rawBlocks[index]!;
205
+ const was = read.blocks[index]!;
206
+ const moved = index !== position;
207
+ if (!was.known) return { text: slice(source, raw), changed: moved };
208
+ if (block.type !== was.type) return { text: newBlockText({ ...block, source: null }, site, 2), changed: true };
209
+
210
+ const text = knownBlockText(source, raw as ObjectNode, was, block, site, 2);
211
+ return text === null ? { text: slice(source, raw), changed: moved } : { text, changed: true };
212
+ }
213
+
214
+ const titleChanged = edited.title !== read.title;
215
+ // M6 Decision 13: the page's `seo`, per key: absent from the edit, untouched; emptied, removed.
216
+ const seo = seoEdit(edited.seo, (source.value as Record<string, unknown>).seo);
217
+ // M6c (G2): the page fields, per key: each one the edit changes (compared parsed).
218
+ const fieldTexts = new Map<string, string>();
219
+ if (values) {
220
+ const raw = source.value as Record<string, unknown>;
221
+ for (const [key, field] of Object.entries(fields)) {
222
+ if (!Object.hasOwn(values, key)) continue;
223
+ const value = parseField(field, values[key]);
224
+ if (!same(value, parseField(field, raw[key]))) fieldTexts.set(key, fieldText(field, value, 1));
225
+ }
226
+ }
227
+ // M9 Decision 7: `hidden` per edit: absent, untouched; true adds it after `title`; false takes it out.
228
+ const hiddenEdit = edited.hidden === undefined || edited.hidden === (read.hidden === true) ? null : edited.hidden;
229
+ if (hiddenEdit !== null) fieldTexts.set('hidden', 'true');
230
+ if (!titleChanged && !blocksChanged && !seo && fieldTexts.size === 0) return base;
231
+
232
+ const replacements = new Map<string, string>();
233
+ if (titleChanged) replacements.set('title', JSON.stringify(edited.title));
234
+ if (blocksChanged) replacements.set('blocks', arrayText(blockTexts, 1));
235
+ if (seo?.text) replacements.set('seo', seo.text);
236
+
237
+ let out = replacements.size ? replaceTopLevel(source, root, replacements) : source.text;
238
+ if (seo && !seo.text) out = removeTopLevel(out, 'seo');
239
+ if (hiddenEdit === false) {
240
+ fieldTexts.delete('hidden');
241
+ out = removeTopLevel(out, 'hidden');
242
+ }
243
+ if (fieldTexts.size) {
244
+ const next = parseSource(out);
245
+ if (next.root.kind === 'object') out = spliceKeys(next, next.root, fieldTexts, ['title', 'hidden', ...Object.keys(fields)]);
246
+ }
247
+ return withTrailingNewline(out);
248
+ }
249
+
250
+ /**
251
+ * A `seo` edit against the file's (M6 Decision 13): null when untouched (absent from the
252
+ * edit, or the same once parsed); `{ text }` to write; `{ text: null }` to remove the key
253
+ * (the last part cleared). A file that never had one and an edit that sets nothing: untouched.
254
+ */
255
+ function seoEdit(edited: Seo | undefined, raw: unknown): { text: string | null } | null {
256
+ if (edited === undefined) return null;
257
+ const next = parseSeo(edited);
258
+ if (same(next, parseSeo(raw))) return null;
259
+ return { text: isEmptySeo(next) ? null : seoText(next, 1) };
260
+ }
261
+
262
+ /** Take a top-level member out of a file's text, keeping everything else as it is. */
263
+ export function removeTopLevel(text: string, key: string): string {
264
+ const source = parseSource(text);
265
+ if (source.root.kind !== 'object') return text;
266
+ const root = source.root;
267
+ const members = root.members;
268
+ const index = members.findIndex((m) => m.key === key);
269
+ if (index === -1) return text;
270
+ const m = members[index]!;
271
+ if (members.length === 1) return text.slice(0, root.start) + '{}' + text.slice(root.end);
272
+ if (index > 0) return text.slice(0, members[index - 1]!.value.end) + text.slice(m.value.end);
273
+ // The first member: from its key's quote to the next member's.
274
+ const keyStart = text.indexOf('"', root.start);
275
+ const nextKey = text.indexOf('"', m.value.end);
276
+ return text.slice(0, keyStart) + text.slice(nextKey);
277
+ }
278
+
279
+ /**
280
+ * A known block as read (`was`, from `raw`) with the editor's `block` applied, as
281
+ * text at `depth`; null when no field was touched (copy it as read). A touched
282
+ * block: `type`, then the schema's fields in schema order, then unknown keys as read.
283
+ */
284
+ function knownBlockText(source: ParsedSource, node: ObjectNode, was: DocumentBlock, block: DocumentBlock, site: Pick<ResolvedSite, 'blocks'>, depth: number): string | null {
285
+ const schema = own(site.blocks, was.type)!;
286
+ const touched = Object.entries(schema.fields).filter(([key, field]) => !same(parseField(field, block.data[key]), was.data[key]));
287
+ if (touched.length === 0) return null;
288
+ const touchedKeys = new Set(touched.map(([key]) => key));
289
+ const members: [string, string][] = [];
290
+ const typeMember = member(node, 'type');
291
+ members.push(['type', typeMember ? slice(source, typeMember.value) : JSON.stringify(was.type)]);
292
+ for (const [key, field] of Object.entries(schema.fields)) {
293
+ const m = member(node, key);
294
+ if (touchedKeys.has(key)) members.push([key, fieldText(field, block.data[key], depth + 1)]);
295
+ else if (m) members.push([key, slice(source, m.value)]);
296
+ }
297
+ const emitted = new Set(members.map(([k]) => k));
298
+ for (const m of node.members) {
299
+ if (emitted.has(m.key)) continue;
300
+ emitted.add(m.key);
301
+ members.push([m.key, slice(source, member(node, m.key)!.value)]);
302
+ }
303
+ return objectText(members, depth);
304
+ }
305
+
306
+ /** A block the file doesn't have (added, or retyped): `type`, then every schema field. */
307
+ function newBlockText(block: DocumentBlock, site: Pick<ResolvedSite, 'blocks'>, depth: number): string {
308
+ // A shared entry added in the editor (M5a Decision 8, the picker's Shared blocks): the
309
+ // shared block's path and nothing else, on one line as the demo writes it.
310
+ if (typeof block.shared === 'string') return `{ "shared": ${JSON.stringify(block.shared)} }`;
311
+ const schema = own(site.blocks, block.type);
312
+ if (!schema) throw new Error(`applyEdits: a new block has the unknown type "${block.type}"`);
313
+ const members: [string, string][] = [['type', JSON.stringify(block.type)]];
314
+ for (const [key, field] of Object.entries(schema.fields)) {
315
+ members.push([key, fieldText(field, block.data[key], depth + 1)]);
316
+ }
317
+ return objectText(members, depth);
318
+ }
319
+
320
+ /**
321
+ * Replace top-level members' values in place, keeping the rest of the file's text
322
+ * as it is. A key the file doesn't have yet is added at the end of the object.
323
+ */
324
+ function replaceTopLevel(source: ParsedSource, root: ObjectNode, replacements: Map<string, string>): string {
325
+ const missing = [...replacements.keys()].filter((key) => !member(root, key));
326
+ if (missing.length > 0) {
327
+ // Rebuild the top level: its members as read, in order, then the new keys.
328
+ const members: [string, string][] = [];
329
+ const seen = new Set<string>();
330
+ for (const m of root.members) {
331
+ if (seen.has(m.key)) continue;
332
+ seen.add(m.key);
333
+ members.push([m.key, replacements.get(m.key) ?? slice(source, member(root, m.key)!.value)]);
334
+ }
335
+ for (const key of missing) members.push([key, replacements.get(key)!]);
336
+ return source.text.slice(0, root.start) + objectText(members, 0) + source.text.slice(root.end);
337
+ }
338
+ const edits = [...replacements].map(([key, text]) => ({ node: member(root, key)!.value, text })).sort((a, b) => b.node.start - a.node.start);
339
+ let out = source.text;
340
+ for (const { node, text } of edits) out = out.slice(0, node.start) + text + out.slice(node.end);
341
+ return out;
342
+ }
343
+
344
+ function withTrailingNewline(text: string): string {
345
+ return text.endsWith('\n') ? text : `${text}\n`;
346
+ }
347
+
348
+ // ── The other kinds of document (M5a) ─────────────────────────────────────────
349
+
350
+ /**
351
+ * One top-level key of a document written per key (Decision 7): how its value
352
+ * parses (to compare the editor's with the file's) and how a touched one is written.
353
+ */
354
+ interface KeyCodec {
355
+ parse(raw: unknown): unknown;
356
+ text(value: unknown, depth: number): string;
357
+ /** Nothing set (a new file leaves it out). */
358
+ empty(value: unknown): boolean;
359
+ /** M6: an edit that empties it takes the key out of the file (`seo`, `googleTag`, `formRecipients`). */
360
+ removeWhenEmpty?: boolean;
361
+ }
362
+
363
+ /** M6 Decision 13: a page's `seo`, and Site Settings' defaults. */
364
+ const seoCodec: KeyCodec = { parse: parseSeo, text: seoText, empty: (v) => isEmptySeo(parseSeo(v)), removeWhenEmpty: true };
365
+ /** M6 Decision 16: the Google tag's ID, trimmed. */
366
+ const googleTagCodec: KeyCodec = { parse: parseGoogleTag, text: (v) => JSON.stringify(parseGoogleTag(v)), empty: (v) => parseGoogleTag(v) === '', removeWhenEmpty: true };
367
+ /** M6 Decision 14: one list of addresses per form, each on one line. */
368
+ const recipientsCodec: KeyCodec = {
369
+ parse: parseFormRecipients,
370
+ text: (v, depth) => toJson(parseFormRecipients(v), depth),
371
+ empty: (v) => Object.keys(parseFormRecipients(v)).length === 0,
372
+ removeWhenEmpty: true,
373
+ };
374
+
375
+ /** Pre-M7 item 8: the redirects, one `{ "from", "to" }` per line. */
376
+ const redirectsCodec: KeyCodec = {
377
+ parse: parseRedirects,
378
+ text: (v, depth) => {
379
+ const list = parseRedirects(v);
380
+ if (list.length === 0) return '[]';
381
+ return `[\n${list.map((r) => `${pad(depth + 1)}{ "from": ${JSON.stringify(r.from)}, "to": ${JSON.stringify(r.to)} }`).join(',\n')}\n${pad(depth)}]`;
382
+ },
383
+ empty: (v) => parseRedirects(v).length === 0,
384
+ removeWhenEmpty: true,
385
+ };
386
+
387
+ const fieldCodec = (field: Field): KeyCodec => ({
388
+ parse: (raw) => parseField(field, raw),
389
+ text: (value, depth) => fieldText(field, value, depth),
390
+ empty: (value) => isEmptyValue(field, value),
391
+ });
392
+
393
+ /** Site Settings' `brand` (Decision 10): `{ "name", "accent" }`, on its own lines; M5b's `logo` and `icon` after them when set, each on one line. */
394
+ const brandCodec: KeyCodec = {
395
+ parse: parseSettingsBrand,
396
+ text: (value, depth) => {
397
+ const brand = parseSettingsBrand(value) as SettingsBrand;
398
+ const members: [string, string][] = [['name', JSON.stringify(brand.name)], ['accent', JSON.stringify(brand.accent)]];
399
+ for (const key of ['logo', 'icon'] as const) {
400
+ const image = brand[key];
401
+ if (image) members.push([key, `{ ${Object.entries(image).map(([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}`).join(', ')} }`]);
402
+ }
403
+ return objectText(members, depth);
404
+ },
405
+ empty: (value) => {
406
+ const brand = parseSettingsBrand(value);
407
+ return brand.name === '' && brand.accent === '' && !brand.logo && !brand.icon;
408
+ },
409
+ };
410
+
411
+ /** A fields collection's keys, or Site Settings' (the package's `editors` and `brand`, then the site's own). */
412
+ function codecsFor(collection: Pick<ResolvedCollection, 'kind' | 'fields'>): Map<string, KeyCodec> {
413
+ const codecs = new Map<string, KeyCodec>();
414
+ if (collection.kind === 'settings') {
415
+ codecs.set('editors', fieldCodec(EDITORS_FIELD));
416
+ codecs.set('brand', brandCodec);
417
+ // M6 Decision 14: Site Settings' search and sharing defaults, the Google tag, the form emails' recipients.
418
+ codecs.set('seo', seoCodec);
419
+ codecs.set('googleTag', googleTagCodec);
420
+ codecs.set('formRecipients', recipientsCodec);
421
+ codecs.set('redirects', redirectsCodec);
422
+ }
423
+ for (const [key, field] of Object.entries(collection.fields ?? ({} as Fields))) codecs.set(key, fieldCodec(field));
424
+ return codecs;
425
+ }
426
+
427
+ /**
428
+ * Write a fields document or Site Settings back to its file, **per key** (M5a
429
+ * Decision 7, M3 Decision 7's rule): an untouched key is written back exactly as
430
+ * read, its text and its position; a touched key takes the schema's form, in place
431
+ * when the file has it, else after the key before it in schema order (`title` first);
432
+ * keys the schema doesn't know stay where they are. With nothing touched, that's
433
+ * `base` byte for byte. `base` null: a new file, `title` then every key `edited.data`
434
+ * has that isn't empty (fields.ts → isEmptyValue), in schema order: `{ data: {} }` is
435
+ * `{ "title" }`, Decision 11's new document.
436
+ */
437
+ export function applyKeyedEdits(
438
+ base: string | null,
439
+ edited: { title: string; data: Record<string, unknown>; seo?: Seo },
440
+ collection: Pick<ResolvedCollection, 'kind' | 'fields'>,
441
+ ): string {
442
+ const codecs = codecsFor(collection);
443
+ // M6 Decision 13: a fields document's `seo` is the document's own (beside `data`, as on a page), after its fields.
444
+ const values = new Map<string, unknown>();
445
+ for (const key of codecs.keys()) if (Object.hasOwn(edited.data, key)) values.set(key, edited.data[key]);
446
+ if (collection.kind === 'fields') {
447
+ codecs.set('seo', seoCodec);
448
+ if (edited.seo !== undefined) values.set('seo', edited.seo);
449
+ else values.delete('seo');
450
+ }
451
+ if (base === null) {
452
+ const members: [string, string][] = [['title', JSON.stringify(edited.title)]];
453
+ for (const [key, codec] of codecs) {
454
+ if (!values.has(key)) continue;
455
+ const value = codec.parse(values.get(key));
456
+ if (!codec.empty(value)) members.push([key, codec.text(value, 1)]);
457
+ }
458
+ return `${objectText(members, 0)}\n`;
459
+ }
460
+ const source = parseSource(base);
461
+ if (source.root.kind !== 'object') throw new Error('applyEdits: the base file is not a JSON object');
462
+ const root = source.root;
463
+ const raw = source.value as Record<string, unknown>;
464
+ const replacements = new Map<string, string>();
465
+ const removals: string[] = [];
466
+ if (edited.title !== (typeof raw.title === 'string' ? raw.title : '')) replacements.set('title', JSON.stringify(edited.title));
467
+ for (const [key, codec] of codecs) {
468
+ if (!values.has(key)) continue;
469
+ const value = codec.parse(values.get(key));
470
+ if (same(value, codec.parse(raw[key]))) continue;
471
+ if (codec.removeWhenEmpty && codec.empty(value)) {
472
+ if (Object.hasOwn(raw, key)) removals.push(key);
473
+ } else replacements.set(key, codec.text(value, 1));
474
+ }
475
+ if (replacements.size === 0 && removals.length === 0) return base;
476
+ let out = replacements.size ? spliceKeys(source, root, replacements, ['title', ...codecs.keys()]) : source.text;
477
+ for (const key of removals) out = removeTopLevel(out, key);
478
+ return withTrailingNewline(out);
479
+ }
480
+
481
+ /**
482
+ * Replace (or add) top-level members' values in the file's text. A key the file has
483
+ * is replaced in place; a missing one goes after the nearest key before it in
484
+ * `order` that the file has (first when there's none). Everything else is the
485
+ * file's own text.
486
+ */
487
+ function spliceKeys(source: ParsedSource, root: ObjectNode, replacements: Map<string, string>, order: readonly string[]): string {
488
+ interface Edit {
489
+ at: number;
490
+ end: number;
491
+ text: string;
492
+ }
493
+ const edits: Edit[] = [];
494
+ for (const [key, text] of replacements) {
495
+ const m = member(root, key);
496
+ if (m) {
497
+ edits.push({ at: m.value.start, end: m.value.end, text });
498
+ continue;
499
+ }
500
+ // After the nearest earlier key the file has (schema order), else first: after `$schema`, which stays first (M8 Decision 14).
501
+ let after: MemberNode | undefined;
502
+ for (const k of [SCHEMA_KEY, ...order.slice(0, Math.max(0, order.indexOf(key)))]) after = member(root, k) ?? after;
503
+ const line = `${pad(1)}${JSON.stringify(key)}: ${text}`;
504
+ if (root.members.length === 0) {
505
+ edits.push({ at: root.start, end: root.end, text: `{\n${line}\n}` });
506
+ } else if (after) {
507
+ edits.push({ at: after.value.end, end: after.value.end, text: `,\n${line}` });
508
+ } else {
509
+ // Before the first member: its key's quote is the first one after the brace.
510
+ const keyStart = source.text.indexOf('"', root.start);
511
+ edits.push({ at: keyStart, end: keyStart, text: `${line.trimStart()},\n${pad(1)}` });
512
+ }
513
+ }
514
+ // Apply from the end of the text; several inserts at one point keep their order.
515
+ let out = source.text;
516
+ const byPoint = new Map<number, Edit[]>();
517
+ for (const e of edits) byPoint.set(e.at, [...(byPoint.get(e.at) ?? []), e]);
518
+ for (const at of [...byPoint.keys()].sort((a, b) => b - a)) {
519
+ const group = byPoint.get(at)!;
520
+ const replace = group.find((e) => e.end !== e.at);
521
+ const inserts = group.filter((e) => e.end === e.at);
522
+ if (replace) out = out.slice(0, replace.at) + replace.text + inserts.map((e) => e.text).join('') + out.slice(replace.end);
523
+ else out = out.slice(0, at) + inserts.map((e) => e.text).join('') + out.slice(at);
524
+ }
525
+ return out;
526
+ }
527
+
528
+ /**
529
+ * Write a shared block back to its file (Decision 8): `title` per key; the block, if
530
+ * a field was touched, as a touched block is on a page (`type`, the schema's fields
531
+ * in order, unknown keys after); an unknown type, or an untouched block, as read.
532
+ * `base` null: a new file, `{ "title", "block": { "type", …every field } }`.
533
+ */
534
+ export function applySharedEdits(base: string | null, edited: SharedDocument, site: Pick<ResolvedSite, 'blocks'>): string {
535
+ if (base === null) {
536
+ const block = own(site.blocks, edited.block.type) ? newBlockText(edited.block, site, 1) : objectText([['type', JSON.stringify(edited.block.type)]], 1);
537
+ return `${objectText([['title', JSON.stringify(edited.title)], ['block', block]], 0)}\n`;
538
+ }
539
+ const source = parseSource(base);
540
+ if (source.root.kind !== 'object') throw new Error('applyEdits: the base file is not a JSON object');
541
+ const root = source.root;
542
+ const raw = source.value as Record<string, unknown>;
543
+ const replacements = new Map<string, string>();
544
+ if (edited.title !== (typeof raw.title === 'string' ? raw.title : '')) replacements.set('title', JSON.stringify(edited.title));
545
+ const blockMember = member(root, 'block');
546
+ const was = parseBlock(raw.block, 0, site);
547
+ if (!was.known || !blockMember || blockMember.value.kind !== 'object') {
548
+ // Unknown, or not a block at all: kept as read, unless the editor gave it a known type.
549
+ if (edited.block.known && own(site.blocks, edited.block.type) && edited.block.type !== was.type) replacements.set('block', newBlockText(edited.block, site, 1));
550
+ } else if (edited.block.type !== was.type) {
551
+ if (own(site.blocks, edited.block.type)) replacements.set('block', newBlockText(edited.block, site, 1));
552
+ } else {
553
+ const text = knownBlockText(source, blockMember.value as ObjectNode, was, edited.block, site, 1);
554
+ if (text !== null) replacements.set('block', text);
555
+ }
556
+ if (replacements.size === 0) return base;
557
+ return withTrailingNewline(spliceKeys(source, root, replacements, ['title', 'block']));
558
+ }
559
+
560
+ /**
561
+ * Any document's write (the write route and live mode's autosave call this): by its
562
+ * collection's kind, the section builder's applyEdits, the shared block's, or the
563
+ * per-key writer for a fields document and Site Settings. A document of the wrong
564
+ * kind for its collection is refused (throws).
565
+ */
566
+ export function applyDocumentEdits(
567
+ base: string | null,
568
+ edited: AnyDocument,
569
+ site: Pick<ResolvedSite, 'blocks'>,
570
+ collection: Pick<ResolvedCollection, 'kind' | 'fields'>,
571
+ ): string {
572
+ const kind = edited.kind ?? 'sections';
573
+ if (kind !== collection.kind) throw new Error(`a ${kind} document can't be written to a ${collection.kind} collection`);
574
+ switch (edited.kind) {
575
+ case 'fields':
576
+ case 'settings':
577
+ return applyKeyedEdits(base, edited as FieldsDocument | SettingsDocument, collection);
578
+ case 'shared':
579
+ return applySharedEdits(base, edited, site);
580
+ default:
581
+ return applyEdits(base, edited as SiteDocument, site, collection.fields);
582
+ }
583
+ }
584
+
585
+ // ── A whole file in the form a save writes (M8 trial, MEC findings item 7) ─────
586
+
587
+ /** How a value is laid out: the serialiser's per-field rules, keyed by what holds it. */
588
+ type Shape =
589
+ | { kind: 'plain' }
590
+ | { kind: 'inline' }
591
+ | { kind: 'rich' }
592
+ | { kind: 'list'; item: Shape }
593
+ | { kind: 'object'; keys: ReadonlyMap<string, Shape> };
594
+
595
+ const PLAIN: Shape = { kind: 'plain' };
596
+ const INLINE: Shape = { kind: 'inline' };
597
+
598
+ function shapeOf(field: Field): Shape {
599
+ switch (field.kind) {
600
+ case 'media':
601
+ return INLINE;
602
+ case 'richText':
603
+ return { kind: 'rich' };
604
+ case 'group':
605
+ return { kind: 'object', keys: shapesOf(field.fields) };
606
+ case 'list':
607
+ if (field.item) return field.item.kind === 'media' ? { kind: 'list', item: INLINE } : field.item.kind === 'richText' ? { kind: 'list', item: { kind: 'rich' } } : PLAIN;
608
+ return { kind: 'list', item: { kind: 'object', keys: shapesOf(field.fields ?? {}) } };
609
+ default:
610
+ return PLAIN;
611
+ }
612
+ }
613
+
614
+ const shapesOf = (fields: Fields): Map<string, Shape> => new Map(Object.entries(fields).map(([k, f]) => [k, shapeOf(f)]));
615
+
616
+ /** One value on one line, as mediaLine and newBlockText's shared entry write it: `{ "key": "…", "focal": [0.5, 0.5] }`. */
617
+ function inlineText(value: unknown): string {
618
+ if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null);
619
+ if (Array.isArray(value)) return `[${value.map(inlineText).join(', ')}]`;
620
+ const entries = Object.entries(value);
621
+ return entries.length ? `{ ${entries.map(([k, v]) => `${JSON.stringify(k)}: ${inlineText(v)}`).join(', ')} }` : '{}';
622
+ }
623
+
624
+ function layout(value: unknown, shape: Shape, depth: number): string {
625
+ if (value === null || typeof value !== 'object') return toJson(value, depth);
626
+ switch (shape.kind) {
627
+ case 'inline':
628
+ return inlineText(value);
629
+ case 'rich':
630
+ // storedText's rule: one block per line, its value as toJson writes it.
631
+ if (!Array.isArray(value)) return toJson(value, depth);
632
+ return arrayText(
633
+ value.map((entry) =>
634
+ entry && typeof entry === 'object' && !Array.isArray(entry)
635
+ ? `{ ${Object.entries(entry).map(([k, v]) => `${JSON.stringify(k)}: ${toJson(v)}`).join(', ')} }`
636
+ : toJson(entry),
637
+ ),
638
+ depth,
639
+ );
640
+ case 'list':
641
+ if (!Array.isArray(value)) return toJson(value, depth);
642
+ return arrayText(value.map((v) => layout(v, shape.item, depth + 1)), depth);
643
+ case 'object': {
644
+ if (Array.isArray(value)) return toJson(value, depth);
645
+ return objectText(Object.entries(value).map(([k, v]): [string, string] => [k, layout(v, shape.keys.get(k) ?? PLAIN, depth + 1)]), depth);
646
+ }
647
+ default:
648
+ return toJson(value, depth);
649
+ }
650
+ }
651
+
652
+ /** A block, or a page's `{ "shared": … }` entry (on one line, as newBlockText writes it); an unknown type's keys as toJson writes them. */
653
+ function blockShape(raw: unknown, site: Pick<ResolvedSite, 'blocks'>): Shape {
654
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return PLAIN;
655
+ const o = raw as Record<string, unknown>;
656
+ if (typeof o.shared === 'string' && o.type === undefined) return INLINE;
657
+ const schema = typeof o.type === 'string' ? own(site.blocks, o.type) : undefined;
658
+ return { kind: 'object', keys: schema ? shapesOf(schema.fields) : new Map() };
659
+ }
660
+
661
+ const SEO_SHAPE: Shape = { kind: 'object', keys: new Map([['image', INLINE]]) };
662
+
663
+ /**
664
+ * A content file's text as the editor's save writes it (two-space JSON, one member per line,
665
+ * a string array on one line, a media value on one line, rich text one block per line, a
666
+ * trailing newline: the rules above, by the collection's kind and fields), **with every key
667
+ * where it is and every value as it is**: only the whitespace changes. Null when the text
668
+ * isn't a JSON object. `validate` warns on a file that differs; `validate --fix` writes this.
669
+ */
670
+ export function savedForm(text: string, collection: Pick<ResolvedCollection, 'kind' | 'fields'>, site: Pick<ResolvedSite, 'blocks'>): string | null {
671
+ let raw: unknown;
672
+ try {
673
+ raw = JSON.parse(text);
674
+ } catch {
675
+ return null;
676
+ }
677
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
678
+ const keys = shapesOf(collection.fields ?? {});
679
+ keys.set('seo', SEO_SHAPE);
680
+ if (collection.kind === 'sections') {
681
+ const members = Object.entries(raw).map(([k, v]): [string, string] => {
682
+ if (k === 'blocks' && Array.isArray(v)) return [k, arrayText(v.map((b) => layout(b, blockShape(b, site), 2)), 1)];
683
+ return [k, layout(v, keys.get(k) ?? PLAIN, 1)];
684
+ });
685
+ return `${objectText(members, 0)}\n`;
686
+ }
687
+ if (collection.kind === 'shared') {
688
+ const block = (raw as Record<string, unknown>).block;
689
+ keys.set('block', blockShape(block, site));
690
+ }
691
+ if (collection.kind === 'settings') {
692
+ // brandCodec: logo and icon on one line each; redirectsCodec: one redirect per line.
693
+ keys.set('brand', { kind: 'object', keys: new Map([['logo', INLINE], ['icon', INLINE]]) });
694
+ keys.set('redirects', { kind: 'list', item: INLINE });
695
+ }
696
+ return `${layout(raw, { kind: 'object', keys }, 0)}\n`;
697
+ }