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,166 @@
1
+ /**
2
+ * The generated JSON Schemas (M8 Decision 14): one per collection, plus Site Settings and
3
+ * shared blocks, written to `schemas/` (`caracms schemas`, `init`, `validate --fix`, and
4
+ * `astro dev` at start). Each content file names its own with `$schema` (schema-key.ts),
5
+ * so an agent's or a person's editor checks and completes the file as it's typed.
6
+ *
7
+ * They describe the stored shapes (fields.ts, media-value.ts, rich-text.ts, seo.ts),
8
+ * strictly: a key the config doesn't know is refused, as `validate` reports it. They're a
9
+ * help for editing, never the check: `validate` judges content by the config itself
10
+ * (validate.ts), so a stale schema can't pass bad content. JSON Schema draft 2020-12.
11
+ *
12
+ * Pure (no file access): the CLI and the integration write what `siteSchemas` returns.
13
+ */
14
+ import type { ResolvedCollection, ResolvedSite } from './config.ts';
15
+ import type { Field, Fields } from './fields.ts';
16
+ import { siteCollections } from './config.ts';
17
+ import { SCHEMA_KEY, schemaFileName } from './schema-key.ts';
18
+
19
+ type Schema = Record<string, unknown>;
20
+
21
+ const DRAFT = 'https://json-schema.org/draft/2020-12/schema';
22
+ const string: Schema = { type: 'string' };
23
+ const strings: Schema = { type: 'array', items: string };
24
+ const size: Schema = { type: 'number', exclusiveMinimum: 0 };
25
+ const focal: Schema = { type: 'array', items: { type: 'number', minimum: 0, maximum: 1 }, minItems: 2, maxItems: 2 };
26
+
27
+ const described = (schema: Schema, field: { label?: string; help?: string }): Schema => ({
28
+ ...(field.label ? { title: field.label } : {}),
29
+ ...(field.help ? { description: field.help } : {}),
30
+ ...schema,
31
+ });
32
+
33
+ /** An object with exactly these keys allowed. */
34
+ const closed = (properties: Record<string, Schema>, required: string[] = []): Schema => ({
35
+ type: 'object',
36
+ properties,
37
+ ...(required.length ? { required } : {}),
38
+ additionalProperties: false,
39
+ });
40
+
41
+ const image: Schema = closed({ key: { type: 'string', minLength: 1 }, alt: string, decorative: { type: 'boolean' }, focal, w: size, h: size }, ['key']);
42
+ const clip: Schema = closed(
43
+ { video: { type: 'string', minLength: 1 }, play: { enum: ['loop', 'player'] }, poster: { type: 'number', minimum: 0 }, name: string, duration: size, w: size, h: size },
44
+ ['video'],
45
+ );
46
+ /** An image slot with no alt (the brand's logo and icon, the share image). */
47
+ const plainImage: Schema = closed({ key: { type: 'string', minLength: 1 }, w: size, h: size, focal }, ['key']);
48
+ const seo: Schema = closed({ title: string, description: string, image: plainImage });
49
+
50
+ function richText(headings: readonly string[], lists = true): Schema {
51
+ const kinds = ['p', ...headings];
52
+ const unit: Schema[] = [
53
+ ...kinds.map((k) => closed({ [k]: string }, [k])),
54
+ // M9 Decision 11: a heading rich-text field is paragraphs only.
55
+ ...(lists ? ['ul', 'ol'] : []).map((k) => closed({ [k]: strings }, [k])),
56
+ ];
57
+ // A plain string reads as rich text too (rich-text.ts → "A plain string is read as rich text").
58
+ return { anyOf: [string, { type: 'array', items: { oneOf: unit } }] };
59
+ }
60
+
61
+ /** A field's stored value (fields.ts → parseField reads it; fieldToJson writes it). */
62
+ export function fieldSchema(field: Field): Schema {
63
+ switch (field.kind) {
64
+ case 'text':
65
+ return described(string, field);
66
+ case 'lockup':
67
+ // Decision 10 (M2): the plain list, or `{ lines, phone }` once it has phone lines.
68
+ return described({ anyOf: [strings, closed({ lines: strings, phone: strings }, ['lines'])] }, field);
69
+ case 'select':
70
+ return described({ enum: field.options.map((o) => o.value) }, field);
71
+ case 'richText':
72
+ return described(richText(field.headings, !field.heading), field);
73
+ case 'link':
74
+ return described(field.addressOnly ? closed({ href: string }) : closed({ label: string, href: string }), field);
75
+ case 'buttons':
76
+ return described({ type: 'array', items: closed({ label: string, href: string, style: { enum: field.styles.map((s) => s.value) } }) }, field);
77
+ case 'toggle':
78
+ return described({ type: 'boolean' }, field);
79
+ case 'number':
80
+ return described({ type: ['number', 'null'] }, field);
81
+ case 'date':
82
+ return described({ anyOf: [{ type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, { type: 'null' }] }, field);
83
+ case 'reference':
84
+ return described({ type: ['string', 'null'], description: `The path of a document in "${field.to}".` }, field);
85
+ case 'media': {
86
+ const kinds = field.only === 'image' ? [image] : field.only === 'video' ? [clip] : [image, clip];
87
+ return described({ anyOf: [...kinds, { type: 'null' }] }, field);
88
+ }
89
+ case 'list': {
90
+ const items = field.item ? fieldSchema(field.item) : fieldsObject(field.fields ?? {});
91
+ return described({ type: 'array', items }, field);
92
+ }
93
+ case 'group':
94
+ return described(fieldsObject(field.fields), field);
95
+ }
96
+ }
97
+
98
+ /** Fields stored as one object (a group, a list item of several fields), every key optional. */
99
+ function fieldsObject(fields: Fields): Schema {
100
+ return closed(Object.fromEntries(Object.entries(fields).map(([key, f]) => [key, fieldSchema(f)])));
101
+ }
102
+
103
+ function blockDefs(site: Pick<ResolvedSite, 'blocks'>): Record<string, Schema> {
104
+ const defs: Record<string, Schema> = {};
105
+ for (const block of Object.values(site.blocks)) {
106
+ const properties: Record<string, Schema> = { type: { const: block.type } };
107
+ for (const [key, f] of Object.entries(block.fields)) properties[key] = fieldSchema(f);
108
+ defs[`block:${block.type}`] = { title: block.label, ...(block.help ? { description: block.help } : {}), ...closed(properties, ['type']) };
109
+ }
110
+ return defs;
111
+ }
112
+
113
+ /** Any of the site's blocks: `{ "type", …its fields }`. */
114
+ const anyBlock = (site: Pick<ResolvedSite, 'blocks'>): Schema => ({ anyOf: Object.keys(site.blocks).map((type) => ({ $ref: `#/$defs/block:${type}` })) });
115
+
116
+ /** A page's entry for a shared block (M5a Decision 8). */
117
+ const sharedEntry: Schema = { description: 'A shared block, by its path in content/shared.', ...closed({ shared: { type: 'string', minLength: 1 }, type: string }, ['shared']) };
118
+
119
+ function document(collection: ResolvedCollection, properties: Record<string, Schema>, required: string[], defs?: Record<string, Schema>): Schema {
120
+ return {
121
+ $schema: DRAFT,
122
+ title: collection.label,
123
+ ...closed({ [SCHEMA_KEY]: string, title: string, ...properties }, required),
124
+ ...(defs ? { $defs: defs } : {}),
125
+ };
126
+ }
127
+
128
+ /** A collection's schema (`schemas/<collection>.schema.json`, or the reserved two's names). */
129
+ export function collectionSchema(site: Pick<ResolvedSite, 'blocks'>, collection: ResolvedCollection): Schema {
130
+ const own = Object.fromEntries(Object.entries(collection.fields ?? {}).map(([key, f]) => [key, fieldSchema(f)]));
131
+ switch (collection.kind) {
132
+ case 'sections':
133
+ // M9 Decision 7: `hidden: true` keeps the page off the site; absent, it's shown.
134
+ const hidden: Schema = { description: 'Hidden from the site: not served, not in the sitemap, still edited in /admin.', const: true };
135
+ return document(collection, { hidden, ...own, blocks: { type: 'array', items: { anyOf: [{ $ref: '#/$defs/shared' }, anyBlock(site)] } }, seo }, ['title', 'blocks'], { shared: sharedEntry, ...blockDefs(site) });
136
+ case 'fields':
137
+ return document(collection, { ...own, seo }, ['title']);
138
+ case 'shared':
139
+ return document(collection, { block: anyBlock(site) }, ['title', 'block'], blockDefs(site));
140
+ case 'settings':
141
+ return document(
142
+ collection,
143
+ {
144
+ editors: { ...strings, description: 'The email addresses of the people who can sign in to the editor.' },
145
+ brand: closed({ name: string, accent: string, logo: { anyOf: [plainImage, { type: 'null' }] }, icon: { anyOf: [plainImage, { type: 'null' }] } }),
146
+ seo,
147
+ googleTag: string,
148
+ formRecipients: { type: 'object', additionalProperties: strings },
149
+ redirects: { type: 'array', items: closed({ from: string, to: string }, ['from', 'to']) },
150
+ ...own,
151
+ },
152
+ ['title'],
153
+ );
154
+ }
155
+ }
156
+
157
+ /** Every schema file's name and schema, in the site's collection order, then shared blocks and Site Settings. */
158
+ export function siteSchemas(site: Pick<ResolvedSite, 'blocks' | 'collections'>): Map<string, Schema> {
159
+ const out = new Map<string, Schema>();
160
+ for (const c of siteCollections(site)) out.set(schemaFileName(c), collectionSchema(site, c));
161
+ for (const c of Object.values(site.collections)) if (c.kind === 'shared' || c.kind === 'settings') out.set(schemaFileName(c), collectionSchema(site, c));
162
+ return out;
163
+ }
164
+
165
+ /** A schema file's text. */
166
+ export const schemaText = (schema: Schema): string => `${JSON.stringify(schema, null, 2)}\n`;
package/src/content.ts ADDED
@@ -0,0 +1,422 @@
1
+ /**
2
+ * The content reader: a collection's documents from its folder on disk.
3
+ *
4
+ * - **Read at request time, never imported** (Decision 5): plain `fs`, so dev keeps
5
+ * seeing what's on disk once content is out of Vite's watch.
6
+ * - **Pure enough to be the drafts store's local implementation** (Phase 2): the
7
+ * seam is `list`, `read` and `write`; `listDocuments` and `readDocument` are its
8
+ * `list` and `read`, and `resolveDocumentFile` is the confinement check the
9
+ * write route reuses. No caches, no module state.
10
+ * - **Pages nest** (Decision 8) in a collection with `nested: true`: the file's path
11
+ * is the URL, `index.json` at the top is the collection's `urlBase`, and an
12
+ * `index.json` any deeper is an error, listed with a warning and not opened.
13
+ * - **Order** (Decision 8): the reader provides it. `listDocuments` returns a nested
14
+ * collection in tree order (each level: Home first, then alphabetical by title,
15
+ * with a folder that has no page of its own sorted by its label), and a flat
16
+ * collection alphabetically by title. `pageTree` builds the tree itself.
17
+ * - A file that isn't valid JSON, or has no `title`, is listed with a warning and
18
+ * can't be opened (Decision 9).
19
+ * - **Two sources, one set of rules** (M4 Current state): the files come from a
20
+ * ContentSource, the disk by default (`diskSource`), or live mode's in-memory
21
+ * `drafts` copy (`filesSource`). Parsing, ordering and warnings are the same code
22
+ * either way, so a document never reads differently in the two modes.
23
+ * - **Every kind of document** (M5a): a collection's kind says how its files parse
24
+ * (document.ts → parseAnyDocument). The two reserved collections are read like any
25
+ * other: `shared` is a flat folder of shared blocks, `settings` holds exactly one
26
+ * document, `site-settings` (`content/site-settings.json`). Neither has URLs.
27
+ * - **"Used on"** (Decision 8): the full listing (every collection) gives each shared
28
+ * block the documents whose block list has an entry for it (`usedOn`), from the
29
+ * same source, so live mode's counts include autosaves.
30
+ */
31
+ import { readdir, readFile } from 'node:fs/promises';
32
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
33
+ import { SETTINGS_PATH, type ResolvedCollection, type ResolvedSite } from './config.ts';
34
+ import { titleCase } from './fields.ts';
35
+ import { isBuiltInColumn } from './columns.ts';
36
+ import { fieldsCover, sectionsCover, type CardCover } from './cards.ts';
37
+ import { own, parseAnyDocument, type AnyDocument, type DocumentKind } from './document.ts';
38
+ import { parseSource } from './json-source.ts';
39
+
40
+ // Posts and tags, Decision 14: a route's reader on the disk (read.ts).
41
+ export { contentReader } from './read.ts';
42
+ // M8 trial (item 1): Site Settings for a site's own routes, as the page component gets them.
43
+ export { siteSettings } from './read.ts';
44
+
45
+ export type WarningCode = 'invalid-json' | 'no-title' | 'nested-index';
46
+
47
+ export interface DocumentWarning {
48
+ code: WarningCode;
49
+ /** A plain sentence for the editor's list. */
50
+ message: string;
51
+ }
52
+
53
+ export interface DocumentEntry {
54
+ collection: string;
55
+ /** The kind of document (its collection's kind, M5a). The reader always sets it; absent reads as `sections`. */
56
+ kind?: DocumentKind;
57
+ /**
58
+ * The document's path inside its collection's folder, `/`-separated, without
59
+ * `.json`: `index`, `about`, `services/branding`, `my-first-post`. With the
60
+ * collection, it's the document's identity (and its `/admin/<collection>/<path>`).
61
+ */
62
+ path: string;
63
+ /** The file, relative to the site's root, `/`-separated. */
64
+ file: string;
65
+ /** The page's URL: `/`, `/services/branding`, `/journal/my-first-post`. Null for a nested `index.json`. */
66
+ url: string | null;
67
+ /** The document's `title`, or `''` if it has none or can't be read. */
68
+ title: string;
69
+ /** The title, or the file name title-cased when there's no title: what the editor lists. */
70
+ label: string;
71
+ /** Set when the file can't be opened. */
72
+ warning: DocumentWarning | null;
73
+ /**
74
+ * M9 Decision 7: hidden from the site, by the page's own `"hidden": true` or (`hiddenBy`, the
75
+ * path of the hidden page above it) a page it's nested under. A site's routes, the sitemap and
76
+ * `contentReader().documents()` leave it out; the editor lists it, marked. Never the home page.
77
+ */
78
+ hidden?: true;
79
+ hiddenBy?: string;
80
+ /** A section builder's shared-block entries, by shared block path, in block order, once each (Decision 8). Absent otherwise. */
81
+ uses?: string[];
82
+ /** A shared block: its block's type (`''` when it has none). Absent otherwise. */
83
+ blockType?: string;
84
+ /**
85
+ * A shared block, in a listing of every collection: the documents whose block list
86
+ * has an entry for it, in listing order (Decision 8's "used on"). Absent otherwise.
87
+ */
88
+ usedOn?: { collection: string; path: string }[];
89
+ /**
90
+ * A list-of-fields document whose collection names table `columns` (pre-M7 polish):
91
+ * those fields' values, as parsed, so the editor's table draws without opening each.
92
+ * Absent otherwise.
93
+ */
94
+ columns?: Record<string, unknown>;
95
+ /**
96
+ * M6c (G5): in a collection with the List / Grid switch, the document's card picture
97
+ * before it's signed (cards.ts, Decision 14): absent when it has none (a text card).
98
+ * The editor's listing swaps it for signed URLs (editor/server/cards.ts) on the way out.
99
+ */
100
+ cover?: CardCover;
101
+ }
102
+
103
+ export interface ReadDocument {
104
+ entry: DocumentEntry;
105
+ /** The file's text exactly as read: the serializer's base (serialize.ts → applyDocumentEdits). */
106
+ text: string;
107
+ document: AnyDocument;
108
+ }
109
+
110
+ export type DocumentErrorCode = WarningCode | 'not-found' | 'bad-path' | 'unknown-collection';
111
+
112
+ export class DocumentError extends Error {
113
+ readonly code: DocumentErrorCode;
114
+ constructor(code: DocumentErrorCode, message: string) {
115
+ super(message);
116
+ this.name = 'DocumentError';
117
+ this.code = code;
118
+ }
119
+ }
120
+
121
+ const posix = (p: string) => p.split(sep).join('/');
122
+
123
+ /**
124
+ * Where the reader's files come from. Paths are absolute, as on disk; an in-memory
125
+ * source maps them back to the site's own relative paths.
126
+ */
127
+ export interface ContentSource {
128
+ /** A file's text, or null when it doesn't exist. */
129
+ readText(file: string): Promise<string | null>;
130
+ /**
131
+ * The `.json` file names under a folder, relative to it and `/`-separated, in any
132
+ * order: every depth when `recursive`, else only the folder's own. Empty when the
133
+ * folder doesn't exist.
134
+ */
135
+ listJson(dir: string, recursive: boolean): Promise<string[]>;
136
+ }
137
+
138
+ /** The content files on disk (M3). */
139
+ export const diskSource: ContentSource = {
140
+ async readText(file) {
141
+ try {
142
+ return await readFile(file, 'utf8');
143
+ } catch (error) {
144
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
145
+ throw error;
146
+ }
147
+ },
148
+ async listJson(dir, recursive) {
149
+ let names: string[];
150
+ try {
151
+ names = (await readdir(dir, { recursive })) as string[];
152
+ } catch (error) {
153
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
154
+ throw error;
155
+ }
156
+ return names.map(posix).filter((n) => n.endsWith('.json'));
157
+ },
158
+ };
159
+
160
+ /**
161
+ * Files held in memory, keyed by their path relative to the site's root
162
+ * (`content/pages/index.json`): live mode's `drafts` copy. `files` is read on every
163
+ * call, so the source follows a copy that changes under it.
164
+ */
165
+ export function filesSource(root: string, files: () => ReadonlyMap<string, string>): ContentSource {
166
+ const rel = (file: string) => posix(relative(root, file));
167
+ return {
168
+ async readText(file) {
169
+ return files().get(rel(file)) ?? null;
170
+ },
171
+ async listJson(dir, recursive) {
172
+ const folder = rel(dir);
173
+ const prefix = folder === '' ? '' : `${folder}/`;
174
+ const out: string[] = [];
175
+ for (const path of files().keys()) {
176
+ if (!path.startsWith(prefix) || !path.endsWith('.json')) continue;
177
+ const inside = path.slice(prefix.length);
178
+ if (!recursive && inside.includes('/')) continue;
179
+ out.push(inside);
180
+ }
181
+ return out;
182
+ },
183
+ };
184
+ }
185
+
186
+ /** A document path's URL in its collection (`index` at the top of a nested collection is its base); null in the reserved collections. */
187
+ export function documentUrl(collection: ResolvedCollection, path: string): string | null {
188
+ if (collection.urlBase === null) return null;
189
+ if (collection.nested) {
190
+ if (path === 'index') return collection.urlBase;
191
+ if (path.endsWith('/index')) return null;
192
+ }
193
+ return collection.urlBase + path;
194
+ }
195
+
196
+ /**
197
+ * The absolute file for a document path, confined to the collection's folder.
198
+ * Refuses (DocumentError 'bad-path') an absolute path, `..`, an empty segment, a
199
+ * backslash, a dot-file, or anything that resolves outside the folder; and an
200
+ * undeclared collection ('unknown-collection'). Phase 2's write route uses this as
201
+ * its security check (Decision 5).
202
+ */
203
+ export function resolveDocumentFile(site: Pick<ResolvedSite, 'collections'>, collectionName: string, path: string): string {
204
+ const collection = own(site.collections, collectionName);
205
+ if (!collection) {
206
+ throw new DocumentError('unknown-collection', `No collection "${collectionName}" is declared.`);
207
+ }
208
+ const bad = () => new DocumentError('bad-path', `"${path}" isn't a document path in ${collectionName}.`);
209
+ if (typeof path !== 'string' || path.length === 0 || isAbsolute(path) || path.includes('\\') || path.includes('\0')) throw bad();
210
+ const segments = path.split('/');
211
+ if (segments.some((s) => s === '' || s === '.' || s === '..' || s.startsWith('.'))) throw bad();
212
+ if (!collection.nested && segments.length > 1) throw bad();
213
+ // Site Settings is one document (M4 Decision 15), however its folder is shared.
214
+ if (collection.kind === 'settings' && path !== SETTINGS_PATH) throw bad();
215
+ const file = resolve(collection.dir, `${path}.json`);
216
+ const inside = relative(collection.dir, file);
217
+ if (inside.startsWith('..') || isAbsolute(inside)) throw bad();
218
+ return file;
219
+ }
220
+
221
+ /** Every document in one collection, or in all of them (in config order), with warnings, in the editor's order. */
222
+ export async function listDocuments(
223
+ site: Pick<ResolvedSite, 'collections' | 'root' | 'blocks'>,
224
+ collectionName?: string,
225
+ source: ContentSource = diskSource,
226
+ ): Promise<DocumentEntry[]> {
227
+ const names = collectionName ? [collectionName] : Object.keys(site.collections);
228
+ const all: DocumentEntry[] = [];
229
+ for (const name of names) {
230
+ const collection = own(site.collections, name);
231
+ if (!collection) throw new DocumentError('unknown-collection', `No collection "${name}" is declared.`);
232
+ const paths = await documentPaths(collection, source);
233
+ const entries = (await Promise.all(paths.map((path) => entryFor(site, collection, path, source)))).filter((e) => e !== null);
234
+ if (collection.nested) hideChildren(entries);
235
+ all.push(...(collection.nested ? flattenTree(pageTree(entries)) : entries.sort(byLabel)));
236
+ }
237
+ if (!collectionName) {
238
+ // Decision 8's "used on": every document whose block list has an entry for the shared block.
239
+ for (const entry of all) {
240
+ if (entry.kind !== 'shared') continue;
241
+ entry.usedOn = all.filter((d) => d.uses?.includes(entry.path)).map((d) => ({ collection: d.collection, path: d.path }));
242
+ }
243
+ }
244
+ return all;
245
+ }
246
+
247
+ /** M9 Decision 7: a hidden page's children are hidden with it; `hiddenBy` names the topmost hidden page above. */
248
+ function hideChildren(entries: DocumentEntry[]): void {
249
+ const hidden = new Set(entries.filter((e) => e.hidden).map((e) => e.path));
250
+ if (hidden.size === 0) return;
251
+ for (const entry of entries) {
252
+ const parts = entry.path.split('/');
253
+ for (let n = 1; n < parts.length; n++) {
254
+ const above = parts.slice(0, n).join('/');
255
+ if (!hidden.has(above)) continue;
256
+ entry.hidden = true;
257
+ entry.hiddenBy = above;
258
+ break;
259
+ }
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Read one document. Throws DocumentError: 'not-found', 'bad-path',
265
+ * 'unknown-collection', or the listing's warning codes ('invalid-json',
266
+ * 'no-title', 'nested-index') for a file that can't be opened.
267
+ */
268
+ export async function readDocument(
269
+ site: Pick<ResolvedSite, 'collections' | 'root' | 'blocks'>,
270
+ collectionName: string,
271
+ path: string,
272
+ source: ContentSource = diskSource,
273
+ ): Promise<ReadDocument> {
274
+ const file = resolveDocumentFile(site, collectionName, path);
275
+ const collection = site.collections[collectionName]!;
276
+ const text = await source.readText(file);
277
+ if (text === null) throw new DocumentError('not-found', `${collection.label}: no document "${path}".`);
278
+ const { entry, document } = inspect(site, collection, path, text);
279
+ if (entry.warning || !document) throw new DocumentError(entry.warning!.code, entry.warning!.message);
280
+ return { entry, text, document };
281
+ }
282
+
283
+ /** Document paths in a collection's folder (sorted, `/`-separated, without `.json`). A missing folder is empty. */
284
+ async function documentPaths(collection: ResolvedCollection, source: ContentSource): Promise<string[]> {
285
+ const names = await source.listJson(collection.dir, collection.nested);
286
+ if (collection.kind === 'settings') return names.includes(`${SETTINGS_PATH}.json`) ? [SETTINGS_PATH] : [];
287
+ return names
288
+ .filter((n) => !n.split('/').some((s) => s.startsWith('.')))
289
+ .map((n) => n.slice(0, -'.json'.length))
290
+ .sort();
291
+ }
292
+
293
+ /** A listed path's entry; null when the file went away between listing and reading. */
294
+ async function entryFor(site: Pick<ResolvedSite, 'root' | 'blocks'>, collection: ResolvedCollection, path: string, source: ContentSource): Promise<DocumentEntry | null> {
295
+ const text = await source.readText(join(collection.dir, `${path}.json`));
296
+ return text === null ? null : inspect(site, collection, path, text).entry;
297
+ }
298
+
299
+ function inspect(
300
+ site: Pick<ResolvedSite, 'root' | 'blocks'>,
301
+ collection: ResolvedCollection,
302
+ path: string,
303
+ text: string,
304
+ ): { entry: DocumentEntry; document: AnyDocument | null } {
305
+ const base = {
306
+ collection: collection.name,
307
+ kind: collection.kind,
308
+ path,
309
+ file: posix(relative(site.root, join(collection.dir, `${path}.json`))),
310
+ url: documentUrl(collection, path),
311
+ };
312
+ const fileLabel = titleCase(path.split('/').pop()!);
313
+ const warn = (code: WarningCode, message: string, title = '') => ({
314
+ entry: { ...base, title, label: title || fileLabel, warning: { code, message } },
315
+ document: null,
316
+ });
317
+
318
+ if (collection.nested && path.endsWith('/index')) {
319
+ const parent = path.slice(0, -'/index'.length);
320
+ return warn('nested-index', `index.json only means the top of ${collection.label}. Rename it to ${parent}.json to make it the ${parent} page.`);
321
+ }
322
+ let document: AnyDocument;
323
+ let raw: unknown;
324
+ try {
325
+ raw = parseSource(text).value;
326
+ document = parseAnyDocument(raw, site, collection);
327
+ } catch (error) {
328
+ return warn('invalid-json', `This file isn't valid JSON (${error instanceof Error ? error.message : String(error)}).`);
329
+ }
330
+ const title = (raw as { title?: unknown } | null)?.title;
331
+ if (typeof title !== 'string' || title.trim() === '') {
332
+ return warn('no-title', 'This file has no title, so it can’t be opened. Add a "title".');
333
+ }
334
+ const entry: DocumentEntry = { ...base, title, label: title, warning: null };
335
+ // M9 Decision 7: the home page is never hidden.
336
+ if ((document.kind === undefined || document.kind === 'sections') && document.hidden && path !== 'index') entry.hidden = true;
337
+ if (document.kind === undefined || document.kind === 'sections') {
338
+ entry.uses = [...new Set(document.blocks.flatMap((b) => (b.shared !== undefined ? [b.shared] : [])))];
339
+ } else if (document.kind === 'shared') {
340
+ entry.blockType = document.block.type;
341
+ }
342
+ // A field column's values ride on the listing (pre-M7 polish); a section builder's page fields too (posts and tags, Decision 13).
343
+ if ((document.kind === 'fields' || document.kind === undefined || document.kind === 'sections') && collection.columns) {
344
+ const data = (document.data ?? {}) as Record<string, unknown>;
345
+ const keys = collection.columns.filter((k) => !isBuiltInColumn(k));
346
+ if (keys.length) entry.columns = Object.fromEntries(keys.map((k) => [k, data[k] ?? null]));
347
+ }
348
+ if (collection.view) {
349
+ const cover =
350
+ document.kind === 'fields'
351
+ ? fieldsCover(collection.fields ?? {}, document.data as Record<string, unknown>, collection.view.image)
352
+ : document.kind === undefined || document.kind === 'sections'
353
+ ? sectionsCover(collection.fields, document.data, document.blocks, site.blocks)
354
+ : null;
355
+ if (cover) entry.cover = cover;
356
+ }
357
+ return { entry, document };
358
+ }
359
+
360
+ // ── The Pages tree (Decision 8) ───────────────────────────────────────────────
361
+
362
+ export type PageTreeNode =
363
+ | { kind: 'page'; path: string; label: string; entry: DocumentEntry; children: PageTreeNode[] }
364
+ | {
365
+ /** A folder with no page of its own: shows or hides its children only. */
366
+ kind: 'group';
367
+ path: string;
368
+ /** The folder's name, hyphens as spaces, capital first letter (`case-studies` → "Case studies"). */
369
+ label: string;
370
+ children: PageTreeNode[];
371
+ };
372
+
373
+ /**
374
+ * A nested collection's entries as a tree that mirrors their paths. A page with
375
+ * pages under it (`about.json` beside `about/`) is one node with children; a folder
376
+ * with no page is a group. Each level is ordered Home (`index`) first, then
377
+ * alphabetically by label.
378
+ */
379
+ export function pageTree(entries: readonly DocumentEntry[]): PageTreeNode[] {
380
+ interface Draft {
381
+ path: string;
382
+ entry?: DocumentEntry;
383
+ children: Map<string, Draft>;
384
+ }
385
+ const top: Draft = { path: '', children: new Map() };
386
+ for (const entry of entries) {
387
+ const segments = entry.path.split('/');
388
+ let node = top;
389
+ segments.forEach((segment, i) => {
390
+ const path = segments.slice(0, i + 1).join('/');
391
+ let child = node.children.get(segment);
392
+ if (!child) {
393
+ child = { path, children: new Map() };
394
+ node.children.set(segment, child);
395
+ }
396
+ node = child;
397
+ });
398
+ node.entry = entry;
399
+ }
400
+ const build = (draft: Draft): PageTreeNode[] =>
401
+ [...draft.children.values()]
402
+ .map((d): PageTreeNode => {
403
+ const children = build(d);
404
+ if (d.entry) return { kind: 'page', path: d.path, label: d.entry.label, entry: d.entry, children };
405
+ return { kind: 'group', path: d.path, label: titleCase(d.path.split('/').pop()!), children };
406
+ })
407
+ .sort((a, b) => {
408
+ if (a.path === 'index' || a.path.endsWith('/index')) return -1;
409
+ if (b.path === 'index' || b.path.endsWith('/index')) return 1;
410
+ return byLabel(a, b);
411
+ });
412
+ return build(top);
413
+ }
414
+
415
+ function flattenTree(nodes: PageTreeNode[]): DocumentEntry[] {
416
+ return nodes.flatMap((n) => [...(n.kind === 'page' ? [n.entry] : []), ...flattenTree(n.children)]);
417
+ }
418
+
419
+ const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true });
420
+ function byLabel(a: { label: string; path: string }, b: { label: string; path: string }): number {
421
+ return collator.compare(a.label, b.label) || collator.compare(a.path, b.path);
422
+ }