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,579 @@
1
+ /**
2
+ * The documents as the package and the editor see them, and how they're parsed from
3
+ * a content file. Four kinds (M5a → Core framing), one per kind of collection:
4
+ *
5
+ * SiteDocument a section builder (Pages, Journal): `{ title, blocks }`, and its
6
+ * page fields' values as `data` when the collection has some (M6c G2)
7
+ * FieldsDocument a list-of-fields document (case studies): `{ kind: 'fields', title, data }`
8
+ * SharedDocument a shared block: `{ kind: 'shared', title, block }`
9
+ * SettingsDocument Site Settings: `{ kind: 'settings', title, data }`
10
+ *
11
+ * A SiteDocument has no `kind` (or `'sections'`): M3's shape, unchanged.
12
+ * `parseAnyDocument(raw, site, collection)` reads any of them by its collection's kind.
13
+ *
14
+ * Block identity: every block carries `source`, its index in the file's `blocks`
15
+ * array as read, or `null` for a block the editor added. The editor keeps `source`
16
+ * on a block through reorders, and drops the block to remove it (bringing it back
17
+ * with Undo restores the same `source`). The serializer (serialize.ts) uses it to
18
+ * find each block's raw form in the file, so an unknown block, or any block the
19
+ * editor didn't change, is written back byte for byte wherever it moves (Decision 7).
20
+ * `source` is never written to the file.
21
+ */
22
+ import type { ResolvedCollection, ResolvedSite } from './config.ts';
23
+ import { fieldAtPath, firstImagePath, getAtPath, headingPaths, isEmptyValue, list, parseField, text, type FieldValue, type Field, type Fields } from './fields.ts';
24
+ import { parseSource, type ParsedSource } from './json-source.ts';
25
+ import { isFileKey } from './files.ts';
26
+ import { parseMedia } from './media-value.ts';
27
+ import { parseRedirects, type Redirect } from './redirects.ts';
28
+ import { parseFormRecipients, parseGoogleTag, parseSeo, type Seo } from './seo.ts';
29
+
30
+ export interface DocumentBlock {
31
+ /** Index of the block in the file's `blocks` as read; `null` for a block the editor added. */
32
+ source: number | null;
33
+ /** The block's `type` (`''` when the file's block has none). */
34
+ type: string;
35
+ /** Whether the site's config knows this type. An unknown block is skipped in the render (Decision 7). */
36
+ known: boolean;
37
+ /**
38
+ * Known: the parsed value of every field in the block's schema, keyed by field.
39
+ * Unknown: the block's raw keys (minus `type`) as read, so the editor can
40
+ * summarise what's kept (Decision 21); the serializer never writes these back,
41
+ * it copies the raw block instead.
42
+ */
43
+ data: Record<string, unknown>;
44
+ /**
45
+ * A shared-block entry (M5a Decision 8: `{ "shared": "footer" }`): the shared
46
+ * block's path in the `shared` collection. Such an entry is never `known` (it's
47
+ * selected, moved and removed like a block, never edited on the page) and is
48
+ * copied from the file as read; the render resolves it to the shared block.
49
+ */
50
+ shared?: string;
51
+ }
52
+
53
+ export interface SiteDocument {
54
+ kind?: 'sections';
55
+ title: string;
56
+ blocks: DocumentBlock[];
57
+ /**
58
+ * M6 Decision 13: the page's search and sharing settings (seo.ts). Present when the
59
+ * file has the key (or the editor sent it); a write with it absent leaves the file's
60
+ * own `seo` as it is.
61
+ */
62
+ seo?: Seo;
63
+ /**
64
+ * M9 Decision 7: the page is hidden from the site (the file's top-level `"hidden": true`;
65
+ * absent means shown). Not served, not in the sitemap, not offered by the link picker; still
66
+ * edited, previewed and published in /admin. The editor sends `false` to unhide, which takes
67
+ * the key out of the file. The home page (`index`) can't be hidden (content.ts reads it as shown).
68
+ */
69
+ hidden?: boolean;
70
+ /**
71
+ * M6c (G2): the page fields' parsed values, keyed by field, every field of the
72
+ * collection's `fields` present (the file's top-level keys after `title`). Present
73
+ * only when the collection has page fields; a write with it absent leaves the
74
+ * file's as they are.
75
+ */
76
+ data?: Record<string, unknown>;
77
+ }
78
+
79
+ /**
80
+ * A list-of-fields document (Decision 7): its title and its fields' parsed values,
81
+ * keyed by field, every field of the collection's schema present. Keys the schema
82
+ * doesn't know aren't here; the serializer keeps them in the file.
83
+ */
84
+ export interface FieldsDocument {
85
+ kind: 'fields';
86
+ title: string;
87
+ data: Record<string, unknown>;
88
+ /** M6 Decision 13, as on a section builder's document. */
89
+ seo?: Seo;
90
+ }
91
+
92
+ /** A shared block (Decision 8): `{ "title", "block": { "type", ...fields } }`. */
93
+ export interface SharedDocument {
94
+ kind: 'shared';
95
+ title: string;
96
+ /** The block, parsed as a page's block is (`source` 0). An unknown type is kept and skipped, as on a page. */
97
+ block: DocumentBlock;
98
+ }
99
+
100
+ /** A brand slot's image (M5b Decision 11): image-only, no alt (the logo's text alternative is the brand's name). */
101
+ export interface BrandImage {
102
+ key: string;
103
+ w?: number;
104
+ h?: number;
105
+ }
106
+
107
+ /** Site Settings' brand (Decision 10), as typed: `accent` may not be a colour yet (Decision 15). M5b adds the logo and icon. */
108
+ export interface SettingsBrand {
109
+ name: string;
110
+ accent: string;
111
+ logo: BrandImage | null;
112
+ icon: BrandImage | null;
113
+ }
114
+
115
+ /**
116
+ * Site Settings (Decision 10): `data` holds `editors` (a list of strings), `brand`
117
+ * (`{ name, accent }`) and the site's own `settings.fields`, parsed. M6 Decision 14 adds
118
+ * `seo` (the defaults), `googleTag` and `formRecipients`, each present when the file
119
+ * has it (or the editor sent it); a write without one leaves the file's as it is.
120
+ */
121
+ export interface SettingsDocument {
122
+ kind: 'settings';
123
+ title: string;
124
+ data: Record<string, unknown> & { editors: string[]; brand: SettingsBrand; seo?: Seo; googleTag?: string; formRecipients?: Record<string, string[]>; redirects?: Redirect[] };
125
+ }
126
+
127
+ /** M6: Site Settings' search and sharing keys, parsed, each only when `raw` has it. */
128
+ function settingsM6(raw: Record<string, unknown>): { seo?: Seo; googleTag?: string; formRecipients?: Record<string, string[]>; redirects?: Redirect[] } {
129
+ return {
130
+ // Pre-M7 item 8: the redirects changing an address adds (redirects.ts).
131
+ ...(Object.hasOwn(raw, 'redirects') ? { redirects: parseRedirects(raw.redirects) } : {}),
132
+ ...(Object.hasOwn(raw, 'seo') ? { seo: parseSeo(raw.seo) } : {}),
133
+ ...(Object.hasOwn(raw, 'googleTag') ? { googleTag: parseGoogleTag(raw.googleTag) } : {}),
134
+ ...(Object.hasOwn(raw, 'formRecipients') ? { formRecipients: parseFormRecipients(raw.formRecipients) } : {}),
135
+ };
136
+ }
137
+
138
+ /** M6: a page's `seo`, parsed, only when `raw` has it. */
139
+ const seoOf = (raw: Record<string, unknown>): { seo?: Seo } => (Object.hasOwn(raw, 'seo') ? { seo: parseSeo(raw.seo) } : {});
140
+
141
+ /** Any document. */
142
+ export type AnyDocument = SiteDocument | FieldsDocument | SharedDocument | SettingsDocument;
143
+ /** What a page renders: a section builder, a fields document, or a shared block on its own (its preview). */
144
+ export type PageDocument = SiteDocument | FieldsDocument | SharedDocument;
145
+ export type DocumentKind = 'sections' | 'fields' | 'shared' | 'settings';
146
+
147
+ /** A document's kind (a SiteDocument has none, or `'sections'`). */
148
+ export function documentKind(document: AnyDocument): DocumentKind {
149
+ return document.kind ?? 'sections';
150
+ }
151
+
152
+ const isObject = (raw: unknown): raw is Record<string, unknown> => !!raw && typeof raw === 'object' && !Array.isArray(raw);
153
+ const asObject = (raw: unknown): Record<string, unknown> => (isObject(raw) ? raw : {});
154
+ const titleOf = (doc: Record<string, unknown>) => (typeof doc.title === 'string' ? doc.title : '');
155
+
156
+ /** Every field's parsed value from a raw object (a missing key parses as empty). */
157
+ export function parseFields(fields: Fields, raw: Record<string, unknown>): Record<string, unknown> {
158
+ const data: Record<string, unknown> = {};
159
+ for (const [key, field] of Object.entries(fields)) data[key] = parseField(field, raw[key]);
160
+ return data;
161
+ }
162
+
163
+ /** A brand slot's raw value as an image (`{ key, w, h }`), or null: a clip, or anything else, is no image. */
164
+ export function parseBrandImage(raw: unknown): BrandImage | null {
165
+ const v = parseMedia(raw);
166
+ if (!v || !('key' in v) || isFileKey(v.key)) return null;
167
+ const out: BrandImage = { key: v.key };
168
+ if (v.w !== undefined) out.w = v.w;
169
+ if (v.h !== undefined) out.h = v.h;
170
+ return out;
171
+ }
172
+
173
+ /** Site Settings' brand from its raw value: two strings, trimmed (`''` when missing), and the logo and icon (null when unset). */
174
+ export function parseSettingsBrand(raw: unknown): SettingsBrand {
175
+ const o = asObject(raw);
176
+ return {
177
+ name: typeof o.name === 'string' ? o.name.trim() : '',
178
+ accent: typeof o.accent === 'string' ? o.accent.trim() : '',
179
+ logo: parseBrandImage(o.logo),
180
+ icon: parseBrandImage(o.icon),
181
+ };
182
+ }
183
+
184
+ /** Site Settings' `editors` (Decision 10): `list(text())`, each an address or an `@domain`. */
185
+ export const EDITORS_FIELD = list(text(), { label: 'Editors' });
186
+
187
+ /** Site Settings' editors, parsed as the field reads them (blank entries kept: they're the editor's to drop). */
188
+ export function parseEditors(raw: unknown): string[] {
189
+ return parseField(EDITORS_FIELD, raw) as string[];
190
+ }
191
+
192
+ /**
193
+ * Any document's plain JSON value, by its collection's kind. Never throws on content:
194
+ * a value of the wrong shape parses as empty (and is kept in the file, Decision 7).
195
+ */
196
+ export function parseAnyDocument(raw: unknown, site: Pick<ResolvedSite, 'blocks'>, collection: Pick<ResolvedCollection, 'kind' | 'fields'>): AnyDocument {
197
+ const doc = asObject(raw);
198
+ switch (collection.kind) {
199
+ case 'fields':
200
+ return { kind: 'fields', title: titleOf(doc), data: parseFields(collection.fields ?? {}, doc), ...seoOf(doc) };
201
+ case 'shared':
202
+ return { kind: 'shared', title: titleOf(doc), block: parseBlock(doc.block, 0, site) };
203
+ case 'settings':
204
+ return {
205
+ kind: 'settings',
206
+ title: titleOf(doc),
207
+ data: { ...parseFields(collection.fields ?? {}, doc), editors: parseEditors(doc.editors), brand: parseSettingsBrand(doc.brand), ...settingsM6(doc) },
208
+ };
209
+ default:
210
+ return { ...parseDocument(raw, site), ...pageDataOf(collection, doc) };
211
+ }
212
+ }
213
+
214
+ /** M6c (G2): a section builder's page fields parsed from `raw`, only when its collection has some. */
215
+ function pageDataOf(collection: Pick<ResolvedCollection, 'fields'>, raw: Record<string, unknown>): { data?: Record<string, unknown> } {
216
+ return collection.fields && Object.keys(collection.fields).length ? { data: parseFields(collection.fields, raw) } : {};
217
+ }
218
+
219
+ /** Parse a document's plain JSON value. Never throws on content: odd blocks become unknown ones. */
220
+ export function parseDocument(raw: unknown, site: Pick<ResolvedSite, 'blocks'>): SiteDocument {
221
+ const doc = raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
222
+ const rawBlocks = Array.isArray(doc.blocks) ? doc.blocks : [];
223
+ return {
224
+ title: typeof doc.title === 'string' ? doc.title : '',
225
+ blocks: rawBlocks.map((b, index) => parseBlock(b, index, site)),
226
+ ...seoOf(doc),
227
+ ...(doc.hidden === true ? { hidden: true } : {}),
228
+ };
229
+ }
230
+
231
+ /** Parse a content file's text. Throws JSON.parse's SyntaxError on invalid JSON. */
232
+ export function parseDocumentText(text: string, site: Pick<ResolvedSite, 'blocks'>): { source: ParsedSource; document: SiteDocument } {
233
+ const source = parseSource(text);
234
+ return { source, document: parseDocument(source.value, site) };
235
+ }
236
+
237
+ /**
238
+ * `record[key]` for the record's own keys only. Block types, collections, text styles
239
+ * and field names come from content files and requests, so a type such as
240
+ * "constructor", "toString" or "__proto__" must read as unknown, never as Object's
241
+ * built-ins (which would make a whole file fail to parse).
242
+ */
243
+ export function own<T>(record: Readonly<Record<string, T>>, key: string): T | undefined {
244
+ return Object.hasOwn(record, key) ? record[key] : undefined;
245
+ }
246
+
247
+ export function parseBlock(raw: unknown, source: number | null, site: Pick<ResolvedSite, 'blocks'>): DocumentBlock {
248
+ const obj = raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : null;
249
+ // A shared-block entry (Decision 8): kept, never edited here; the render resolves it.
250
+ if (obj && typeof obj.shared === 'string') {
251
+ const { type: _t, ...rest } = obj;
252
+ return { source, type: typeof obj.type === 'string' ? obj.type : '', known: false, data: rest, shared: obj.shared };
253
+ }
254
+ const type = obj && typeof obj.type === 'string' ? obj.type : '';
255
+ const schema = obj ? own(site.blocks, type) : undefined;
256
+ if (!obj || !schema) {
257
+ const { type: _type, ...rest } = obj ?? {};
258
+ return { source, type, known: false, data: rest };
259
+ }
260
+ const data: Record<string, unknown> = {};
261
+ for (const [key, field] of Object.entries(schema.fields)) data[key] = parseField(field, obj[key]);
262
+ return { source, type, known: true, data };
263
+ }
264
+
265
+ /**
266
+ * Make a document that came from outside (the editor's POST to the preview route)
267
+ * safe to render: every known block's fields re-parsed by their schema, so a value
268
+ * of the wrong shape renders as empty instead of throwing. Sources are kept.
269
+ */
270
+ export function normaliseDocument(input: unknown, site: Pick<ResolvedSite, 'blocks'>): SiteDocument {
271
+ const doc = input && typeof input === 'object' ? (input as Partial<SiteDocument>) : {};
272
+ const blocks = Array.isArray(doc.blocks) ? doc.blocks : [];
273
+ return {
274
+ title: typeof doc.title === 'string' ? doc.title : '',
275
+ blocks: blocks.map((b): DocumentBlock => {
276
+ const block = b && typeof b === 'object' ? (b as Partial<DocumentBlock>) : {};
277
+ const source = typeof block.source === 'number' ? block.source : null;
278
+ const type = typeof block.type === 'string' ? block.type : '';
279
+ const data = block.data && typeof block.data === 'object' ? block.data : {};
280
+ // A shared entry: `shared` on the block, or (from an editor that keeps only source, type, known and data) in its raw keys.
281
+ const shared = typeof block.shared === 'string' ? block.shared : block.known !== true && typeof data.shared === 'string' ? data.shared : undefined;
282
+ if (shared !== undefined) return { source, type, known: false, data, shared };
283
+ const schema = own(site.blocks, type);
284
+ if (!schema) return { source, type, known: false, data };
285
+ const parsed: Record<string, unknown> = {};
286
+ for (const [key, field] of Object.entries(schema.fields)) parsed[key] = parseField(field, data[key]);
287
+ return { source, type, known: true, data: parsed };
288
+ }),
289
+ ...seoOf(asObject(input)),
290
+ // M9 Decision 7: true hides, false unhides, absent leaves the file's as it is.
291
+ ...(typeof doc.hidden === 'boolean' ? { hidden: doc.hidden } : {}),
292
+ };
293
+ }
294
+
295
+ /**
296
+ * Any document that came from outside (the editor's POST to the preview or write
297
+ * route), made safe by its collection's kind: every field re-parsed by its schema.
298
+ * A shape that doesn't match the collection's kind reads as an empty one of that kind.
299
+ */
300
+ export function normaliseAnyDocument(input: unknown, site: Pick<ResolvedSite, 'blocks'>, collection: Pick<ResolvedCollection, 'kind' | 'fields'>): AnyDocument {
301
+ const doc = asObject(input);
302
+ switch (collection.kind) {
303
+ case 'fields':
304
+ return { kind: 'fields', title: titleOf(doc), data: parseFields(collection.fields ?? {}, asObject(doc.data)), ...seoOf(doc) };
305
+ case 'shared': {
306
+ const [block] = normaliseDocument({ blocks: [doc.block ?? {}] }, site).blocks;
307
+ return { kind: 'shared', title: titleOf(doc), block: { ...block!, source: 0 } };
308
+ }
309
+ case 'settings': {
310
+ const data = asObject(doc.data);
311
+ return {
312
+ kind: 'settings',
313
+ title: titleOf(doc),
314
+ data: { ...parseFields(collection.fields ?? {}, data), editors: parseEditors(data.editors), brand: parseSettingsBrand(data.brand), ...settingsM6(data) },
315
+ };
316
+ }
317
+ default:
318
+ // `data` absent (or not an object) means "leave the file's page fields as they are"
319
+ // (SiteDocument.data): parsing an empty object would write every field's default.
320
+ return { ...normaliseDocument(input, site), ...(isObject(doc.data) ? pageDataOf(collection, doc.data) : {}) };
321
+ }
322
+ }
323
+
324
+ /**
325
+ * The shared block a block-list entry names (Decision 8), or undefined: `shared` on
326
+ * the block, or, for a block the editor holds as `{ source, type, known, data }`, the
327
+ * `shared` key among its raw keys.
328
+ */
329
+ export function sharedEntryOf(block: Pick<DocumentBlock, 'known' | 'data' | 'shared'>): string | undefined {
330
+ if (typeof block.shared === 'string') return block.shared;
331
+ return !block.known && typeof block.data?.shared === 'string' ? block.data.shared : undefined;
332
+ }
333
+
334
+ /** A new block of a type, with every field empty (its parsed default). For the editor's picker. */
335
+ export function emptyBlock(type: string, site: Pick<ResolvedSite, 'blocks'>): DocumentBlock {
336
+ return parseBlock({ type }, null, site);
337
+ }
338
+
339
+ // ── Rendering contract ────────────────────────────────────────────────────────
340
+
341
+ /** A document another component reads through the render context (Decision 9). */
342
+ export interface ReadEntry {
343
+ collection: string;
344
+ path: string;
345
+ /** Its page's URL, or null (a shared block, Site Settings). */
346
+ url: string | null;
347
+ title: string;
348
+ document: AnyDocument;
349
+ /**
350
+ * A fields document's (or Site Settings') render data: each field as a component
351
+ * gets it (fields.ts → renderData; a reference to a document that doesn't exist is
352
+ * null). For a section builder with page fields (M6c G2), those fields' render data
353
+ * (a game's tile details, read by a block on another page). Empty otherwise.
354
+ */
355
+ data: Record<string, unknown>;
356
+ }
357
+
358
+ /**
359
+ * Read access to the site's other documents from a render (M5a Decision 9), **from
360
+ * the source the page itself is rendered from**: the disk at build (`main` as
361
+ * deployed), and in a preview the editor's store with autosaves on top. The same
362
+ * parsing either way. Documents that can't be opened (invalid JSON, no title) aren't
363
+ * there.
364
+ */
365
+ export interface DocumentReader {
366
+ /** One document by collection and path, or null when it doesn't exist (or can't be opened). */
367
+ document(collection: string, path: string): Promise<ReadEntry | null>;
368
+ /** A collection's documents in the editor's order (a flat collection: alphabetical by title). */
369
+ documents(collection: string): Promise<ReadEntry[]>;
370
+ /** Whether a document exists (and opens): what a reference's render checks. */
371
+ exists(collection: string, path: unknown): Promise<boolean>;
372
+ }
373
+
374
+ /**
375
+ * What the Blocks loop gives each block component, beside its data. Built per
376
+ * render from the document (never module state, so concurrent preview renders
377
+ * can't leak into each other: M3 → Watch-outs).
378
+ */
379
+ /** M9 Decision 9: `<Blocks wrap>`'s rule and its runs, for a site's page component (`import type { WrapBlocks } from 'caracms/document'`). */
380
+ export type { BlockRun, WrapBlock, WrapBlocks } from './block-runs.ts';
381
+
382
+ export interface BlockContext {
383
+ /**
384
+ * The block's position in the document being rendered: the editor's block index (0 for a
385
+ * fields document's or a shared block's own render). It counts every entry in the file's
386
+ * `blocks`, rendered or not, so `index === 0` isn't "the first block on the page" when the
387
+ * first entry doesn't render (an unknown type, a shared block that's gone): use `first`.
388
+ */
389
+ index: number;
390
+ /**
391
+ * MEC trial item 2: this is the first block the page renders (unknown blocks and shared
392
+ * entries that don't resolve are skipped; a shared block counts where it's placed). A fields
393
+ * document's render, and a shared block's own preview, are always first. Its page's other
394
+ * blocks are never told about it: anything page-wide goes through the page component.
395
+ */
396
+ first: boolean;
397
+ /** The block's type; for a fields document's render, its collection's name. */
398
+ type: string;
399
+ /**
400
+ * A preview render (Decision 17): only then do the package's components emit
401
+ * `data-cms-*` markers and make plain text fields `contenteditable`.
402
+ */
403
+ preview: boolean;
404
+ /**
405
+ * The key of this block's field that holds the page's `h1` (Decision 16), or
406
+ * null. Set on the first block, in render order, with a non-empty heading.
407
+ * The package's Lockup and Text use it; `headingTag()` reads it for a block's own markup.
408
+ */
409
+ h1Field: string | null;
410
+ /**
411
+ * A fields document's render (M5a Decision 7): its collection's name. The package's
412
+ * components then find a field's descriptor in the collection's fields, and the
413
+ * root's marker is `data-cms-document` in place of `data-cms-block` (Decision 16).
414
+ * Null for a block.
415
+ */
416
+ collection?: string | null;
417
+ /**
418
+ * A shared block rendered on a page through a `{ "shared": … }` entry (Decision 8).
419
+ * It's never edited on the page, so its fields carry no markers; in a preview its
420
+ * root still says where it is (`data-cms-block`, `data-cms-shared`). Null otherwise.
421
+ */
422
+ shared?: { name: string; preview: boolean } | null;
423
+ /** Read the site's other documents (Decision 9): the case-study list reads its collection here. */
424
+ read: DocumentReader;
425
+ /**
426
+ * Posts and tags, Decision 12: the page of a paged listing this render is (1, 2, …), as the
427
+ * route passes it to RenderDocument. 1 for every page that isn't paged, and always 1 in a preview.
428
+ */
429
+ listPage: number;
430
+ /**
431
+ * M5b Decision 4's loading rule: the block is one of the first two *rendered* blocks
432
+ * (unknown and skipped ones don't count), so its images load eagerly. A fields
433
+ * document's render is its page's first. Absent: lazy.
434
+ */
435
+ eager?: boolean;
436
+ /**
437
+ * The path of the one image that gets `fetchpriority="high"`: the first image of the
438
+ * first rendered block (fields.ts → firstImagePath), or a fields document's first
439
+ * (its cover). Null on every other block.
440
+ */
441
+ priority?: string | null;
442
+ }
443
+
444
+ /** Props of a block component: `const { block, cms } = Astro.props as BlockProps<…>`. */
445
+ export interface BlockProps<D = Record<string, unknown>> {
446
+ block: D;
447
+ cms: BlockContext;
448
+ }
449
+
450
+ /**
451
+ * Props of a fields collection's component (Decision 7): `document` is the
452
+ * document's render data (each field as fields.ts → renderData gives it, a missing
453
+ * reference as null) plus its `title`; `cms` is its render context, as a block's.
454
+ */
455
+ export interface FieldsDocumentProps<D = Record<string, unknown>> {
456
+ document: D & { title: string };
457
+ cms: BlockContext;
458
+ }
459
+
460
+ /** Props of the site's page component. */
461
+ export interface PageProps {
462
+ /** The document: a section builder's, a fields document's, or a shared block alone (its own preview, Decision 8). */
463
+ document: PageDocument;
464
+ /** The collection the document belongs to. */
465
+ collection: string;
466
+ /**
467
+ * MEC trial item 2: the document's address on the site (`/`, `/about`, `/work/harbour-coffee`),
468
+ * the same in a public render and in the editor's preview, so the page component never reads
469
+ * `Astro.url` or `?path=` itself. No trailing slash, not URL-encoded; a paged listing's page 2
470
+ * (`/journal/page/2`) is its document's `/journal` (`listPage` says which page). Null when the
471
+ * document has no page of its own: a shared block's preview, a collection without pages.
472
+ * RenderDocument always gives it (head.ts → documentPath).
473
+ */
474
+ path: string | null;
475
+ /** A preview render (Decision 17). Pass it on to `<Blocks>`. */
476
+ preview?: boolean;
477
+ /**
478
+ * M6c (G2): the document's own fields' render data, as a component gets them
479
+ * (fields.ts → renderData, a reference to a missing document as null): a section
480
+ * builder's page fields (the header's "side" picked on the page), or a fields
481
+ * document's fields. Empty for a shared block and for a section builder with no page
482
+ * fields. RenderDocument always gives it.
483
+ */
484
+ data: Record<string, unknown>;
485
+ /**
486
+ * M7 Decision 13: Site Settings' render data, for the page's shell (its menu, its footer):
487
+ * the site's own `settings: { fields }` as a block's are rendered (links as render values,
488
+ * media resolved, a reference to a missing document as null), plus `brand` (`name`, `logo`,
489
+ * `icon`) and `seo` (`title`, `description`, `image`, and the config's `titleSeparator`). `brand.name` is the site's name (the
490
+ * brand's, else the default search title, else "Site"). Read from the render's source, so a
491
+ * preview shows the draft's and a build the published. Never undefined; never `editors`,
492
+ * `formRecipients` or `googleTag`. RenderDocument always gives it (read.ts → settingsRenderData).
493
+ */
494
+ settings: Record<string, unknown>;
495
+ /**
496
+ * Posts and tags, Decision 12: the page of a paged listing this render is (1 by default, always 1
497
+ * in a preview). Pass it on to `<Blocks>` and `<Head>`, so a paged block shows its page and the
498
+ * title says " — page N".
499
+ */
500
+ listPage: number;
501
+ /**
502
+ * Posts and tags, Decision 12: the site's other documents, read from the render's source, the
503
+ * same reader a block's `cms.read` is (a post's page finds its neighbours here).
504
+ */
505
+ read: DocumentReader;
506
+ }
507
+
508
+ /** `h1` if this field holds the page's first heading, otherwise `h2`. */
509
+ export function headingTag(cms: BlockContext, field: string): 'h1' | 'h2' {
510
+ return cms.h1Field === field ? 'h1' : 'h2';
511
+ }
512
+
513
+ /** Is a field one that can hold the page's heading (Decision 16): a lockup, or a text or rich-text field (M9 Decision 11) marked `heading`? */
514
+ export const isHeadingField = (field: Field): boolean => (field.kind === 'lockup' || field.kind === 'text' || field.kind === 'richText') && !!field.heading;
515
+
516
+ /**
517
+ * Decision 16: the index of the first block, in render order (skipping unknown
518
+ * blocks), whose heading field is non-empty; -1 if none. A shared entry counts by
519
+ * the block it resolves to, when the caller gives them (`resolved`, by index).
520
+ */
521
+ export function firstHeadingIndex(document: SiteDocument, site: Pick<ResolvedSite, 'blocks'>, resolved: ReadonlyMap<number, DocumentBlock> = new Map()): number {
522
+ return document.blocks.findIndex((entry, index) => {
523
+ const block = resolved.get(index) ?? entry;
524
+ if (!block.known) return false;
525
+ const schema = own(site.blocks, block.type);
526
+ if (!schema?.headingField) return false;
527
+ // The heading field may sit in a group (`left.heading`).
528
+ const field = fieldAtPath(schema.fields, schema.headingField)?.field;
529
+ return !!field && !isEmptyValue(field, getAtPath(block.data, schema.headingField) as FieldValue<Field>);
530
+ });
531
+ }
532
+
533
+ /**
534
+ * The render context for every block of a document, in order (null for unknown
535
+ * blocks, which don't render). `resolved` gives a shared entry's block by its index
536
+ * (Blocks.astro resolves them through `read`); an entry without one doesn't render.
537
+ */
538
+ export function blockContexts(
539
+ document: SiteDocument,
540
+ site: Pick<ResolvedSite, 'blocks'>,
541
+ preview: boolean,
542
+ read: DocumentReader = EMPTY_READER,
543
+ resolved: ReadonlyMap<number, DocumentBlock> = new Map(),
544
+ listPage = 1,
545
+ ): (BlockContext | null)[] {
546
+ const first = firstHeadingIndex(document, site, resolved);
547
+ let rendered = 0;
548
+ return document.blocks.map((entry, index) => {
549
+ const block = resolved.get(index) ?? entry;
550
+ if (!block.known) return null;
551
+ const schema = own(site.blocks, block.type)!;
552
+ const h1Field = index === first ? schema.headingField : null;
553
+ const order = rendered++;
554
+ const media = { first: order === 0, eager: order < 2, priority: order === 0 ? firstImagePath(schema.fields, block.data) : null };
555
+ if (entry.shared !== undefined) return { index, type: block.type, preview: false, h1Field, collection: null, shared: { name: entry.shared, preview }, read, listPage, ...media };
556
+ return { index, type: block.type, preview, h1Field, collection: null, shared: null, read, listPage, ...media };
557
+ });
558
+ }
559
+
560
+ /**
561
+ * A fields document's heading field (Decision 7): the first heading field with a
562
+ * non-empty value, top-level ones in schema order first, then those inside groups
563
+ * (`left.heading`, fields.ts → headingPaths), or null (the page has no `h1`, as M3
564
+ * accepted). One path at most, so two groups' headings never make two h1s.
565
+ */
566
+ export function fieldsHeading(fields: Fields, data: Record<string, unknown>): string | null {
567
+ for (const path of headingPaths(fields)) {
568
+ const field = fieldAtPath(fields, path)?.field;
569
+ if (field && !isEmptyValue(field, getAtPath(data, path) as FieldValue<Field>)) return path;
570
+ }
571
+ return null;
572
+ }
573
+
574
+ /** A reader with nothing in it (a render with no source: tests, and the default). */
575
+ export const EMPTY_READER: DocumentReader = {
576
+ document: async () => null,
577
+ documents: async () => [],
578
+ exists: async () => false,
579
+ };