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,134 @@
1
+ /**
2
+ * Code routes (M8 Decision 10): the URLs the site's own `src/pages/` serves, which a
3
+ * document can't take. A page is either code or a document, never both.
4
+ *
5
+ * - **Read from the file names**, as Astro's file routing reads them (`srcDir` honoured
6
+ * by the caller): `about.astro` is `/about`, `index.astro` its folder, `sitemap.xml.ts`
7
+ * `/sitemap.xml`, `[slug]` and `[...rest]` are parameters. Endpoints count too. A file
8
+ * or folder whose name starts with `_` or `.` isn't a route (Astro's rule).
9
+ * - **The documents' own routes reserve nothing:** a route with parameters whose static
10
+ * prefix is a collection's `urlBase` (the catch-all `[...path].astro` at `/`,
11
+ * `work/[slug].astro` at `/work/`).
12
+ * - **One per base.** When several routes with parameters sit at one collection's
13
+ * `urlBase`, the collection's own is the one Astro ranks last (`[...path]` below
14
+ * `[slug]`: a rest parameter ranks below a named one); every one ranked above it is
15
+ * code that takes the documents' addresses first. So the trial's `[slug].astro` beside
16
+ * `[...path].astro` at `/` is code: `validate` reports it as an error naming it and the
17
+ * collection (`shadowingRoutes`), and it reserves its addresses like any other route
18
+ * with parameters. A base's only route with parameters is always the collection's.
19
+ * - **Everything else is code.** A static route reserves exactly its URL; a route with
20
+ * parameters reserves everything under its static prefix (and the prefix itself when
21
+ * its first parameter is a rest parameter, which can be empty).
22
+ *
23
+ * The integration reads them once at setup (`site.codeRoutes`, so the editor's server and
24
+ * browser both have them); `validate` reads them from disk itself (cli/validate.ts). This
25
+ * module is pure and browser-safe: the reading of the folder is `code-routes-disk.ts`.
26
+ */
27
+
28
+ /** An address compared with others: lower case, no trailing slash (slug.ts → sameAddress, kept here so slug.ts can import this). */
29
+ const sameAddress = (url: string): string => {
30
+ const lower = url.toLowerCase();
31
+ return lower.length > 1 ? lower.replace(/\/+$/, '') : lower;
32
+ };
33
+
34
+ export interface CodeRoute {
35
+ /** The route's file, relative to the site's root, `/`-separated (`src/pages/about.astro`). */
36
+ file: string;
37
+ /** A static route's URL, or a route with parameters' static prefix, ending in `/` (`/work/`). */
38
+ url: string;
39
+ /** True for a route with parameters: it reserves everything under `url`. */
40
+ dynamic: boolean;
41
+ /** A dynamic route whose first parameter is a rest one (`[...path]`): it also matches `url` itself without its slash. */
42
+ rest: boolean;
43
+ }
44
+
45
+ /** The file extensions Astro routes from `src/pages/` (pages and endpoints). */
46
+ export const ROUTE_EXTENSIONS = ['.astro', '.md', '.mdx', '.markdown', '.html', '.ts', '.js', '.mjs', '.mts'] as const;
47
+
48
+ /**
49
+ * A file under the pages folder as its route, or null when it isn't one. `path` is the
50
+ * file's path inside the pages folder (`work/[slug].astro`); `file` is how to name it.
51
+ */
52
+ export function routeOf(path: string, file: string): CodeRoute | null {
53
+ const parts = path.split('/').filter(Boolean);
54
+ if (parts.length === 0 || parts.some((p) => p.startsWith('_') || p.startsWith('.'))) return null;
55
+ const name = parts[parts.length - 1]!;
56
+ if (/\.d\.[mc]?ts$/.test(name)) return null;
57
+ const ext = ROUTE_EXTENSIONS.find((e) => name.endsWith(e));
58
+ if (!ext) return null;
59
+ const base = name.slice(0, -ext.length);
60
+ const segments = [...parts.slice(0, -1), ...(base === 'index' ? [] : [base])];
61
+ const firstParam = segments.findIndex((s) => s.includes('['));
62
+ if (firstParam === -1) return { file, url: `/${segments.join('/')}`, dynamic: false, rest: false };
63
+ const prefix = segments.slice(0, firstParam);
64
+ const url = prefix.length ? `/${prefix.join('/')}/` : '/';
65
+ return { file, url, dynamic: true, rest: /^\[\.\.\.[^\]]+\]$/.test(segments[firstParam]!) };
66
+ }
67
+
68
+ /**
69
+ * Astro's order for routes with parameters at the same static prefix, highest first: a
70
+ * named parameter above a rest one, more segments above fewer, then by name (Astro's own
71
+ * tie-break). Negative when `a` ranks above `b`.
72
+ */
73
+ function astroRank(a: CodeRoute, b: CodeRoute): number {
74
+ if (a.rest !== b.rest) return a.rest ? 1 : -1;
75
+ const depth = (r: CodeRoute) => r.file.replace(/\/index\.[^/]+$/, '').split('/').length;
76
+ if (depth(a) !== depth(b)) return depth(b) - depth(a);
77
+ return a.file < b.file ? -1 : a.file > b.file ? 1 : 0;
78
+ }
79
+
80
+ /** A route with parameters at a collection's `urlBase` that isn't the collection's own: Astro ranks it above `own`, so it serves the documents' addresses first. */
81
+ export interface ShadowingRoute<C> {
82
+ route: CodeRoute;
83
+ /** The collection's own route at that base (the one Astro ranks last). */
84
+ own: CodeRoute;
85
+ collection: C;
86
+ }
87
+
88
+ /** Every route with parameters that sits at a collection's base above the collection's own (see the header: one per base). */
89
+ export function shadowingRoutes<C extends { urlBase: string | null }>(routes: readonly CodeRoute[], collections: readonly C[]): ShadowingRoute<C>[] {
90
+ const out: ShadowingRoute<C>[] = [];
91
+ const seen = new Set<string>();
92
+ for (const collection of collections) {
93
+ if (collection.urlBase === null) continue;
94
+ const base = sameAddress(collection.urlBase);
95
+ if (seen.has(base)) continue;
96
+ seen.add(base);
97
+ const at = routes.filter((r) => r.dynamic && sameAddress(r.url) === base).sort(astroRank);
98
+ const own = at[at.length - 1];
99
+ for (const route of at.slice(0, -1)) out.push({ route, own: own!, collection });
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /** The routes that reserve URLs: every route but the collections' own (the last-ranked route with parameters at a collection's `urlBase`). */
105
+ export function codeRoutesFrom(routes: readonly CodeRoute[], collections: readonly { urlBase: string | null }[]): CodeRoute[] {
106
+ const bases = new Set(collections.map((c) => c.urlBase).filter((b): b is string => b !== null).map((b) => sameAddress(b)));
107
+ const shadowing = new Set(shadowingRoutes(routes, collections).map((s) => s.route));
108
+ return routes.filter((r) => shadowing.has(r) || !(r.dynamic && bases.has(sameAddress(r.url))));
109
+ }
110
+
111
+ /** validate's sentence for a route that shadows a collection's documents. */
112
+ export const shadowingMessage = (s: ShadowingRoute<{ name: string; urlBase: string | null }>): string =>
113
+ `${s.route.file} is a second route with parameters at the ${s.collection.name} collection's address (${s.collection.urlBase}), beside its own ${s.own.file}. Astro ranks it first, so it serves the addresses of that collection's documents and hides them. Move it under a folder of its own, or delete it if the documents replace it.`;
114
+
115
+ /** The code route that serves `url` (a public address), or null. */
116
+ export function codeRouteAt(url: string, routes: readonly CodeRoute[] | undefined): CodeRoute | null {
117
+ if (!routes?.length) return null;
118
+ const at = sameAddress(url);
119
+ // A static route first: it's the more specific (Astro's own priority).
120
+ const exact = routes.find((r) => !r.dynamic && sameAddress(r.url) === at);
121
+ if (exact) return exact;
122
+ return (
123
+ routes.find((r) => {
124
+ if (!r.dynamic) return false;
125
+ const prefix = r.url.toLowerCase();
126
+ if (prefix === '/') return at !== '/' || r.rest;
127
+ return at.startsWith(prefix) || (r.rest && at === sameAddress(prefix));
128
+ }) ?? null
129
+ );
130
+ }
131
+
132
+ /** The editor's and validate's sentence for a document at a code route's URL. */
133
+ export const codeRouteMessage = (url: string, route: CodeRoute): string =>
134
+ `${url} is served by the site's own code (${route.file}), so a page can't go there.`;
package/src/columns.ts ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * A collection's table columns (pre-M7 polish: a flat collection's documents as a
3
+ * table over the editor's stage). The config names them per collection (`columns`):
4
+ * its fields by key (a section builder's page fields too), of the kinds that show as one
5
+ * short value, and two built-ins. Posts and tags (Decision 13) add a reference, shown as
6
+ * the referenced document's title, and a list of references, its titles joined by commas.
7
+ * Shared by the config's check (config.ts) and the editor's table
8
+ * (editor/client/collections/table.ts), so the two agree on what a column can be.
9
+ */
10
+
11
+ /** Built-in columns: the document's address, and when it was last edited. */
12
+ export const BUILT_IN_COLUMNS = ['address', 'edited'] as const;
13
+ export type BuiltInColumn = (typeof BUILT_IN_COLUMNS)[number];
14
+
15
+ /** Without `columns`: the title (always first), the address and when it was last edited. */
16
+ export const DEFAULT_COLUMNS: readonly BuiltInColumn[] = ['address', 'edited'];
17
+
18
+ /** The field kinds a column can show. */
19
+ export const COLUMN_FIELD_KINDS = ['text', 'select', 'toggle', 'date', 'number'] as const;
20
+
21
+ /** What a column field can be, in words, for the config's refusals. */
22
+ export const COLUMN_FIELD_WORDS = 'a text, select, toggle, date, number or reference field, or a list of references';
23
+
24
+ /** A field's column kind: one of COLUMN_FIELD_KINDS, `reference`, `references` (a list of references), or null when it can't be a column. */
25
+ export function columnKindOf(field: { kind: string; item?: { kind: string } | null }): (typeof COLUMN_FIELD_KINDS)[number] | 'reference' | 'references' | null {
26
+ if ((COLUMN_FIELD_KINDS as readonly string[]).includes(field.kind)) return field.kind as (typeof COLUMN_FIELD_KINDS)[number];
27
+ if (field.kind === 'reference') return 'reference';
28
+ if (field.kind === 'list' && field.item?.kind === 'reference') return 'references';
29
+ return null;
30
+ }
31
+
32
+ /** A collection's initial order (Decision 13): `title` or one of its columns, either way. */
33
+ export interface SortConfig {
34
+ column: string;
35
+ order: 'asc' | 'desc';
36
+ }
37
+
38
+ export const isBuiltInColumn = (key: string): key is BuiltInColumn => (BUILT_IN_COLUMNS as readonly string[]).includes(key);
39
+ export const isColumnField = (field: { kind: string; item?: { kind: string } | null }) => columnKindOf(field) !== null;
@@ -0,0 +1,46 @@
1
+ ---
2
+ /**
3
+ * A block's root element. Use it as the outermost element of every block
4
+ * component; it adds the block's markers in preview renders (Decision 17) and
5
+ * nothing at all in the public render.
6
+ *
7
+ * <Block cms={cms} as="section" class="hero">…</Block>
8
+ *
9
+ * Preview markers: `data-cms-block="<index>"` (the block's position in the
10
+ * document, the editor's index) and `data-cms-type="<type>"`. A click maps to its
11
+ * block with `el.closest('[data-cms-block]')`, and to its field with
12
+ * `el.closest('[data-cms-field]')`; a lockup's line set with `[data-cms-lines]`.
13
+ *
14
+ * M5a:
15
+ * - **A fields document's component** uses it as its root too (Decision 7): there it
16
+ * carries `data-cms-document="<collection>"` in place of `data-cms-block` (Decision
17
+ * 16), and the fields' markers sit under it.
18
+ * - **A shared block on a page** (Decision 8) is never edited there, so its fields have
19
+ * no markers; in a preview its root still carries `data-cms-block="<index>"`,
20
+ * `data-cms-type` and `data-cms-shared="<name>"`, so the editor can outline and
21
+ * select it.
22
+ */
23
+ import type { HTMLTag } from 'astro/types';
24
+ import type { BlockContext } from '../document.ts';
25
+ import { passThrough } from './attributes.ts';
26
+
27
+ interface Props {
28
+ cms: BlockContext;
29
+ as?: HTMLTag;
30
+ class?: string;
31
+ [attribute: string]: unknown;
32
+ }
33
+
34
+ const { cms, as: Tag = 'section', class: className, ...given } = Astro.props;
35
+ // M8 trial (item 4): a bare attribute stays bare, false is none (attributes.ts).
36
+ const rest = passThrough(given);
37
+ const markers = cms.shared?.preview
38
+ ? { 'data-cms-block': String(cms.index), 'data-cms-type': cms.type, 'data-cms-shared': cms.shared.name }
39
+ : !cms.preview
40
+ ? {}
41
+ : cms.collection
42
+ ? { 'data-cms-document': cms.collection }
43
+ : { 'data-cms-block': String(cms.index), 'data-cms-type': cms.type };
44
+ ---
45
+
46
+ <Tag class={className} {...rest} {...markers}><slot /></Tag>
@@ -0,0 +1,45 @@
1
+ ---
2
+ /**
3
+ * `<Blocks>`'s own renderer for runs of blocks (M9 Decision 9; block-runs.ts): each entry
4
+ * as `<Blocks>` draws it (the block's component, or a skipped block's preview marker), and
5
+ * each run as its component round its own entries and runs, recursively. Internal: a site
6
+ * uses `<Blocks wrap={…}>`.
7
+ */
8
+ import type { RunNode } from '../block-runs.ts';
9
+ import type { BlockContext } from '../document.ts';
10
+ import { skippedMarker } from '../skipped.ts';
11
+
12
+ export interface RunItem {
13
+ Component: ((props: any) => any) | undefined;
14
+ data: Record<string, unknown> | null;
15
+ cms: BlockContext | null;
16
+ skipped: { type: string; shared?: string } | null;
17
+ }
18
+
19
+ interface Props {
20
+ nodes: RunNode[];
21
+ items: ReadonlyMap<number, RunItem>;
22
+ preview: boolean;
23
+ }
24
+
25
+ const { nodes, items, preview } = Astro.props;
26
+ ---
27
+
28
+ {
29
+ nodes.map((node) => {
30
+ if (node.kind === 'run') {
31
+ const Wrapper = node.component as (props: any) => any;
32
+ return (
33
+ <Wrapper {...node.props}>
34
+ <Astro.self nodes={node.children} items={items} preview={preview} />
35
+ </Wrapper>
36
+ );
37
+ }
38
+ const item = items.get(node.index);
39
+ if (!item) return null;
40
+ const { Component, data, cms, skipped } = item;
41
+ if (Component && cms) return <Component block={data} cms={cms} />;
42
+ // As in Blocks.astro: a comment, so the site's sibling CSS matches across it.
43
+ return preview && skipped ? <Fragment set:html={skippedMarker(node.index, skipped)} /> : null;
44
+ })
45
+ }
@@ -0,0 +1,139 @@
1
+ ---
2
+ /**
3
+ * The page's body: put it where the page's content goes, `<Blocks document={document}
4
+ * collection={collection} preview={preview} />`. What it renders depends on the
5
+ * document's kind (M5a → Core framing):
6
+ *
7
+ * - **A section builder's** blocks, with the site's block components, in order.
8
+ * - **A list-of-fields document** (Decision 7) through its collection's component
9
+ * (`collection` names it; the page component passes it on). The component gets
10
+ * `{ document, cms }`: the document's render data plus its `title`, and a render
11
+ * context whose `h1Field` is the first heading field in schema order with a value.
12
+ * - **A shared block on its own** (its own preview, Decision 8): the block alone,
13
+ * following M3 Decision 16's `h1` rule as the page's only block.
14
+ *
15
+ * Rules, as before:
16
+ * - An unknown block type is skipped in the render (Decision 7; it stays in the file).
17
+ * In a preview render only (Decision 17), it leaves an HTML comment where it would
18
+ * be, `<!--cms-skipped <index> type=<type>-->` (skipped.ts),
19
+ * so the editor can mark it (amber dashes, from the overlay layer) and open it. No
20
+ * element: a site's sibling CSS matches across it, as on the public page, where
21
+ * there's nothing for it at all.
22
+ * - **A shared-block entry** (`{ "shared": "footer" }`, Decision 8) renders the shared
23
+ * block as it stands in the page's own source (Decision 9: the disk at build, the
24
+ * editor's store in a preview). One that names a shared block that doesn't exist,
25
+ * or whose type the code doesn't know, is skipped exactly as an unknown block is
26
+ * (its comment also says `shared=<name>`).
27
+ * - A document with no blocks leaves, in a preview render only, an empty area
28
+ * (`data-cms-empty`) where its blocks would go, so the editor can offer "Add the
29
+ * first block" on the page (Phase 4's insert points). The public render has nothing.
30
+ * - Each block gets `{ block, cms }`: its render data (fields.ts → renderData: the
31
+ * parsed data, with a value the field can't use, such as a number out of range,
32
+ * as `null`, M5a Decision 15; a reference to a document that doesn't exist as
33
+ * `null`, Decision 6), and its render context (document.ts → BlockContext): its
34
+ * index, whether this is a preview render (Decision 17), which of its fields holds
35
+ * the page's `h1` (Decision 16), `read`, the other documents (Decision 9), and
36
+ * `listPage`, the page of a paged listing (posts and tags, Decision 12: the page
37
+ * component passes on the `listPage` it gets, 1 when it doesn't).
38
+ * - The context is computed here, per render, and passed down as props: no module
39
+ * state, so concurrent preview renders can't leak into each other.
40
+ * - **Runs of blocks** (M9 Decision 9): `wrap` is the page component's rule for which
41
+ * neighbouring blocks go together and what's drawn round them (a band of cards, each
42
+ * card several blocks). It gets the blocks that render (`{ index, type, data }`) and
43
+ * returns runs, `{ from, to, component, props?, runs? }` (block-runs.ts). Each block in a
44
+ * run keeps its index, context, markers, selection and morph, exactly as outside one.
45
+ * A section builder's (or a shared block's) blocks only.
46
+ */
47
+ import { blocks } from 'virtual:caracms/blocks';
48
+ import { components } from 'virtual:caracms/collections';
49
+ import { site } from 'virtual:caracms/config';
50
+ import { blockContexts, documentKind, fieldsHeading, own, type BlockContext, type DocumentBlock, type PageDocument, type SiteDocument } from '../document.ts';
51
+ import { firstImagePath } from '../fields.ts';
52
+ import { documentReader, renderFields, renderSourceOf, resolveShared } from '../read.ts';
53
+ import { skippedMarker } from '../skipped.ts';
54
+ import { runTree, type RunNode, type WrapBlocks } from '../block-runs.ts';
55
+ import BlockRunTree from './BlockRunTree.astro';
56
+
57
+ interface Props {
58
+ document: PageDocument;
59
+ /** The document's collection: needed for a fields document (its component is the collection's). */
60
+ collection?: string;
61
+ preview?: boolean;
62
+ /** Posts and tags, Decision 12: the page of a paged listing (the page component's `listPage`); 1 by default. */
63
+ listPage?: number;
64
+ /** M9 Decision 9: which runs of blocks go together, and what's drawn round each (block-runs.ts). */
65
+ wrap?: WrapBlocks;
66
+ }
67
+
68
+ const { document, collection, preview = false, wrap } = Astro.props;
69
+ // A preview is always page 1; anything that isn't a whole number from 1 reads as 1.
70
+ const listPage = !preview && Number.isInteger(Astro.props.listPage) && Astro.props.listPage! >= 1 ? Astro.props.listPage! : 1;
71
+ const read = documentReader(site, renderSourceOf(Astro.locals));
72
+ const kind = documentKind(document);
73
+
74
+ interface Item {
75
+ index: number;
76
+ Component: ((props: any) => any) | undefined;
77
+ data: Record<string, unknown> | null;
78
+ cms: BlockContext | null;
79
+ skipped: { type: string; shared?: string } | null;
80
+ }
81
+ let items: Item[] = [];
82
+ let empty = false;
83
+ let body: { Component: (props: any) => any; props: Record<string, unknown> } | null = null;
84
+ let tree: RunNode[] | null = null;
85
+
86
+ if (kind === 'fields') {
87
+ const c = collection ? own(site.collections, collection) : undefined;
88
+ if (!c || c.kind !== 'fields') throw new Error(`<Blocks>: a list-of-fields document needs its collection (pass collection={collection}); got ${JSON.stringify(collection)}.`);
89
+ const Component = components[c.name];
90
+ if (!Component) throw new Error(`<Blocks>: collection "${c.name}" has no component.`);
91
+ const doc = document as Extract<PageDocument, { kind: 'fields' }>;
92
+ const fields = c.fields ?? {};
93
+ // M5b Decision 4: a fields document is its page's first block, so its images load eagerly and its first (the cover) is high priority.
94
+ const cms: BlockContext = { index: 0, first: true, type: c.name, preview, h1Field: fieldsHeading(fields, doc.data), collection: c.name, shared: null, read, listPage, eager: true, priority: firstImagePath(fields, doc.data) };
95
+ body = { Component, props: { document: { ...(await renderFields(fields, doc.data, read)), title: doc.title }, cms } };
96
+ } else {
97
+ // A shared block on its own renders as the only block of a page (Decision 8).
98
+ const page: SiteDocument = kind === 'shared' ? { title: document.title, blocks: [(document as Extract<PageDocument, { kind: 'shared' }>).block] } : (document as SiteDocument);
99
+ const resolved = new Map<number, DocumentBlock>();
100
+ const skips = new Map<number, { type: string; shared: string }>();
101
+ await Promise.all(
102
+ page.blocks.map(async (entry, i) => {
103
+ if (entry.shared === undefined) return;
104
+ const r = await resolveShared(entry, read);
105
+ if ('block' in r) resolved.set(i, r.block);
106
+ else skips.set(i, { type: r.type, shared: entry.shared });
107
+ }),
108
+ );
109
+ const contexts = blockContexts(page, site, preview, read, resolved, listPage);
110
+ items = await Promise.all(
111
+ page.blocks.map(async (entry, i): Promise<Item> => {
112
+ const cms = contexts[i] ?? null;
113
+ const block = resolved.get(i) ?? entry;
114
+ const Component = cms ? blocks[block.type] : undefined;
115
+ if (Component && cms) return { index: i, Component, data: await renderFields(own(site.blocks, block.type)!.fields, block.data, read), cms, skipped: null };
116
+ return { index: i, Component: undefined, data: null, cms: null, skipped: skips.get(i) ?? { type: entry.type } };
117
+ }),
118
+ );
119
+ empty = page.blocks.length === 0;
120
+ if (wrap) {
121
+ const rendered = items.filter((it) => it.Component && it.cms).map((it) => ({ index: it.index, type: it.cms!.type, data: it.data! }));
122
+ const runs = wrap(rendered);
123
+ if (runs.length) tree = runTree(items.map((it) => it.index), runs);
124
+ }
125
+ }
126
+ const byIndex = new Map(items.map((it) => [it.index, it]));
127
+ ---
128
+
129
+ {body && <body.Component {...body.props} />}
130
+ {tree && <BlockRunTree nodes={tree} items={byIndex} preview={preview} />}
131
+ {
132
+ !tree &&
133
+ items.map(({ index, Component, data, cms, skipped }) => {
134
+ if (Component && cms) return <Component block={data} cms={cms} />;
135
+ // A comment, not an element: the site's sibling CSS (`+`, `:has(+ …)`) matches across it as on the public page.
136
+ return preview && skipped ? <Fragment set:html={skippedMarker(index, skipped)} /> : null;
137
+ })
138
+ }
139
+ {preview && empty ? <div data-cms-empty="" aria-hidden="true" style="display:block;min-height:320px;margin:0;padding:0;border:0" /> : null}
@@ -0,0 +1,116 @@
1
+ ---
2
+ /**
3
+ * A buttons field (M5a Decision 2; the template's one button shape):
4
+ * `[{ label, href, style }]` as links. A block author never writes the "half a
5
+ * button shows nothing" rule: this component applies it.
6
+ *
7
+ * <Buttons cms={cms} field="buttons" value={block.buttons} class="actions" buttonClass="button" />
8
+ *
9
+ * - Shows the first `max` buttons (the field's, default 2) that have both a label
10
+ * and an href passing the link rule. Half a button, or one whose link fails the
11
+ * rule, shows nothing (the field says why). Indexes stay the field's own, so
12
+ * markers line up with the editor's rows.
13
+ * - Each is `<a href class="<buttonClass> <buttonClass>--<style>">label</a>`, in a
14
+ * wrapper element `as` (default `div`) that takes `class` and other attributes.
15
+ * Public render: nothing at all when no button shows.
16
+ * - A field with `allowNoLink` (the editor-friction batch, item 4): a button with words
17
+ * and no link is meant ("Coming soon"), and shows as a plain label with the same
18
+ * classes, `<span class="<buttonClass> <buttonClass>--<style>">label</span>`. A
19
+ * custom slot gets it with `href: ''` and `linked: false`. One whose link fails the
20
+ * rule still shows nothing (the field says why).
21
+ * - Or render each one yourself with a function as the default slot:
22
+ * <Buttons cms={cms} field="buttons" value={block.buttons}>
23
+ * {(b) => <a href={b.href} class={`btn btn-${b.style}`} {...b.markers}>{b.label}</a>}
24
+ * </Buttons>
25
+ * `b` is `{ label, href, linked, style, index, markers }`; spread `markers` on the element.
26
+ * - Preview render only (M3 Decision 17): the wrapper is always there with
27
+ * `data-cms-field="<path>" data-cms-kind="buttons"`, and each button carries
28
+ * `data-cms-field="<path>.<index>.label" data-cms-kind="button"` (Decision 16).
29
+ * - M6c Decision 11: a button whose link is a file link carries the site's behaviour
30
+ * (`fileLinkAttributes`): `target="_blank" rel="noopener"` and a visually hidden
31
+ * " (opens in a new tab)" for a PDF without `?download`, `download` for any other.
32
+ * A custom slot gets them as `b.attributes` (spread them) and `b.newTabNote` (HTML
33
+ * to put inside, or '').
34
+ */
35
+ import { site } from 'virtual:caracms/config';
36
+ import type { HTMLTag } from 'astro/types';
37
+ import type { BlockContext } from '../document.ts';
38
+ import { parseField, usableHref, type ButtonValue } from '../fields.ts';
39
+ import { fileLinkAttributes, NEW_TAB_NOTE_HTML } from '../rich-text.ts';
40
+ import { requireField } from './descriptor.ts';
41
+ import { passThrough } from './attributes.ts';
42
+
43
+ /** One button as the default slot's function gets it. */
44
+ export interface ShownButton {
45
+ label: string;
46
+ /** Where it goes; '' for a plain label (a field with `allowNoLink`, a button with no link). */
47
+ href: string;
48
+ /** False for a plain label: draw it as text with the button's look, not a link. */
49
+ linked: boolean;
50
+ style: string;
51
+ /** Its index in the field's value (the editor's row). */
52
+ index: number;
53
+ /** The preview markers to spread on its element (empty in the public render). */
54
+ markers: Record<string, string>;
55
+ /** A file link's attributes to spread on its element (M6c Decision 11), else empty. */
56
+ attributes: Record<string, string | boolean>;
57
+ /** The visually hidden " (opens in a new tab)" to put inside a new-tab file link, else ''. */
58
+ newTabNote: string;
59
+ }
60
+
61
+ interface Props {
62
+ cms: BlockContext;
63
+ field: string;
64
+ value: ButtonValue[];
65
+ as?: HTMLTag;
66
+ class?: string;
67
+ /** Each button's class, and `<buttonClass>--<style>` beside it. */
68
+ buttonClass?: string;
69
+ [attribute: string]: unknown;
70
+ }
71
+
72
+ const { cms, field, value, as: Tag = 'div', class: className, buttonClass, ...given } = Astro.props;
73
+ // M8 trial (item 4): a bare attribute stays bare, false is none (attributes.ts).
74
+ const rest = passThrough(given);
75
+ const descriptor = requireField(site, cms, field, 'buttons', 'Buttons');
76
+ const all = parseField(descriptor, value) as ButtonValue[];
77
+ const shown: ShownButton[] = [];
78
+ all.slice(0, descriptor.max).forEach((b, index) => {
79
+ if (!b.label) return;
80
+ const markers: Record<string, string> = cms.preview ? { 'data-cms-field': `${field}.${index}.label`, 'data-cms-kind': 'button' } : {};
81
+ // allowNoLink: words and no link at all are a plain label.
82
+ if (!b.href && descriptor.allowNoLink) {
83
+ shown.push({ label: b.label, href: '', linked: false, style: b.style, index, markers, attributes: {}, newTabNote: '' });
84
+ return;
85
+ }
86
+ const href = usableHref(b.href);
87
+ if (!href) return;
88
+ const file = fileLinkAttributes(href);
89
+ const attributes = Object.fromEntries(Object.entries(file.attributes).map(([name, v]) => [name, v === '' ? true : v]));
90
+ shown.push({ label: b.label, href, linked: true, style: b.style, index, markers, attributes, newTabNote: file.newTab ? NEW_TAB_NOTE_HTML : '' });
91
+ });
92
+ const custom = Astro.slots.has('default');
93
+ const rendered = custom ? await Promise.all(shown.map((b) => Astro.slots.render('default', [b]))) : [];
94
+ const markers = cms.preview ? { 'data-cms-field': field, 'data-cms-kind': 'buttons' } : {};
95
+ ---
96
+
97
+ {
98
+ (shown.length > 0 || cms.preview) && (
99
+ <Tag class={className} {...rest} {...markers}>
100
+ {custom
101
+ ? rendered.map((html) => <Fragment set:html={html} />)
102
+ : shown.map((b) =>
103
+ b.linked ? (
104
+ <a href={b.href} class:list={buttonClass ? [buttonClass, `${buttonClass}--${b.style}`] : undefined} {...b.attributes} {...b.markers}>
105
+ {b.label}
106
+ {b.newTabNote && <Fragment set:html={b.newTabNote} />}
107
+ </a>
108
+ ) : (
109
+ <span class:list={buttonClass ? [buttonClass, `${buttonClass}--${b.style}`] : undefined} {...b.markers}>
110
+ {b.label}
111
+ </span>
112
+ ),
113
+ )}
114
+ </Tag>
115
+ )
116
+ }
@@ -0,0 +1,14 @@
1
+ ---
2
+ /**
3
+ * The text styles' fonts, for the page's `<head>`: `<Fonts />`. Astro's fonts API
4
+ * emits each `@font-face` (registered by the integration from the text styles,
5
+ * `font-display: block`, Decision 14) and a preload.
6
+ */
7
+ import { Font } from 'astro:assets';
8
+ import { site } from 'virtual:caracms/config';
9
+
10
+ const styles = Object.values(site.textStyles);
11
+ const variables = [...new Set(styles.map((s) => s.cssVariable))];
12
+ ---
13
+
14
+ {variables.map((cssVariable) => <Font cssVariable={cssVariable as any} preload />)}