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,84 @@
1
+ /**
2
+ * Runs of blocks (M9 Decision 9): the page component tells `<Blocks>` which neighbouring
3
+ * blocks go together and what's drawn round them, such as a band holding cards, each card
4
+ * holding several blocks (a Tina template's About page).
5
+ *
6
+ * <Blocks document={document} collection={collection} preview={preview} wrap={cardRuns} />
7
+ *
8
+ * // The page component's own rule, from the blocks' data:
9
+ * const cardRuns: WrapBlocks = (blocks) => …[{ from: 2, to: 6, component: Band, runs: [
10
+ * { from: 2, to: 4, component: Card, props: { id: 'story' } },
11
+ * { from: 5, to: 6, component: Card, props: { id: 'team' } },
12
+ * ] }];
13
+ *
14
+ * - `wrap` gets the blocks that render, in order: each one's document index, type and render
15
+ * data (what its component gets as `block`), so the rule can read a field (`card`, `anchor`).
16
+ * - A run is `from`..`to`, document indices, inclusive: everything between renders inside it,
17
+ * a skipped block's preview marker included. Its `component` is drawn round the run with
18
+ * `props`, the blocks in its default slot. `runs` are runs inside it (the cards in a band),
19
+ * each within it.
20
+ * - Runs are in order and don't overlap. A rule that breaks that is a mistake in the site's
21
+ * code, so the render stops with a sentence saying which runs.
22
+ * - Nothing about each block changes: its index, its context (`index`, `first`, `h1Field`,
23
+ * `eager`), its markers in a preview, so it's selected, typed into and morphed there as
24
+ * anywhere else. A wrapper carries no markers: it's the site's markup.
25
+ */
26
+
27
+ /** A block as `wrap` sees it. */
28
+ export interface WrapBlock {
29
+ /** Its index in the document (the editor's index, `cms.index`). */
30
+ index: number;
31
+ type: string;
32
+ /** Its render data: what its component gets as `block`. */
33
+ data: Record<string, unknown>;
34
+ }
35
+
36
+ /** A run of blocks and what's drawn round it. */
37
+ export interface BlockRun {
38
+ /** The first block's document index. */
39
+ from: number;
40
+ /** The last block's document index (inclusive). */
41
+ to: number;
42
+ /** An Astro component (or any component Astro renders): the run goes in its default slot. */
43
+ component: unknown;
44
+ props?: Record<string, unknown>;
45
+ /** Runs inside this one, each within `from`..`to`. */
46
+ runs?: readonly BlockRun[];
47
+ }
48
+
49
+ /** The page component's rule: the runs, from the blocks that render. */
50
+ export type WrapBlocks = (blocks: readonly WrapBlock[]) => readonly BlockRun[];
51
+
52
+ /** What `<Blocks>` draws, as a tree: an entry (by document index), or a run holding more. */
53
+ export type RunNode = { kind: 'entry'; index: number } | { kind: 'run'; component: unknown; props: Record<string, unknown>; children: RunNode[] };
54
+
55
+ const named = (run: BlockRun) => `the run ${run.from}–${run.to}`;
56
+
57
+ /**
58
+ * The document's entries (`indices`, in order: every block the render has an entry for)
59
+ * grouped by `runs`. Throws on runs out of order, overlapping, outside their parent, or
60
+ * with `from` after `to`.
61
+ */
62
+ export function runTree(indices: readonly number[], runs: readonly BlockRun[]): RunNode[] {
63
+ const place = (within: readonly number[], level: readonly BlockRun[], parent: BlockRun | null): RunNode[] => {
64
+ let previous: BlockRun | null = null;
65
+ for (const run of level) {
66
+ if (!Number.isInteger(run.from) || !Number.isInteger(run.to) || run.from > run.to) throw new Error(`<Blocks wrap>: ${named(run)} needs whole-number indices with from at or before to.`);
67
+ if (!run.component) throw new Error(`<Blocks wrap>: ${named(run)} has no component to draw round it.`);
68
+ if (previous && run.from <= previous.to) throw new Error(`<Blocks wrap>: ${named(run)} starts before ${named(previous)} ends. Runs are in document order and don't overlap.`);
69
+ if (parent && (run.from < parent.from || run.to > parent.to)) throw new Error(`<Blocks wrap>: ${named(run)} isn't inside ${named(parent)}, which holds it.`);
70
+ previous = run;
71
+ }
72
+ const out: RunNode[] = [];
73
+ let i = 0;
74
+ for (const run of level) {
75
+ while (i < within.length && within[i]! < run.from) out.push({ kind: 'entry', index: within[i++]! });
76
+ const inside: number[] = [];
77
+ while (i < within.length && within[i]! <= run.to) inside.push(within[i++]!);
78
+ out.push({ kind: 'run', component: run.component, props: run.props ?? {}, children: place(inside, run.runs ?? [], run) });
79
+ }
80
+ while (i < within.length) out.push({ kind: 'entry', index: within[i++]! });
81
+ return out;
82
+ };
83
+ return place(indices, runs, null);
84
+ }
package/src/cards.ts ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * M6c (G5): the grid view of a flat collection, package side. Pure, so the config's
3
+ * check (config.ts), the listing (content.ts) and the editor share one reading.
4
+ *
5
+ * - **Which collections get the switch** (Decision 13): a flat collection whose
6
+ * documents can hold an image. A fields collection with a media field anywhere in its
7
+ * fields (lists included); a section builder when any of the site's blocks, or its own
8
+ * page fields, has one. Never Pages (nested), Shared blocks or Site Settings.
9
+ * - **A card's picture** (Decision 14): the document's first image. A fields document:
10
+ * the first in schema order, or the field `view.image` names (a list of media gives its
11
+ * first item). A section builder's: its page fields first, then each block in order,
12
+ * shared entries and unknown blocks skipped. A clip (a loop too) stands as its poster.
13
+ * - **The config** (Decision 17): `view: { default?, image?, ratio? }`; defaults list, the
14
+ * first image, 4:3.
15
+ */
16
+ import { firstMedia, holdsMedia, type Fields } from './fields.ts';
17
+ import { parseMedia, renderMedia, type RenderMedia } from './media-value.ts';
18
+ import type { DocumentBlock } from './document.ts';
19
+
20
+ export const VIEW_MODES = ['list', 'grid'] as const;
21
+ export type ViewMode = (typeof VIEW_MODES)[number];
22
+
23
+ /** A card's picture ratios, as the config writes them. */
24
+ export const CARD_RATIOS = ['4:3', '3:2', '1:1', '16:9', '3:4'] as const;
25
+ export type CardRatio = (typeof CARD_RATIOS)[number];
26
+
27
+ /** `view` in a collection's config (Decision 17). */
28
+ export interface ViewConfig {
29
+ /** The view a collection opens in until an editor picks one (then the editor's own choice, Decision 18). Default `'list'`. */
30
+ default?: ViewMode;
31
+ /** A fields collection only: the top-level media field (or list of media) a card shows. Default: the first image. */
32
+ image?: string;
33
+ /** The card's picture ratio. Default `'4:3'`. */
34
+ ratio?: CardRatio;
35
+ }
36
+
37
+ /** A collection's view, checked and defaulted; on a collection only when it gets the switch. */
38
+ export interface ResolvedView {
39
+ default: ViewMode;
40
+ image: string | null;
41
+ ratio: CardRatio;
42
+ }
43
+
44
+ export const defaultResolvedView = (): ResolvedView => ({ default: 'list', image: null, ratio: '4:3' });
45
+
46
+ /** `'4:3'` → 4 / 3. */
47
+ export function ratioValue(ratio: CardRatio): number {
48
+ const [w, h] = ratio.split(':').map(Number);
49
+ return w! / h!;
50
+ }
51
+
52
+ /** Can a flat collection's documents hold an image (Decision 13)? `blocks`: every block's fields (a section builder can take any of them). */
53
+ export function collectionHoldsImages(collection: { kind: string; nested: boolean; fields: Fields | null }, blocks: Iterable<{ fields: Fields }>): boolean {
54
+ if (collection.nested) return false;
55
+ if (collection.kind === 'fields') return holdsMedia(collection.fields);
56
+ if (collection.kind !== 'sections') return false;
57
+ if (holdsMedia(collection.fields)) return true;
58
+ for (const b of blocks) if (holdsMedia(b.fields)) return true;
59
+ return false;
60
+ }
61
+
62
+ /** A card's picture before it's signed: an image's key, alt and focal point, or a clip's poster. */
63
+ export type CardCover =
64
+ | { kind: 'image'; key: string; alt: string; focal: [number, number] | null }
65
+ | { kind: 'clip'; playbackId: string; time: number | null; duration: number | null; name: string };
66
+
67
+ function coverOf(value: unknown, media: RenderMedia): CardCover {
68
+ if (media.kind === 'image') return { kind: 'image', key: media.key, alt: media.alt, focal: media.focal };
69
+ const stored = parseMedia(value);
70
+ const time = stored && 'video' in stored && typeof stored.poster === 'number' ? stored.poster : null;
71
+ return { kind: 'clip', playbackId: media.playbackId, time, duration: media.duration, name: media.name };
72
+ }
73
+
74
+ /** A fields document's cover: `view.image`'s field when named, else the first image in schema order. */
75
+ export function fieldsCover(fields: Fields, data: Record<string, unknown>, image: string | null): CardCover | null {
76
+ if (image !== null) {
77
+ const field = fields[image];
78
+ if (field?.kind === 'media') {
79
+ const media = renderMedia(data[image], { only: field.only, play: field.play });
80
+ return media ? coverOf(data[image], media) : null;
81
+ }
82
+ if (field?.kind === 'list' && field.item?.kind === 'media' && Array.isArray(data[image])) {
83
+ const item = field.item;
84
+ for (const v of data[image] as unknown[]) {
85
+ const media = renderMedia(v, { only: item.only, play: item.play });
86
+ if (media) return coverOf(v, media);
87
+ }
88
+ }
89
+ return null;
90
+ }
91
+ const found = firstMedia(fields, data);
92
+ return found ? coverOf(found.value, found.media) : null;
93
+ }
94
+
95
+ /** A section builder's document's cover: its page fields' first image, then each block's in order (shared entries and unknown blocks skipped). */
96
+ export function sectionsCover(pageFields: Fields | null, data: Record<string, unknown> | undefined, blocks: readonly DocumentBlock[], schemas: Record<string, { fields: Fields }>): CardCover | null {
97
+ if (pageFields && data) {
98
+ const found = firstMedia(pageFields, data);
99
+ if (found) return coverOf(found.value, found.media);
100
+ }
101
+ for (const block of blocks) {
102
+ if (block.shared !== undefined || !block.known) continue;
103
+ const schema = schemas[block.type];
104
+ if (!schema) continue;
105
+ const found = firstMedia(schema.fields, block.data);
106
+ if (found) return coverOf(found.value, found.media);
107
+ }
108
+ return null;
109
+ }
110
+
111
+ /**
112
+ * `view` from a collection's config, checked (Decision 17): null when the collection
113
+ * gets no switch (and then `view` is refused if set, with a sentence); else the
114
+ * defaulted view. `fields` is the collection's (a fields collection's, or a section
115
+ * builder's page fields).
116
+ */
117
+ export function checkView(
118
+ where: string,
119
+ raw: unknown,
120
+ collection: { kind: 'fields' | 'sections'; nested: boolean; fields: Fields | null },
121
+ blocks: Iterable<{ fields: Fields }>,
122
+ fail: (message: string) => never,
123
+ ): ResolvedView | null {
124
+ const switches = collectionHoldsImages(collection, blocks);
125
+ if (raw === undefined) return switches ? defaultResolvedView() : null;
126
+ if (collection.nested) fail(`${where}: view is for a flat collection's List / Grid switch; a nested one (Pages) is a tree.`);
127
+ if (!switches) {
128
+ fail(
129
+ collection.kind === 'fields'
130
+ ? `${where}: view: its documents have no media field, so there's nothing to show as a grid. Add a media() field, or take view out.`
131
+ : `${where}: view: none of the blocks it offers has a media field, so there's nothing to show as a grid. Take view out.`,
132
+ );
133
+ }
134
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) fail(`${where}: view must be an object, like { default: 'grid', ratio: '3:2' }.`);
135
+ const v = raw as Record<string, unknown>;
136
+ for (const key of Object.keys(v)) {
137
+ if (key !== 'default' && key !== 'image' && key !== 'ratio') fail(`${where}: view has no "${key}"; it takes default, image and ratio.`);
138
+ }
139
+ const view = defaultResolvedView();
140
+ if (v.default !== undefined) {
141
+ if (!(VIEW_MODES as readonly unknown[]).includes(v.default)) fail(`${where}: view.default must be 'list' or 'grid' (got ${JSON.stringify(v.default)}).`);
142
+ view.default = v.default as ViewMode;
143
+ }
144
+ if (v.ratio !== undefined) {
145
+ if (!(CARD_RATIOS as readonly unknown[]).includes(v.ratio)) fail(`${where}: view.ratio must be one of ${CARD_RATIOS.map((r) => `'${r}'`).join(', ')} (got ${JSON.stringify(v.ratio)}).`);
146
+ view.ratio = v.ratio as CardRatio;
147
+ }
148
+ if (v.image !== undefined) {
149
+ if (collection.kind !== 'fields') fail(`${where}: view.image is for a list-of-fields collection; a section builder's card shows the first image in its page fields or blocks.`);
150
+ if (typeof v.image !== 'string' || v.image === '') fail(`${where}: view.image must name one of its media fields (got ${JSON.stringify(v.image)}).`);
151
+ const field = collection.fields?.[v.image];
152
+ if (!field) fail(`${where}: view.image: "${v.image}" isn't one of its fields; name a media field, or a list of media.`);
153
+ const isMedia = field.kind === 'media' || (field.kind === 'list' && field.item?.kind === 'media');
154
+ if (!isMedia) fail(`${where}: view.image: "${v.image}" is a ${field.kind === 'list' ? 'list of fields' : `${field.kind} field`}; a card's picture comes from a media field, or a list of media (its first item).`);
155
+ view.image = v.image;
156
+ }
157
+ return view;
158
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * `caracms schemas` (M8 Decision 14): write `schemas/` from the config, and give every
3
+ * content file that lacks one its `$schema` key (or correct a wrong path). `init` and
4
+ * `validate --fix` run it; `astro dev` runs only the first half (`writeSchemaFiles`), so a
5
+ * dev run never touches content.
6
+ *
7
+ * `schemas/` is the package's: a `*.schema.json` there that the config no longer makes
8
+ * (a collection removed or renamed) is deleted. Nothing else in it is touched.
9
+ */
10
+ import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import type { ResolvedSite } from '../config.ts';
13
+ import { siteSchemas, schemaText } from '../content-schema.ts';
14
+ import { listDocuments } from '../content.ts';
15
+ import { SCHEMAS_FOLDER, schemaRef, stampSchema } from '../schema-key.ts';
16
+ import { loadSite } from './site.ts';
17
+
18
+ // `caracms init` loads the site through this module too (bin/init.mjs).
19
+ export { loadSite };
20
+
21
+ type Site = Pick<ResolvedSite, 'root' | 'blocks' | 'collections'>;
22
+
23
+ export interface SchemaFileChange {
24
+ /** Site-relative. */
25
+ file: string;
26
+ /** Null: the file goes (the config no longer makes it). */
27
+ text: string | null;
28
+ /** What's there now; null when it's new. */
29
+ before: string | null;
30
+ }
31
+
32
+ const read = (path: string): string | null => {
33
+ try {
34
+ return readFileSync(path, 'utf8');
35
+ } catch {
36
+ return null;
37
+ }
38
+ };
39
+
40
+ /** The schema files that differ from what the config makes: new, changed or gone. Empty when they're current. */
41
+ export function schemaFileChanges(site: Site): SchemaFileChange[] {
42
+ const out: SchemaFileChange[] = [];
43
+ const made = siteSchemas(site);
44
+ for (const [name, schema] of made) {
45
+ const file = `${SCHEMAS_FOLDER}/${name}`;
46
+ const text = schemaText(schema);
47
+ const before = read(join(site.root, file));
48
+ if (before !== text) out.push({ file, text, before });
49
+ }
50
+ let names: string[] = [];
51
+ try {
52
+ names = readdirSync(join(site.root, SCHEMAS_FOLDER));
53
+ } catch {
54
+ // No folder yet.
55
+ }
56
+ for (const name of names.sort()) {
57
+ if (!name.endsWith('.schema.json') || made.has(name)) continue;
58
+ const file = `${SCHEMAS_FOLDER}/${name}`;
59
+ out.push({ file, text: null, before: read(join(site.root, file)) });
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /** Write the schema files (astro dev's half). Returns what changed. */
65
+ export function writeSchemaFiles(site: Site): SchemaFileChange[] {
66
+ const changes = schemaFileChanges(site);
67
+ if (changes.length) mkdirSync(join(site.root, SCHEMAS_FOLDER), { recursive: true });
68
+ for (const c of changes) {
69
+ const path = join(site.root, c.file);
70
+ if (c.text === null) rmSync(path, { force: true });
71
+ else writeFileSync(path, c.text);
72
+ }
73
+ return changes;
74
+ }
75
+
76
+ export interface StampChange {
77
+ file: string;
78
+ text: string;
79
+ before: string;
80
+ }
81
+
82
+ /** Content files whose `$schema` is missing or wrong, with their stamped text. A file that isn't JSON is left alone. */
83
+ export async function stampChanges(site: Site): Promise<StampChange[]> {
84
+ const out: StampChange[] = [];
85
+ for (const entry of await listDocuments(site)) {
86
+ const before = read(join(site.root, entry.file));
87
+ if (before === null) continue;
88
+ const text = stampSchema(before, schemaRef(site.collections[entry.collection]!, entry.file));
89
+ if (text !== before) out.push({ file: entry.file, text, before });
90
+ }
91
+ return out;
92
+ }
93
+
94
+ export interface SchemasResult {
95
+ /** Schema files written or removed. */
96
+ schemas: SchemaFileChange[];
97
+ /** Content files given their `$schema`. */
98
+ stamped: string[];
99
+ }
100
+
101
+ /** The whole command, on a resolved site. */
102
+ export async function runSchemas(site: Site): Promise<SchemasResult> {
103
+ const schemas = writeSchemaFiles(site);
104
+ const stamps = await stampChanges(site);
105
+ for (const s of stamps) writeFileSync(join(site.root, s.file), s.text);
106
+ return { schemas, stamped: stamps.map((s) => s.file) };
107
+ }
108
+
109
+ /** What `caracms schemas` prints for a result. */
110
+ export function schemasReport(result: SchemasResult): string {
111
+ if (!result.schemas.length && !result.stamped.length) return 'caracms schemas: the schemas and every content file\'s $schema are up to date.\n';
112
+ const lines: string[] = [];
113
+ for (const c of result.schemas) lines.push(` ${c.text === null ? 'removed' : 'wrote'} ${c.file}`);
114
+ for (const f of result.stamped) lines.push(` added $schema to ${f}`);
115
+ return `caracms schemas:\n${lines.join('\n')}\n`;
116
+ }
117
+
118
+ /** The command's entry (bin/caracms.mjs loads this module through the site's Vite). */
119
+ export async function command(root: string): Promise<{ code: number; out: string }> {
120
+ const result = await runSchemas(await loadSite(root));
121
+ return { code: 0, out: schemasReport(result) };
122
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The site as the commands see it (M8): its `caracms.config.ts`, loaded and resolved as
3
+ * the integration resolves it, and its content files on disk. Loaded through the site's
4
+ * own Vite (bin/site-runner.mjs), so the config's TypeScript and its imports of
5
+ * `caracms/config` work on any Node the package supports.
6
+ */
7
+ import { existsSync, readdirSync, statSync } from 'node:fs';
8
+ import { join, relative, sep } from 'node:path';
9
+ import { resolveSite, type ResolvedSite, type SiteConfig } from '../config.ts';
10
+
11
+ /** The config file names a site may use, in the order they're looked for. */
12
+ export const CONFIG_FILES = ['caracms.config.ts', 'caracms.config.mts', 'caracms.config.mjs', 'caracms.config.js'] as const;
13
+
14
+ export class CommandError extends Error {}
15
+
16
+ export function configFile(root: string): string | null {
17
+ for (const name of CONFIG_FILES) if (existsSync(join(root, name))) return name;
18
+ return null;
19
+ }
20
+
21
+ /** The site's resolved config. Throws a CommandError with the config's own sentence when it's missing or wrong. */
22
+ export async function loadSite(root: string): Promise<ResolvedSite> {
23
+ const name = configFile(root);
24
+ if (!name) throw new CommandError(`there's no caracms.config.ts in ${root}. Run this from the site's root (run \`caracms init\` first on a site without caracms).`);
25
+ let config: SiteConfig;
26
+ try {
27
+ config = ((await import(/* @vite-ignore */ join(root, name))) as { default: SiteConfig }).default;
28
+ } catch (error) {
29
+ throw new CommandError(`${name} couldn't be loaded: ${(error as Error).message}`);
30
+ }
31
+ if (!config || typeof config !== 'object') throw new CommandError(`${name} has no default export. It should end \`export default defineSite({ … })\`.`);
32
+ try {
33
+ return resolveSite(config, root);
34
+ } catch (error) {
35
+ throw new CommandError((error as Error).message);
36
+ }
37
+ }
38
+
39
+ export const posix = (path: string): string => path.split(sep).join('/');
40
+
41
+ /** Every `.json` file under `dir` (absolute), site-relative and `/`-separated; dot folders and files skipped. */
42
+ export function jsonFilesUnder(root: string, dir: string): string[] {
43
+ const out: string[] = [];
44
+ const walk = (d: string) => {
45
+ let names: string[];
46
+ try {
47
+ names = readdirSync(d);
48
+ } catch {
49
+ return;
50
+ }
51
+ for (const n of names.sort()) {
52
+ if (n.startsWith('.')) continue;
53
+ const p = join(d, n);
54
+ let isDir = false;
55
+ try {
56
+ isDir = statSync(p).isDirectory();
57
+ } catch {
58
+ continue;
59
+ }
60
+ if (isDir) walk(p);
61
+ else if (n.endsWith('.json')) out.push(posix(relative(root, p)));
62
+ }
63
+ };
64
+ walk(dir);
65
+ return out;
66
+ }