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
package/src/config.ts ADDED
@@ -0,0 +1,825 @@
1
+ /**
2
+ * The site's config (Decision 3): everything the package learns about a site. The
3
+ * package names no block, no collection and no style; it reads them from here.
4
+ *
5
+ * A site writes `caracms.config.ts` with `defineSite({...})` and passes it to the
6
+ * integration in `astro.config.mjs`:
7
+ *
8
+ * import caracms from 'caracms';
9
+ * import site from './caracms.config.ts';
10
+ * export default defineConfig({ integrations: [caracms(site)] });
11
+ *
12
+ * The config is plain data: field descriptors, and components named by their path
13
+ * (relative to the site's root), never imported. That lets `astro.config.mjs` load
14
+ * it before Vite can compile `.astro` files, lets the integration validate it before
15
+ * the dev server or the build starts, and lets the integration hand it to the site's
16
+ * routes and the editor as one JSON blob (see index.ts → the virtual module).
17
+ */
18
+ import { existsSync } from 'node:fs';
19
+ import { isAbsolute, resolve } from 'node:path';
20
+ import { FIELD_KINDS, headingPaths, titleCase, type Field, type Fields } from './fields.ts';
21
+ import { DEFAULT_SHARED_FOLDER, RESERVED_COLLECTIONS, SETTINGS_KEYS, SITE_SETTINGS_FILE } from './reserved.ts';
22
+ import type { ResolvedTextStyle, TextStyle } from './text-styles.ts';
23
+ import type { CodeRoute } from './code-routes.ts';
24
+ import { checkForms, type FormConfig, type ResolvedForm } from './forms/rules.ts';
25
+
26
+ export * from './fields.ts';
27
+ export type { TextStyle, TextTransform, ResolvedTextStyle } from './text-styles.ts';
28
+ export { variationSettings } from './text-styles.ts';
29
+ import { COLUMN_FIELD_WORDS, DEFAULT_COLUMNS, columnKindOf, isBuiltInColumn, type BuiltInColumn, type SortConfig } from './columns.ts';
30
+ import { checkView, type ResolvedView, type ViewConfig } from './cards.ts';
31
+ export type { BuiltInColumn, SortConfig } from './columns.ts';
32
+ export type { FormConfig, FormFieldConfig, FormFieldType, ResolvedForm, ResolvedFormField } from './forms/rules.ts';
33
+
34
+ /** Decision 11's default: phone lines take over below 640px wide. */
35
+ export const DEFAULT_PHONE_LINES_WIDTH = 640;
36
+
37
+ /** A section builder (M3): a document is a title and a list of blocks. */
38
+ export interface SectionsCollectionConfig {
39
+ /** Shown in the editor. Defaults to the key, title-cased. */
40
+ label?: string;
41
+ kind: 'sections';
42
+ /** The folder holding the collection's content files, relative to the site's root. */
43
+ folder: string;
44
+ /** The URL the collection's documents live under: `/` for Pages, `/journal/` for a journal. */
45
+ urlBase: string;
46
+ /**
47
+ * Pages' rule (Decision 8): documents nest in folders and the file's path is the
48
+ * URL; `index.json` at the top of the folder is `urlBase` itself, and an
49
+ * `index.json` anywhere deeper is an error. Off: a flat list of `<slug>.json`.
50
+ */
51
+ nested?: boolean;
52
+ /**
53
+ * M6c (G2): the page's own fields, beside its blocks (a game's tile details, a
54
+ * header's "side"). Optional. Stored as top-level keys of the document's file,
55
+ * after `title` and before `blocks`, in schema order; edited in the sidebar at the
56
+ * document level, never on the page. The page component gets their render data as
57
+ * `data` (document.ts → PageProps), and `cms.read` as a ReadEntry's `data`.
58
+ * `title`, `blocks` and `seo` are the document's own keys, so no field takes them.
59
+ */
60
+ fields?: Fields;
61
+ /**
62
+ * The editor's table of the collection (a flat one, at its own level): the columns
63
+ * after the title, in order. Its page fields by key (Decision 13 of posts and tags:
64
+ * text, select, toggle, date, number, a reference or a list of references), and the
65
+ * built-ins `'address'` and `'edited'` (when it was last edited). Default:
66
+ * `['address', 'edited']`.
67
+ */
68
+ columns?: readonly string[];
69
+ /**
70
+ * The table's, the grid's and the sidebar list's order until an editor clicks a
71
+ * header (Decision 13): `'title'` or one of `columns` (not a list of references),
72
+ * either way. Default: the title, A to Z.
73
+ */
74
+ sort?: SortConfig;
75
+ /**
76
+ * M6c (G5): the editor's List / Grid switch on a flat collection (Decision 17):
77
+ * which view it opens in until an editor picks (`'list'`, the default, or `'grid'`)
78
+ * and the card picture's ratio (`'4:3'` by default). A card shows the first image in
79
+ * the page fields, then in the blocks.
80
+ */
81
+ view?: Omit<ViewConfig, 'image'>;
82
+ /**
83
+ * The blocks its pages offer, by type (`['full-image', 'image-pair', 'video']`): the
84
+ * picker shows only these, and only shared blocks of these types. Left out, every
85
+ * block. A stored block of another type is kept and still renders; the editor marks it.
86
+ */
87
+ blocks?: readonly string[];
88
+ }
89
+
90
+ /**
91
+ * A list of fields per document (M5a Decision 7): the file is `{ "title", ...fields }`,
92
+ * with keys in schema order. Flat, at `urlBase` + slug, or with no pages of their own
93
+ * (no `urlBase`, M6c G1).
94
+ */
95
+ export interface FieldsCollectionConfig<F extends Fields = Fields> {
96
+ label?: string;
97
+ kind: 'fields';
98
+ folder: string;
99
+ /**
100
+ * The documents' URLs are `urlBase` + their slug (`/work/harbour-coffee`). Leave it
101
+ * out for documents with no pages of their own (M6c G1: a team, awards, logos), read
102
+ * by other pages through references and `cms.read`: they have no URL, aren't in the
103
+ * sitemap, aren't offered as link targets, have no address column, no "Change
104
+ * address…" and no redirects. The editor still previews one through `component`
105
+ * in the page component, as a shared block's own preview is.
106
+ */
107
+ urlBase?: string;
108
+ /** Every document's fields, in the form's order. `title` is every document's own, so it can't be one of them. */
109
+ fields: F;
110
+ /**
111
+ * The Astro component that renders a document's body, relative to the site's root.
112
+ * It gets `{ document, cms }` (document.ts → FieldsDocumentProps); the site's page
113
+ * component renders it in the page's frame (its `<Blocks>` does).
114
+ */
115
+ component: string;
116
+ /**
117
+ * The editor's table of the collection (at its own level): the columns after the
118
+ * title, in order. Its fields by key (text, select, toggle, date or number: one short
119
+ * value each), and the built-ins `'address'` and `'edited'` (when it was last edited).
120
+ * Default: `['address', 'edited']` (`['edited']` with no `urlBase`, which refuses
121
+ * `'address'`). Select and toggle columns get a filter.
122
+ */
123
+ columns?: readonly (Extract<keyof F, string> | BuiltInColumn)[];
124
+ /**
125
+ * The table's, the grid's and the sidebar list's order until an editor clicks a
126
+ * header (posts and tags, Decision 13): `'title'` or one of `columns` (not a list of
127
+ * references), either way. Default: the title, A to Z.
128
+ */
129
+ sort?: { column: 'title' | Extract<keyof F, string> | BuiltInColumn; order: 'asc' | 'desc' };
130
+ /**
131
+ * M6c (G5): the editor's List / Grid switch (Decision 17), when the fields hold a media
132
+ * field: which view it opens in until an editor picks (`'list'`, the default, or
133
+ * `'grid'`), the media field (or list of media: its first item) a card shows
134
+ * (default: the first image in schema order), and the picture's ratio (`'4:3'`).
135
+ */
136
+ view?: ViewConfig & { image?: Extract<keyof F, string> };
137
+ }
138
+
139
+ export type CollectionConfig = SectionsCollectionConfig | FieldsCollectionConfig;
140
+
141
+ export interface BlockConfig<F extends Fields = Fields> {
142
+ /** Shown in the editor's block list and picker. Defaults to the key, title-cased. */
143
+ label?: string;
144
+ /**
145
+ * The block's Astro component, relative to the site's root. It gets
146
+ * `{ block, cms }`: the block's parsed data and its render context
147
+ * (see document.ts → BlockProps).
148
+ */
149
+ component: string;
150
+ fields: F;
151
+ /** M6c (G8): plain text at the top of the block's sidebar level, saying what it's for. Never on the page. */
152
+ help?: string;
153
+ }
154
+
155
+ /**
156
+ * The brand's logo and tab icon as URLs (M2 → Brand slots). Since M5b Decision 11 they're
157
+ * Site Settings' (`content/site-settings.json` → `brand.logo`, `brand.icon`, image values
158
+ * uploaded in the editor), which `brandFrom` resolves to signed URLs; the config's
159
+ * `brand.logo` and `brand.icon` are refused (`brandMediaRefusal`), as `brand.name` and
160
+ * `brand.accent` are (M5a Decision 10).
161
+ */
162
+ export interface BrandConfig {
163
+ /** The logo's URL on the site, e.g. `/logo.svg` (served from `public/`). */
164
+ logo?: string;
165
+ /** The tab icon's URL on the site, e.g. `/favicon.svg`. */
166
+ icon?: string;
167
+ }
168
+
169
+ /** The editor's four brand slots as they apply: the config's logo and icon, Site Settings' name and accent. */
170
+ export interface Brand extends BrandConfig {
171
+ /** Site Settings' `brand.name`; "Site" when it has none. */
172
+ name: string;
173
+ /** Site Settings' `brand.accent` (`#rgb` or `#rrggbb`); the editor's neutral accent when it has none. */
174
+ accent: string;
175
+ }
176
+
177
+ export { DEFAULT_SHARED_FOLDER, RESERVED_COLLECTIONS, SETTINGS_KEYS, SETTINGS_PATH, SITE_SETTINGS_FILE } from './reserved.ts';
178
+
179
+ export interface SiteConfig {
180
+ collections: Record<string, CollectionConfig>;
181
+ blocks: Record<string, BlockConfig>;
182
+ textStyles: Record<string, TextStyle>;
183
+ /**
184
+ * The site's page component, relative to the site's root. It renders a whole
185
+ * page from a document: `{ document, collection, preview }` (document.ts → PageProps).
186
+ * The public routes and the editor's preview both render through it.
187
+ */
188
+ page: string;
189
+ /**
190
+ * Refused in full since M5b: the brand's name, accent, logo and tab icon are all Site
191
+ * Settings' (`content/site-settings.json` → `brand`, M5a Decision 10, M5b Decision 11),
192
+ * so the editor can change them. An empty `brand: {}` is allowed.
193
+ */
194
+ brand?: BrandConfig;
195
+ /** The folder holding shared blocks, relative to the site's root (M5a Decision 8). Default `content/shared`. */
196
+ sharedFolder?: string;
197
+ /**
198
+ * Site Settings' own keys beyond the package's `title`, `editors` and `brand`
199
+ * (M5a Decision 10). They apply once published.
200
+ */
201
+ settings?: { fields: Fields };
202
+ /**
203
+ * Phone lines take over below this viewport width, in px (Decision 11). Above 375
204
+ * and at most 768. Set it to the site's own phone breakpoint. Default 640.
205
+ */
206
+ phoneLinesWidth?: number;
207
+ /**
208
+ * The site's folder inside its git repo, `/`-separated (M4 Decision 3). Default
209
+ * `'.'`: the site sits at the repo's root. Live mode joins it to the collection
210
+ * folders to find the site's files in git; on disk, folders stay relative to the
211
+ * site's root as before.
212
+ */
213
+ repoPath?: string;
214
+ /** The time zone the editor shows times in, an IANA name such as `Europe/Dublin` (M4 Decision 16). Default `UTC`. */
215
+ timeZone?: string;
216
+ /**
217
+ * The site's public forms, by id (M6 Decision 4): each a label, the email's subject
218
+ * and its fields in order. The public form is `Form.astro`; submissions go to the
219
+ * site's own bucket and the inbox. A form's id is never renamed once it's live.
220
+ */
221
+ forms?: Record<string, FormConfig>;
222
+ /**
223
+ * What joins a page's title and the site's name in the fallback `<title>`
224
+ * (`<document title><titleSeparator><brand name>`, head.ts), spaces included. Default
225
+ * `' · '`. Code, not a Site Settings field: a site's title format is design, not content.
226
+ * A site adopting caracms sets it to keep its old titles (` | `, ` - `). `siteSettings()`
227
+ * hands it to the site's own routes as `seo.titleSeparator`, so their titles match.
228
+ */
229
+ titleSeparator?: string;
230
+ /**
231
+ * What a page without its own `seo.title` falls back to (M9, head.ts). `'page-and-brand'`
232
+ * (the default): `<document title><titleSeparator><brand name>`, the home page excepted.
233
+ * `'site'`: every page falls back as the home page does, to Site Settings' `seo.title`,
234
+ * then the brand name, then the document title. For a site (a Tina template's, say)
235
+ * whose pages all show the site's own title.
236
+ */
237
+ titleFallback?: TitleFallback;
238
+ }
239
+
240
+ /** The config's `titleFallback` values (head.ts). */
241
+ export type TitleFallback = 'page-and-brand' | 'site';
242
+
243
+ /** The fallback title's separator when the config sets none (head.ts). */
244
+ export const DEFAULT_TITLE_SEPARATOR = ' · ';
245
+
246
+ /** Identity, for types: keeps each block's fields typed. */
247
+ export function defineSite<const C extends SiteConfig>(config: C): C {
248
+ return config;
249
+ }
250
+
251
+ // ── Resolved: what the integration validates and hands on ─────────────────────
252
+
253
+ /**
254
+ * A collection as the package uses it. `kind`:
255
+ * - `sections`, `fields`: the site's own (config order);
256
+ * - `shared`, `settings`: the two the package reserves (Decision 8), always present,
257
+ * after the site's. Shared blocks live in `sharedFolder`, one document per file.
258
+ * Site Settings is one document, `site-settings` (the file `content/site-settings.json`);
259
+ * its `dir` is that file's folder, but it holds nothing else.
260
+ * Neither reserved one has URLs.
261
+ */
262
+ export interface ResolvedCollection {
263
+ name: string;
264
+ label: string;
265
+ kind: 'sections' | 'fields' | 'shared' | 'settings';
266
+ /** As configured, relative to the root. */
267
+ folder: string;
268
+ /** Absolute. */
269
+ dir: string;
270
+ /** Always starts and ends with `/`. Null for the reserved collections, and a fields collection with no pages of its own (M6c G1). */
271
+ urlBase: string | null;
272
+ nested: boolean;
273
+ /**
274
+ * A fields collection's fields (labelled); a section builder's page fields (M6c G2,
275
+ * labelled), null when it has none; Site Settings' site-added fields; null otherwise.
276
+ */
277
+ fields: Fields | null;
278
+ /** A fields collection's component, absolute; null otherwise. */
279
+ component: string | null;
280
+ /** The editor's table columns after the title, as configured (checked); null for the default (columns.ts). */
281
+ columns: string[] | null;
282
+ /** The view's initial order, as configured (checked; Decision 13); null or absent for the title, A to Z. */
283
+ sort?: SortConfig | null;
284
+ /** M6c (G5): the List / Grid switch's view, checked and defaulted (cards.ts); null when the collection gets no switch. Absent reads as null. */
285
+ view?: ResolvedView | null;
286
+ /** A section builder's offered blocks, as configured (checked); null or absent for every block. */
287
+ blocks?: string[] | null;
288
+ }
289
+
290
+ /** A section builder's `blocks` option, checked: null when left out (every block). */
291
+ function checkOffered(where: string, raw: unknown, blocks: Record<string, ResolvedBlock>, fail: (message: string) => never): string[] | null {
292
+ if (raw === undefined) return null;
293
+ const example = Object.keys(blocks).slice(0, 2);
294
+ if (!Array.isArray(raw) || raw.some((t) => typeof t !== 'string')) fail(`${where}: blocks must be a list of block types, like ${JSON.stringify(example)}.`);
295
+ if (raw.length === 0) fail(`${where}: blocks is empty, so its pages could add nothing. Name the blocks they offer, or take blocks out to offer every block.`);
296
+ for (const type of raw as string[]) {
297
+ if (!Object.hasOwn(blocks, type)) fail(`${where}: blocks names "${type}", which isn't one of the site's blocks (${Object.keys(blocks).join(', ')}).`);
298
+ }
299
+ return [...new Set(raw as string[])];
300
+ }
301
+
302
+ /** The site's own collections, in config order (the reserved two left out). */
303
+ export function siteCollections(site: Pick<ResolvedSite, 'collections'>): ResolvedCollection[] {
304
+ return Object.values(site.collections).filter((c) => c.kind === 'sections' || c.kind === 'fields');
305
+ }
306
+
307
+ /** Is this a collection name the package reserves? */
308
+ export const isReserved = (name: string): boolean => (RESERVED_COLLECTIONS as readonly string[]).includes(name);
309
+
310
+ export interface ResolvedBlock {
311
+ type: string;
312
+ label: string;
313
+ /** Absolute path of the component. */
314
+ component: string;
315
+ fields: Fields;
316
+ /** The field holding the block's heading (Decision 16): the first top-level one marked as one, else the first inside a group (a dotted path, `left.heading`), or null. */
317
+ headingField: string | null;
318
+ /** M6c (G8): its help, shown at the top of its sidebar level; absent when it has none. */
319
+ help?: string;
320
+ }
321
+
322
+ export interface ResolvedSite {
323
+ /** The site's root, absolute. */
324
+ root: string;
325
+ collections: Record<string, ResolvedCollection>;
326
+ blocks: Record<string, ResolvedBlock>;
327
+ textStyles: Record<string, ResolvedTextStyle>;
328
+ /** Absolute path of the page component. */
329
+ page: string;
330
+ /** The logo and icon (the name and accent are Site Settings', read at request time: editor/server/settings.ts). */
331
+ brand: BrandConfig;
332
+ /** The shared-block folder, as configured, relative to the root. */
333
+ sharedFolder: string;
334
+ phoneLinesWidth: number;
335
+ /** The site's folder in its repo, normalised: `'.'` for the root, else `a/b` (no leading or trailing `/`). */
336
+ repoPath: string;
337
+ /** An IANA time zone name, checked with Intl. */
338
+ timeZone: string;
339
+ /** The forms, checked (M6 Decision 4), by id in config order; `{}` with none. */
340
+ forms: Record<string, ResolvedForm>;
341
+ /** The fallback title's separator, spaces included (`DEFAULT_TITLE_SEPARATOR` when the config sets none). */
342
+ titleSeparator: string;
343
+ /** What a page without its own `seo.title` falls back to (`'page-and-brand'` when the config sets none). */
344
+ titleFallback: TitleFallback;
345
+ /**
346
+ * M8 Decision 10: the URLs the site's own `src/pages/` serves (code-routes.ts), read by the
347
+ * integration at setup (and by the commands). Absent before then: nothing is reserved.
348
+ */
349
+ codeRoutes?: CodeRoute[];
350
+ }
351
+
352
+ export class CaracmsConfigError extends Error {
353
+ constructor(message: string) {
354
+ super(`caracms config: ${message}`);
355
+ this.name = 'CaracmsConfigError';
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Validate the config and resolve its paths against the site's root. Throws a
361
+ * CaracmsConfigError with a plain sentence for the first problem found; the
362
+ * integration calls this before the dev server or the build starts, so a bad
363
+ * config stops it with that sentence.
364
+ */
365
+ export function resolveSite(config: SiteConfig, root: string): ResolvedSite {
366
+ const fail = (message: string): never => {
367
+ throw new CaracmsConfigError(message);
368
+ };
369
+ const abs = (p: string) => (isAbsolute(p) ? p : resolve(root, p));
370
+ const mustExist = (p: string, what: string) => {
371
+ if (!existsSync(abs(p))) fail(`${what} "${p}" doesn't exist (looked in ${abs(p)}).`);
372
+ };
373
+
374
+ if (!config || typeof config !== 'object') fail('pass the site config to caracms(): caracms(site).');
375
+
376
+ // Decision 11.
377
+ const phoneLinesWidth = config.phoneLinesWidth ?? DEFAULT_PHONE_LINES_WIDTH;
378
+ if (typeof phoneLinesWidth !== 'number' || !Number.isFinite(phoneLinesWidth) || phoneLinesWidth <= 375 || phoneLinesWidth > 768) {
379
+ fail(
380
+ `phoneLinesWidth must be above 375 and at most 768 (got ${String(config.phoneLinesWidth)}), ` +
381
+ 'so the phone preview (375) always shows phone lines and tablet (768) and desktop never do.',
382
+ );
383
+ }
384
+
385
+ // Text styles.
386
+ const textStyles: Record<string, ResolvedTextStyle> = {};
387
+ for (const [name, style] of Object.entries(config.textStyles ?? {})) {
388
+ const where = `text style "${name}"`;
389
+ if (!(style.sizePx > 0)) fail(`${where}: sizePx must be a positive number.`);
390
+ if (!(style.minSizePx > 0) || style.minSizePx > style.sizePx) fail(`${where}: minSizePx must be positive and no larger than sizePx.`);
391
+ if (!style.cssVariable?.startsWith('--')) fail(`${where}: cssVariable must start with "--".`);
392
+ mustExist(style.fontFile.measure, `${where}: fontFile.measure`);
393
+ mustExist(style.fontFile.web, `${where}: fontFile.web`);
394
+ textStyles[name] = {
395
+ ...style,
396
+ name,
397
+ resolvedFontFile: { measure: abs(style.fontFile.measure), web: abs(style.fontFile.web) },
398
+ };
399
+ }
400
+
401
+ // Collections: the site's own first (config order), then the two the package reserves.
402
+ const collections: Record<string, ResolvedCollection> = {};
403
+ const dirs = new Map<string, string>();
404
+ const claimFolder = (name: string, folder: string) => {
405
+ const dir = abs(folder);
406
+ const other = dirs.get(dir);
407
+ const what = (n: string) => (n === 'shared' ? 'sharedFolder' : `collection "${n}"`);
408
+ if (other) fail(`${what(other)} and ${what(name)} share the folder "${folder}".`);
409
+ dirs.set(dir, name);
410
+ return dir;
411
+ };
412
+ const fieldsCollections: [string, FieldsCollectionConfig][] = [];
413
+ const pageFieldCollections: [string, Fields][] = [];
414
+ const sectionsCollections: [string, SectionsCollectionConfig][] = [];
415
+ for (const [name, c] of Object.entries(config.collections ?? {})) {
416
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) fail(`collection "${name}": its key must be lowercase letters, digits and hyphens (it's part of the editor's addresses).`);
417
+ if (isReserved(name)) fail(`collection "${name}": "shared" and "settings" are the package's own collections (shared blocks and Site Settings); name it something else.`);
418
+ if (name === 'submissions') fail(`collection "submissions": /admin/submissions is the editor's inbox (M6), so a collection can't be called that; name it something else.`);
419
+ if (c.kind !== 'sections' && c.kind !== 'fields') fail(`collection "${name}": kind must be 'sections' or 'fields' (got ${JSON.stringify((c as { kind?: unknown }).kind)}).`);
420
+ if (!c.folder) fail(`collection "${name}": folder is required.`);
421
+ const dir = claimFolder(name, c.folder);
422
+ // M6c (G1): a list-of-fields collection may have no pages of its own (no urlBase).
423
+ const pageless = c.kind === 'fields' && c.urlBase === undefined;
424
+ if (!pageless && (typeof c.urlBase !== 'string' || !c.urlBase.startsWith('/'))) {
425
+ fail(c.urlBase === undefined ? `collection "${name}": a section builder needs a urlBase, like '/' or '/journal/' (its documents are pages there).` : `collection "${name}": urlBase must start with "/".`);
426
+ }
427
+ const urlBase = pageless ? null : c.urlBase!.endsWith('/') ? c.urlBase! : `${c.urlBase}/`;
428
+ const label = c.label ?? titleCase(name);
429
+ if (c.kind === 'fields') {
430
+ if ((c as { nested?: unknown }).nested) fail(`collection "${name}": a list-of-fields collection is flat (one level of slugs under its urlBase); "nested" is for section builders.`);
431
+ if (!c.component) fail(`collection "${name}": component is required (it renders a document's body).`);
432
+ mustExist(c.component, `collection "${name}": component`);
433
+ if (!c.fields || typeof c.fields !== 'object') fail(`collection "${name}": fields is required.`);
434
+ fieldsCollections.push([name, c]);
435
+ collections[name] = { name, label, kind: 'fields', folder: c.folder, dir, urlBase, nested: false, fields: {}, component: abs(c.component), columns: null };
436
+ } else {
437
+ if (c.columns !== undefined && c.nested) fail(`collection "${name}": columns are for the editor's table of a flat collection; a nested one (Pages) is a tree.`);
438
+ if (c.sort !== undefined && c.nested) fail(`collection "${name}": sort is for the editor's table of a flat collection; a nested one (Pages) is a tree.`);
439
+ sectionsCollections.push([name, c]);
440
+ if (c.fields !== undefined) {
441
+ if (!c.fields || typeof c.fields !== 'object' || Array.isArray(c.fields)) fail(`collection "${name}": fields must be an object of fields, like { side: select({ options: ['left', 'right'] }) }.`);
442
+ pageFieldCollections.push([name, c.fields]);
443
+ }
444
+ collections[name] = { name, label, kind: 'sections', folder: c.folder, dir, urlBase, nested: c.nested ?? false, fields: null, component: null, columns: null };
445
+ }
446
+ }
447
+ if (Object.keys(collections).length === 0) fail('declare at least one collection.');
448
+ const ownCollections = { ...collections };
449
+
450
+ const settingsFolder = SITE_SETTINGS_FILE.slice(0, SITE_SETTINGS_FILE.lastIndexOf('/'));
451
+ // Site Settings is one file in that folder (SITE_SETTINGS_FILE); a collection owning the folder would treat it as one of its documents, and a write to it would go to that collection.
452
+ const settingsDir = abs(settingsFolder);
453
+ for (const c of Object.values(ownCollections)) {
454
+ if (c.dir === settingsDir) fail(`collection "${c.name}": its folder "${c.folder}" is where Site Settings lives (${SITE_SETTINGS_FILE}), so it would read Site Settings as one of its documents; give the collection a folder of its own, like "${c.folder}/${c.name}".`);
455
+ }
456
+
457
+ // Shared blocks (Decision 8): their own folder, which no collection may share.
458
+ const sharedFolder = config.sharedFolder ?? DEFAULT_SHARED_FOLDER;
459
+ if (typeof sharedFolder !== 'string' || sharedFolder === '') fail('sharedFolder must be a folder, relative to the site, like \'content/shared\'.');
460
+ const sharedDir = claimFolder('shared', sharedFolder);
461
+ if (sharedDir === settingsDir) fail(`sharedFolder "${sharedFolder}" is where Site Settings lives (${SITE_SETTINGS_FILE}); give shared blocks a folder of their own.`);
462
+ for (const c of Object.values(ownCollections)) {
463
+ const inside = (a: string, b: string) => a.startsWith(`${b}/`) || a.startsWith(`${b}\\`);
464
+ if (inside(sharedDir, c.dir) || inside(c.dir, sharedDir)) fail(`sharedFolder "${sharedFolder}" and collection "${c.name}"'s folder "${c.folder}" sit inside each other; give shared blocks a folder of their own.`);
465
+ }
466
+
467
+ // Fields: a fields collection's, then Site Settings' own (both may refer to any of the site's collections).
468
+ const fieldCheck = (where: string): FieldCheck => ({ fail, textStyles, collections: ownCollections, listDepth: 0, where });
469
+ for (const [name, c] of fieldsCollections) {
470
+ const where = `collection "${name}"`;
471
+ for (const [key, field] of Object.entries(c.fields)) {
472
+ if (key === 'title') fail(`${where}: "title" is every document's own field (the form's first, one line), so a field can't be called that.`);
473
+ if (key === 'seo') fail(`${where}: "seo" is every page's own search and sharing settings (M6 Decision 13), so a field can't be called that.`);
474
+ checkFieldKey(key, `${where}: field "${key}"`, fail);
475
+ validateField(field, `${where}: field "${key}"`, fieldCheck(where));
476
+ }
477
+ const columns = checkColumns(where, c.columns, c.fields, fail);
478
+ if (c.urlBase === undefined && columns?.includes('address')) fail(`${where}: its documents have no pages of their own (no urlBase), so there's no 'address' column.`);
479
+ const sort = checkSort(where, c.sort, columns ?? DEFAULT_COLUMNS.filter((k) => k !== 'address' || c.urlBase !== undefined), c.fields, fail);
480
+ collections[name] = { ...collections[name]!, fields: withLabels(c.fields), columns, sort };
481
+ }
482
+ // M6c (G2): a section builder's page fields, beside `title`, `blocks` and `seo` in the file.
483
+ for (const [name, fields] of pageFieldCollections) {
484
+ const where = `collection "${name}"`;
485
+ for (const [key, field] of Object.entries(fields)) {
486
+ if (key === 'title' || key === 'blocks' || key === 'seo') {
487
+ fail(`${where}: "${key}" is every page's own (its ${key === 'title' ? 'title' : key === 'blocks' ? 'list of blocks' : 'search and sharing settings'}), so a page field can't be called that.`);
488
+ }
489
+ checkFieldKey(key, `${where}: field "${key}"`, fail);
490
+ validateField(field, `${where}: field "${key}"`, fieldCheck(where));
491
+ }
492
+ if (Object.keys(fields).length) collections[name] = { ...collections[name]!, fields: withLabels(fields) };
493
+ }
494
+ // Posts and tags (Decision 13): a section builder's columns may name its page fields, so they're checked once those are.
495
+ for (const [name, c] of sectionsCollections) {
496
+ const where = `collection "${name}"`;
497
+ const fields = c.fields && Object.keys(c.fields).length ? c.fields : null;
498
+ const columns = checkColumns(where, c.columns, fields, fail);
499
+ const sort = checkSort(where, c.sort, columns ?? DEFAULT_COLUMNS, fields, fail);
500
+ collections[name] = { ...collections[name]!, columns, sort };
501
+ }
502
+ const settingsFields = config.settings?.fields ?? {};
503
+ for (const [key, field] of Object.entries(settingsFields)) {
504
+ if ((SETTINGS_KEYS as readonly string[]).includes(key)) fail(`settings: "${key}" is one of Site Settings' own keys (${SETTINGS_KEYS.join(', ')}); name the field something else.`);
505
+ checkFieldKey(key, `settings: field "${key}"`, fail);
506
+ validateField(field, `settings: field "${key}"`, fieldCheck('settings'));
507
+ }
508
+ collections.shared = { name: 'shared', label: 'Shared blocks', kind: 'shared', folder: sharedFolder, dir: sharedDir, urlBase: null, nested: false, fields: null, component: null, columns: null };
509
+ collections.settings = { name: 'settings', label: 'Site Settings', kind: 'settings', folder: settingsFolder, dir: settingsDir, urlBase: null, nested: false, fields: withLabels(settingsFields), component: null, columns: null };
510
+
511
+ // Blocks.
512
+ const blocks: Record<string, ResolvedBlock> = {};
513
+ for (const [type, b] of Object.entries(config.blocks ?? {})) {
514
+ const where = `block "${type}"`;
515
+ if (!b.component) fail(`${where}: component is required.`);
516
+ mustExist(b.component, `${where}: component`);
517
+ for (const [key, field] of Object.entries(b.fields ?? {})) {
518
+ if (key === 'type') fail(`${where}: "type" is reserved for the block's type; name the field something else.`);
519
+ if (key === 'shared') fail(`${where}: "shared" is reserved (a block-list entry \`{ "shared": … }\` uses a shared block); name the field something else.`);
520
+ checkFieldKey(key, `${where}: field "${key}"`, fail);
521
+ validateField(field, `${where}: field "${key}"`, { fail, textStyles, collections: ownCollections, listDepth: 0, where });
522
+ }
523
+ // The block's one heading field (M3 Decision 16): its first top-level heading, else the first inside a group
524
+ // (`left.heading`), so a block with a heading in each of two groups still gives the page one h1.
525
+ const headingField = headingPaths(b.fields ?? {})[0] ?? null;
526
+ // Every field gets its label (the key, title-cased, unless set), for the editor; a list's item fields too.
527
+ const fields = withLabels(b.fields ?? {});
528
+ if (b.help !== undefined && (typeof b.help !== 'string' || b.help.trim() === '')) fail(`${where}: help is a sentence of plain text, shown at the top of the block in the editor (got ${JSON.stringify(b.help)}).`);
529
+ blocks[type] = { type, label: b.label ?? titleCase(type), component: abs(b.component), fields, headingField, ...(b.help !== undefined ? { help: b.help } : {}) };
530
+ }
531
+
532
+ // M6c (G5): each flat collection's List / Grid switch, once the blocks are known (a section builder's cards can come from any of them).
533
+ for (const [name, c] of Object.entries(config.collections ?? {})) {
534
+ const resolved = collections[name]!;
535
+ const kind = resolved.kind === 'fields' ? 'fields' : 'sections';
536
+ const offered = kind === 'sections' ? checkOffered(`collection "${name}"`, (c as { blocks?: unknown }).blocks, blocks, fail) : null;
537
+ if (kind === 'fields' && (c as { blocks?: unknown }).blocks !== undefined) fail(`collection "${name}": blocks is for a section builder's picker; a list-of-fields collection has no blocks. Take it out.`);
538
+ const view = checkView(`collection "${name}"`, (c as { view?: unknown }).view, { kind, nested: resolved.nested, fields: resolved.fields }, offered ? offered.map((type) => blocks[type]!) : Object.values(blocks), fail);
539
+ collections[name] = { ...resolved, view, ...(offered ? { blocks: offered } : {}) };
540
+ }
541
+
542
+ // The page component.
543
+ if (!config.page) fail('page (the page component) is required.');
544
+ mustExist(config.page, 'page');
545
+
546
+ // Brand slots: all four are Site Settings' (M5a Decision 10 moved the name and the
547
+ // accent, M5b Decision 11 the logo and the icon), so the config's are refused.
548
+ const brand: BrandConfig = { ...(config.brand ?? {}) };
549
+ for (const key of ['name', 'accent'] as const) {
550
+ if (Object.hasOwn(brand, key)) {
551
+ fail(
552
+ `brand.${key} has moved to Site Settings (content/site-settings.json → "brand": { "name", "accent" }), so the editor can change it; ` +
553
+ `take it out of the config.`,
554
+ );
555
+ }
556
+ }
557
+ const mediaRefusal = brandMediaRefusal(brand);
558
+ if (mediaRefusal) fail(mediaRefusal);
559
+
560
+ // The site's folder in its repo (M4 Decision 3).
561
+ const repoPath = normaliseRepoPath(config.repoPath ?? '.');
562
+ if (repoPath === null) {
563
+ fail(`repoPath must be a folder inside the repo, relative and "/"-separated, like 'starter' or '.' (got ${JSON.stringify(config.repoPath)}).`);
564
+ }
565
+
566
+ // The time zone (M4 Decision 16).
567
+ const timeZone = config.timeZone ?? 'UTC';
568
+ if (!isTimeZone(timeZone)) fail(`timeZone must be an IANA time zone name like 'Europe/Dublin' or 'UTC' (got ${JSON.stringify(config.timeZone)}).`);
569
+
570
+ // The forms (M6 Decision 4): a bad entry stops the dev server and the build, naming the form and the field.
571
+ const forms = checkForms(config.forms, fail);
572
+
573
+ // The fallback title's separator (M8 trial, round 2): any text but an empty one or one with a line break.
574
+ const titleSeparator = config.titleSeparator ?? DEFAULT_TITLE_SEPARATOR;
575
+ if (typeof titleSeparator !== 'string' || !titleSeparator.trim() || /[\r\n]/.test(titleSeparator)) {
576
+ fail(`titleSeparator must be the text between a page's title and the site's name, spaces included, like ' | ' (got ${JSON.stringify(config.titleSeparator)}).`);
577
+ }
578
+
579
+ // What a page without its own title falls back to (M9): the page and the brand, or the site's title.
580
+ const titleFallback = config.titleFallback ?? 'page-and-brand';
581
+ if (titleFallback !== 'page-and-brand' && titleFallback !== 'site') {
582
+ fail(`titleFallback must be 'page-and-brand' (the page's title and the site's name) or 'site' (Site Settings' title on every page) (got ${JSON.stringify(config.titleFallback)}).`);
583
+ }
584
+
585
+ return { root, collections, blocks, textStyles, page: abs(config.page), brand, sharedFolder, phoneLinesWidth, repoPath: repoPath!, timeZone, forms, titleSeparator, titleFallback };
586
+ }
587
+
588
+ interface FieldCheck {
589
+ fail: (message: string) => never;
590
+ textStyles: Record<string, ResolvedTextStyle>;
591
+ collections: Record<string, ResolvedCollection>;
592
+ /**
593
+ * How many lists hold this field (M7 Decision 14: one level of list inside a list). At 2,
594
+ * inside an inner list, a list is refused.
595
+ */
596
+ listDepth: number;
597
+ /** The item of a list of one field: a list here is refused (`list(list(…))`). */
598
+ listItem?: boolean;
599
+ /** Inside a group: a group here is refused (the editor-friction batch: one level of sections). */
600
+ inGroup?: boolean;
601
+ /** Inside a group inside a list's item: a list here is refused (an inner list is one of the item's own fields). */
602
+ groupInList?: boolean;
603
+ /** Whose fields these are, for messages. */
604
+ where?: string;
605
+ }
606
+
607
+ /**
608
+ * M5b Decision 11: the config's `brand.logo` and `brand.icon` are refused once Site
609
+ * Settings holds them (the sentence names where they went), as `brand.name` is. Null
610
+ * when the config has neither. resolveSite fails with it.
611
+ */
612
+ export function brandMediaRefusal(brand: BrandConfig | undefined): string | null {
613
+ const key = (['logo', 'icon'] as const).find((k) => !!brand && Object.hasOwn(brand, k));
614
+ if (!key) return null;
615
+ return (
616
+ `brand.${key} has moved to Site Settings (content/site-settings.json → "brand": { "logo", "icon" }, uploaded in the editor's Site Settings), ` +
617
+ 'so the editor can change it; take it out of the config.'
618
+ );
619
+ }
620
+
621
+ /** A field's key: no dot (dots separate a path's parts, Decision 16), not a number. */
622
+ function checkFieldKey(key: string, where: string, fail: (message: string) => never): void {
623
+ if (key.includes('.')) fail(`${where} has a "." in its name; dots separate the parts of a field's path.`);
624
+ if (/^\d+$/.test(key)) fail(`${where} can't be a number (it would read as a list's index in a path).`);
625
+ }
626
+
627
+ /**
628
+ * One field's descriptor (M5a Decision 2): a kind the package knows, built with
629
+ * its helpers, and each kind's own rules. `where` names it in the sentence.
630
+ */
631
+ function validateField(field: Field, where: string, check: FieldCheck): void {
632
+ const { fail } = check;
633
+ if (!field || typeof field !== 'object' || !(FIELD_KINDS as readonly string[]).includes(field.kind)) {
634
+ fail(`${where} isn't a field the package knows (${FIELD_KINDS.join(', ')}). Build it with the package's field helpers.`);
635
+ }
636
+ // M6c (G8): help is plain text, said in the sidebar.
637
+ if (field.help !== undefined && (typeof field.help !== 'string' || field.help.trim() === '')) fail(`${where}: help is a sentence of plain text, shown under the field in the editor (got ${JSON.stringify(field.help)}).`);
638
+ switch (field.kind) {
639
+ case 'text':
640
+ // M9 Decision 10.
641
+ if (field.lines !== undefined && field.lines !== true) fail(`${where}: lines is true or left out (got ${JSON.stringify(field.lines)}).`);
642
+ if (field.lines && !field.multiline) fail(`${where}: lines draws each typed line as its own element, so the field takes several lines. Write text({ lines: true }) without multiline: false.`);
643
+ break;
644
+ case 'lockup':
645
+ if (!Object.hasOwn(check.textStyles, field.style)) fail(`${where} uses the text style "${field.style}", which isn't in textStyles.`);
646
+ break;
647
+ case 'select':
648
+ if (!Array.isArray(field.options) || field.options.length === 0) fail(`${where}: a select needs at least one option.`);
649
+ break;
650
+ case 'link':
651
+ if (field.addressOnly !== undefined && field.addressOnly !== true) fail(`${where}: addressOnly is true or left out (got ${JSON.stringify(field.addressOnly)}).`);
652
+ break;
653
+ case 'richText': {
654
+ const headings = Array.isArray(field.headings) ? field.headings : [];
655
+ const ok = headings.length === 0 || (headings.length === 1 && headings[0] === 'h2') || (headings.length === 2 && headings.includes('h2') && headings.includes('h3'));
656
+ if (!ok) {
657
+ fail(
658
+ `${where}: richText headings must be ['h2'] or ['h2', 'h3'] (got ${JSON.stringify(field.headings)}). ` +
659
+ "An h3 with no h2 above it breaks the page's outline, and the page's h1 is a block's heading field.",
660
+ );
661
+ }
662
+ // M9 Decision 11: a heading is bold and line breaks only.
663
+ if (field.heading !== undefined && field.heading !== true) fail(`${where}: heading is true or left out (got ${JSON.stringify(field.heading)}).`);
664
+ if (field.heading && headings.length > 0) fail(`${where}: a heading rich-text field can't hold headings of its own; it is the block's heading. Leave headings out.`);
665
+ if (field.heading && field.links) fail(`${where}: a heading rich-text field takes bold and line breaks only, not links. Leave links out.`);
666
+ break;
667
+ }
668
+ case 'buttons':
669
+ if (!Array.isArray(field.styles) || field.styles.length === 0) fail(`${where}: buttons need at least one style.`);
670
+ if (!Number.isInteger(field.max) || field.max < 1) fail(`${where}: buttons' max must be a whole number of at least 1 (got ${JSON.stringify(field.max)}).`);
671
+ if (field.allowNoLink !== undefined && field.allowNoLink !== true) fail(`${where}: allowNoLink is true or left out (got ${JSON.stringify(field.allowNoLink)}).`);
672
+ break;
673
+ case 'number': {
674
+ for (const k of ['min', 'max', 'step'] as const) {
675
+ const v = field[k];
676
+ if (v !== undefined && (typeof v !== 'number' || !Number.isFinite(v))) fail(`${where}: ${k} must be a number (got ${JSON.stringify(v)}).`);
677
+ }
678
+ if (field.min !== undefined && field.max !== undefined && field.min > field.max) fail(`${where}: min (${field.min}) is above max (${field.max}).`);
679
+ if (field.step !== undefined && !(field.step > 0)) fail(`${where}: step must be above 0.`);
680
+ break;
681
+ }
682
+ case 'reference':
683
+ if (typeof field.to !== 'string' || !Object.hasOwn(check.collections, field.to)) {
684
+ fail(`${where}: reference({ to: ${JSON.stringify(field.to)} }) must name one of the site's collections (${Object.keys(check.collections).join(', ')}).`);
685
+ }
686
+ break;
687
+ case 'list': {
688
+ // M7 Decision 14: a list of several fields may hold a list among its item fields, one level deep.
689
+ if (check.listItem) fail(`${where}: a list of one field can't be of a list. Make it a list of several fields, list({ … }), with the inner list as one of them.`);
690
+ if (check.listDepth >= 2) fail(`${where}: a list inside a list can't contain a list. One level of lists inside lists is the most.`);
691
+ if (check.groupInList) fail(`${where}: a list inside a list must be one of the item's own fields, not inside a group in it.`);
692
+ const inner = { ...check, listDepth: check.listDepth + 1, listItem: false, groupInList: false };
693
+ if (field.item && field.fields) fail(`${where}: a list is of one field or of several, not both.`);
694
+ if (field.item) {
695
+ if (field.item.kind === 'group') fail(`${where}: a list can't be of a group. A list of several fields, list({ … }), already keeps each item's fields together.`);
696
+ validateField(field.item, `${where}'s item`, { ...inner, listItem: true });
697
+ } else {
698
+ const entries = Object.entries(field.fields ?? {});
699
+ if (entries.length === 0) fail(`${where}: a list needs a field, or at least one field in its object.`);
700
+ for (const [key, sub] of entries) {
701
+ if (key.includes('.') || /^\d+$/.test(key)) fail(`${where}: item field "${key}" can't be a number or have a "." in its name (they'd read as part of a path).`);
702
+ validateField(sub, `${where}, item field "${key}"`, inner);
703
+ }
704
+ }
705
+ if (field.itemLabel !== undefined && !(field.fields && Object.hasOwn(field.fields, field.itemLabel))) {
706
+ fail(`${where}: itemLabel "${field.itemLabel}" isn't one of the list's item fields.`);
707
+ }
708
+ // M5b Decision 10: bounds that warn, never block.
709
+ for (const k of ['min', 'max'] as const) {
710
+ const v = field[k];
711
+ if (v !== undefined && (!Number.isInteger(v) || v < 0)) fail(`${where}: a list's ${k} must be a whole number, 0 or more (got ${JSON.stringify(v)}).`);
712
+ }
713
+ if (field.min !== undefined && field.max !== undefined && field.min > field.max) fail(`${where}: min (${field.min}) is above max (${field.max}).`);
714
+ break;
715
+ }
716
+ case 'group': {
717
+ if (check.inGroup) fail(`${where}: a group can't hold another group. Put its fields in this group, or make it a group of its own beside this one.`);
718
+ const entries = Object.entries(isPlainFields(field.fields) ? field.fields : {});
719
+ if (entries.length === 0) fail(`${where}: a group needs at least one field.`);
720
+ const inner = { ...check, inGroup: true, groupInList: check.listDepth > 0, listItem: false };
721
+ for (const [key, sub] of entries) {
722
+ if (key.includes('.') || /^\d+$/.test(key)) fail(`${where}: group field "${key}" can't be a number or have a "." in its name (they'd read as part of a path).`);
723
+ validateField(sub, `${where}, group field "${key}"`, inner);
724
+ }
725
+ break;
726
+ }
727
+ case 'media':
728
+ // M5b Decision 3.
729
+ if (field.only !== undefined && field.only !== 'image' && field.only !== 'video') fail(`${where}: media's only must be 'image' or 'video' (got ${JSON.stringify(field.only)}).`);
730
+ if (field.play !== undefined && field.play !== 'loop') fail(`${where}: media's play can only be 'loop' (a loop-only slot, like a hero's); leave it out to let each clip choose (got ${JSON.stringify(field.play)}).`);
731
+ if (field.play === 'loop' && field.only === 'image') fail(`${where}: play: 'loop' is for a slot that takes videos, but this one takes images only.`);
732
+ // M9 Decision 8.
733
+ if (field.format !== undefined && field.format !== 'webp' && field.format !== 'png') fail(`${where}: media's format must be 'png' or 'webp' (got ${JSON.stringify(field.format)}).`);
734
+ if (field.format !== undefined && field.only === 'video') fail(`${where}: format is for images, but this slot takes videos only.`);
735
+ break;
736
+ default:
737
+ break;
738
+ }
739
+ }
740
+
741
+ /** Fields with every label filled in (the key, title-cased, unless set), recursing into lists. */
742
+ /**
743
+ * A collection's `columns` (the editor's table, pre-M7 polish), checked: a list of its
744
+ * fields (a section builder's page fields) that show one short value, or the built-ins,
745
+ * each once; never `title` (always first). Undefined: null (the default columns).
746
+ */
747
+ function checkColumns(where: string, columns: unknown, fields: Fields | null, fail: (message: string) => never): string[] | null {
748
+ if (columns === undefined) return null;
749
+ const allowed = `${fields ? 'its fields that show one short value (text, select, toggle, date, number, a reference or a list of references), or ' : ''}'address' and 'edited'`;
750
+ if (!Array.isArray(columns)) return fail(`${where}: columns must be a list: ${allowed}.`);
751
+ const seen = new Set<string>();
752
+ for (const key of columns as unknown[]) {
753
+ if (typeof key !== 'string') return fail(`${where}: columns must be a list of names: ${allowed}.`);
754
+ if (key === 'title') fail(`${where}: columns: the title is always the table's first column, so leave "title" out.`);
755
+ if (seen.has(key)) fail(`${where}: columns names "${key}" twice.`);
756
+ seen.add(key);
757
+ if (isBuiltInColumn(key)) continue;
758
+ const field = fields?.[key];
759
+ if (!field) fail(`${where}: columns: "${key}" isn't one of ${allowed}.`);
760
+ else if (!columnKindOf(field)) fail(`${where}: columns: "${key}" is a ${field.kind === 'list' ? 'list' : field.kind} field; a column shows one short value, so it can be ${COLUMN_FIELD_WORDS}. Take "${key}" out of columns.`);
761
+ }
762
+ return [...(columns as string[])];
763
+ }
764
+
765
+ /**
766
+ * A collection's `sort` (posts and tags, Decision 13), checked against its columns
767
+ * (`columns` as configured, or the default): `title` or one of them, either way, but
768
+ * not a list of references (several titles don't order). Undefined: null (the title, A to Z).
769
+ */
770
+ function checkSort(where: string, sort: unknown, columns: readonly string[], fields: Fields | null, fail: (message: string) => never): SortConfig | null {
771
+ if (sort === undefined) return null;
772
+ const example = `{ column: 'title', order: 'asc' }`;
773
+ if (!sort || typeof sort !== 'object' || Array.isArray(sort)) return fail(`${where}: sort must be a column and an order, like ${example}.`);
774
+ const { column, order, ...rest } = sort as Record<string, unknown>;
775
+ const extra = Object.keys(rest)[0];
776
+ if (extra !== undefined) fail(`${where}: sort has no "${extra}"; it's a column and an order, like ${example}.`);
777
+ if (typeof column !== 'string' || column === '') return fail(`${where}: sort needs a column, like ${example}.`);
778
+ if (order !== 'asc' && order !== 'desc') fail(`${where}: sort's order must be 'asc' (A to Z, oldest or lowest first) or 'desc' (got ${JSON.stringify(order)}).`);
779
+ if (column !== 'title' && !columns.includes(column)) {
780
+ fail(`${where}: sort: "${column}" isn't one of its table's columns (${['title', ...columns].map((k) => `'${k}'`).join(', ')}). Add "${column}" to columns, or sort by one of those.`);
781
+ }
782
+ const field = fields?.[column];
783
+ if (field && columnKindOf(field) === 'references') fail(`${where}: sort: "${column}" is a list of references, which can't order the table (a document may have several). Sort by another column.`);
784
+ return { column, order: order as SortConfig['order'] };
785
+ }
786
+
787
+ function withLabels(fields: Fields): Fields {
788
+ return Object.fromEntries(Object.entries(fields).map(([key, field]) => [key, withLabel(field, key)]));
789
+ }
790
+
791
+ const isPlainFields = (value: unknown): value is Fields => !!value && typeof value === 'object' && !Array.isArray(value);
792
+
793
+ function withLabel(field: Field, key: string): Field {
794
+ const labelled = { ...field, label: field.label ?? titleCase(key) } as Field;
795
+ if (labelled.kind === 'group') return { ...labelled, fields: withLabels(labelled.fields) };
796
+ if (labelled.kind === 'list') {
797
+ if (labelled.fields) return { ...labelled, fields: withLabels(labelled.fields) };
798
+ if (labelled.item) return { ...labelled, item: { ...labelled.item, label: labelled.item.label ?? labelled.label } };
799
+ }
800
+ return labelled;
801
+ }
802
+
803
+ /** `'./starter/'` → `'starter'`, `''` or `'.'` → `'.'`; null for an absolute path, `..`, a backslash or an empty part. */
804
+ function normaliseRepoPath(value: unknown): string | null {
805
+ if (typeof value !== 'string') return null;
806
+ if (value.includes('\\') || value.includes('\0') || value.startsWith('/') || /^[a-zA-Z]:/.test(value)) return null;
807
+ const parts = value.split('/').filter((p, i, all) => !(p === '.' || (p === '' && (i === 0 || i === all.length - 1))));
808
+ if (parts.some((p) => p === '' || p === '..')) return null;
809
+ return parts.length ? parts.join('/') : '.';
810
+ }
811
+
812
+ function isTimeZone(value: unknown): value is string {
813
+ if (typeof value !== 'string' || value === '') return false;
814
+ try {
815
+ new Intl.DateTimeFormat('en', { timeZone: value });
816
+ return true;
817
+ } catch {
818
+ return false;
819
+ }
820
+ }
821
+
822
+ /** The prefix a site's file takes in its repo: `''` at the root, else `'starter/'`. */
823
+ export function repoPrefix(site: Pick<ResolvedSite, 'repoPath'>): string {
824
+ return site.repoPath === '.' ? '' : `${site.repoPath}/`;
825
+ }