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,342 @@
1
+ /**
2
+ * The block list (the mockup's #blocks, #removed, #drag, #unknown): a section-builder
3
+ * document's blocks, one row each.
4
+ *
5
+ * - **Hover** sets `blockHover(editor)` (link.ts), and a row is drawn hovered while
6
+ * that store holds its key, so the preview's overlays can follow it both ways.
7
+ * **Selected** (`.is-current`) is `editor.view.block`.
8
+ * - **Reorder by drag** (the grip; the drop line is full width) **and by keys**
9
+ * (M2's keys rule, on a focused row): ⌥↑/⌥↓ move it, ⌫ removes it (with Undo:
10
+ * the toast and ⌘Z, Decision 18), Enter opens it, ↑/↓ move between rows. The row
11
+ * menu has the same moves with their keys.
12
+ * - **An unknown block** (Decisions 7 and 21) is marked Skipped with its kept keys
13
+ * summarised; it moves and is removed like any block, and opens to a panel, not
14
+ * fields. The serializer writes it back as read (by its `source`).
15
+ *
16
+ * Everything that changes the document goes through `editor.doc` (document-store.ts).
17
+ */
18
+ import { own } from '../../../document.ts';
19
+ import { fieldHoldsMedia, fieldWarnings, type Field } from '../../../fields.ts';
20
+ import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
21
+ import { useEditor, useStore } from '../context.tsx';
22
+ import { Icon } from '../shell/Icons.tsx';
23
+ import { blockLabel } from '../state/document-store.ts';
24
+ import { offersBlock } from '../state/schema.ts';
25
+ import { sameDoc, type EditorBlock, type EditorSite } from '../../types.ts';
26
+ import { blockHover, openPicker, setBlockHover } from './link.ts';
27
+ import { rowKeyAction, useRowDrag } from './reorder.ts';
28
+ import { blockIcon, blockSummary, sharedOf, usedOnLine } from './summary.ts';
29
+
30
+ export function BlockList() {
31
+ const editor = useEditor();
32
+ const blocks = useStore(editor.doc.state, (s) => s.document.blocks);
33
+ const title = useStore(editor.doc.state, (s) => s.document.title || s.entry.label);
34
+ const current = useStore(editor.view, (v) => v.block);
35
+ const hovered = useStore(blockHover(editor));
36
+ // The row's marks beside the name (the mockup's browHTML): too long for mobile, and has its own phone lines.
37
+ const showing = useStore(editor.previewStatus, (s) => s.showing);
38
+ const warnings = useStore(editor.previewStatus, (s) => s.warnings);
39
+ const ref = useStore(editor.doc.state, (s) => s.ref);
40
+ const tooLong = new Set(sameDoc(showing, ref) ? warnings.filter((w) => w.tooLong).map((w) => w.block) : []);
41
+ // Read-only (M4: a lock, held edits, signed out): rows still open their blocks, nothing moves or goes.
42
+ const readOnly = useStore(editor.access, (a) => a.readOnly);
43
+ const documents = useStore(editor.documents);
44
+ const [menu, setMenu] = useState<string | null>(null);
45
+ const list = useRef<HTMLOListElement>(null);
46
+ /** A row (by key) to focus once the list has re-rendered: after a keyboard move or remove. */
47
+ const focusNext = useRef<string | 'add' | null>(null);
48
+
49
+ useEffect(() => {
50
+ const want = focusNext.current;
51
+ if (!want) return;
52
+ focusNext.current = null;
53
+ const el =
54
+ want === 'add'
55
+ ? list.current?.parentElement?.querySelector<HTMLElement>('.add-row')
56
+ : list.current?.querySelector<HTMLElement>(`.brow[data-block-key="${want}"] .brow-main`);
57
+ el?.focus();
58
+ });
59
+
60
+ // A row hovered or focused when the list goes (the picker opens, a block opens) isn't hovered any more.
61
+ useEffect(() => () => setBlockHover(editor, null), [editor]);
62
+
63
+ // The row menu closes on a click outside it.
64
+ useEffect(() => {
65
+ if (!menu) return;
66
+ const onDown = (event: MouseEvent) => {
67
+ const target = event.target as Element | null;
68
+ if (target?.closest?.('.menu') || target?.closest?.('.brow-more')) return;
69
+ setMenu(null);
70
+ };
71
+ document.addEventListener('mousedown', onDown);
72
+ return () => document.removeEventListener('mousedown', onDown);
73
+ }, [menu]);
74
+
75
+ // A shared entry that renders (M5a Decision 8) is named by its shared block; one that's skipped stays "Unknown block".
76
+ const label = (b: EditorBlock) => sharedOf(editor.site, documents, b)?.label ?? blockLabel(editor.site, b);
77
+
78
+ const move = (key: string, to: number, viaKeys: boolean) => {
79
+ const from = editor.doc.indexOf(key);
80
+ const block = editor.doc.block(key);
81
+ if (!block || from < 0 || to < 0 || to >= blocks.length || to === from) return;
82
+ if (viaKeys) focusNext.current = key;
83
+ editor.doc.moveBlock(key, to);
84
+ editor.announce(`Moved ${label(block)} to ${to + 1} of ${blocks.length}`);
85
+ };
86
+
87
+ const remove = (key: string, viaKeys: boolean) => {
88
+ const index = editor.doc.indexOf(key);
89
+ const block = editor.doc.block(key);
90
+ if (!block) return;
91
+ if (viaKeys) focusNext.current = blocks[index + 1]?.key ?? blocks[index - 1]?.key ?? 'add';
92
+ editor.doc.removeBlock(key);
93
+ editor.announce(`Removed ${label(block)}. Undo with Command Z.`);
94
+ };
95
+
96
+ const onRowKey = (event: KeyboardEvent<HTMLButtonElement>, block: EditorBlock, index: number) => {
97
+ if (readOnly && (event.altKey || event.key === 'Backspace' || event.key === 'Delete')) return;
98
+ const action = rowKeyAction(event, index, blocks.length);
99
+ if (action?.kind === 'move') move(block.key, action.to, true);
100
+ else if (action?.kind === 'remove') remove(block.key, true);
101
+ else if (action?.kind === 'focus') list.current?.querySelector<HTMLElement>(`.brow[data-block-key="${blocks[action.to]!.key}"] .brow-main`)?.focus();
102
+ };
103
+
104
+ // Drag (HTML drag and drop on the rows; the drop line is the mockup's): reorder.ts, shared with M5a's lists.
105
+ const rows = useRowDrag({
106
+ rowOf: (target) => target.closest?.('.brow[data-block-key]') as HTMLElement | null,
107
+ keyOf: (row) => row.dataset.blockKey!,
108
+ indexOf: (key) => editor.doc.indexOf(key),
109
+ onMove: (key, to) => move(key, to, false),
110
+ onStart: () => setMenu(null),
111
+ });
112
+ const drag = rows.drag;
113
+
114
+ const dragged = drag ? blocks.find((b) => b.key === drag.key) : undefined;
115
+ const target = drag?.drop ? blocks.find((b) => b.key === drag.drop!.key) : undefined;
116
+
117
+ return (
118
+ <>
119
+ <ol
120
+ ref={list}
121
+ className="blist"
122
+ aria-label={`Blocks on ${title}`}
123
+ onMouseLeave={() => setBlockHover(editor, null)}
124
+ onDragOver={rows.over}
125
+ onDrop={rows.drop}
126
+ >
127
+ {blocks.map((block, i) => {
128
+ const name = label(block);
129
+ const shared = sharedOf(editor.site, documents, block);
130
+ const skipped = !block.known && !shared;
131
+ const type = shared?.blockType ?? (block.known ? block.type : null);
132
+ const notOffered = !!type && !offersBlock(editor.site, ref.collection, type);
133
+ const cls = [
134
+ 'brow',
135
+ current === block.key && !drag ? 'is-current' : '',
136
+ skipped ? 'is-unknown' : '',
137
+ readOnly ? 'is-disabled' : '',
138
+ hovered === block.key && !drag ? 'hov' : '',
139
+ rows.classes(block.key),
140
+ ]
141
+ .filter(Boolean)
142
+ .join(' ');
143
+ return (
144
+ <li
145
+ key={block.key}
146
+ className={cls}
147
+ data-block-key={block.key}
148
+ data-block-type={shared?.blockType ?? block.type}
149
+ data-shared={shared ? shared.path : undefined}
150
+ draggable={!readOnly}
151
+ onMouseEnter={() => setBlockHover(editor, block.key)}
152
+ onDragStart={(e) => rows.start(e, block.key)}
153
+ onDragEnd={rows.cancel}
154
+ >
155
+ <button
156
+ type="button"
157
+ className="brow-main"
158
+ aria-current={current === block.key ? 'true' : undefined}
159
+ aria-label={`${name}${shared ? ', shared' : ''}, ${i + 1} of ${blocks.length}${skipped ? ', skipped' : ''}. Alt and arrow keys move it`}
160
+ onClick={() => editor.openBlock(block.key)}
161
+ onKeyDown={(e) => onRowKey(e, block, i)}
162
+ onFocus={() => setBlockHover(editor, block.key)}
163
+ onBlur={() => {
164
+ if (blockHover(editor).get() === block.key) setBlockHover(editor, null);
165
+ }}
166
+ >
167
+ <span className="grip" aria-hidden="true">
168
+ {readOnly ? null : <Icon name="grip" />}
169
+ </span>
170
+ <span className="bi">
171
+ <Icon name={blockIcon(shared ? { type: shared.blockType!, known: true } : block)} />
172
+ </span>
173
+ <span className="bt">
174
+ <span className="nm">
175
+ {name}
176
+ {shared ? (
177
+ <span className="chip shared" data-chip="shared">
178
+ <Icon name="shared" size="xs" />
179
+ Shared
180
+ </span>
181
+ ) : null}
182
+ {!skipped ? null : (
183
+ <span className="chip warn" title="Its type isn't in the site's code: kept in the file, skipped on the page">
184
+ <Icon name="warn" size="xs" />
185
+ Skipped
186
+ </span>
187
+ )}
188
+ {notOffered ? (
189
+ <span className="chip warn" title="Not offered by this collection" data-chip="not-offered">
190
+ <Icon name="warn" size="xs" />
191
+ Not offered here
192
+ </span>
193
+ ) : null}
194
+ {tooLong.has(i) ? (
195
+ <span className="chip warn" title="Too long for mobile" data-chip="too-long">
196
+ <Icon name="phone" size="xs" />
197
+ Long
198
+ </span>
199
+ ) : null}
200
+ {altGaps(editor.site, block) ? (
201
+ <span className="chip warn" title="An image has no alt text" data-chip="alt">
202
+ <Icon name="image" size="xs" />
203
+ Alt
204
+ </span>
205
+ ) : null}
206
+ {hasPhoneLines(editor.site, block) ? (
207
+ <span className="chip phone" title="Has its own lines on Phone" data-chip="phone-lines">
208
+ <Icon name="phone" size="xs" />
209
+ </span>
210
+ ) : null}
211
+ </span>
212
+ <span className="sm">{shared ? usedOnLine(shared) : blockSummary(editor.site, block, editor.forms)}</span>
213
+ </span>
214
+ </button>
215
+ {readOnly ? null : (
216
+ <button
217
+ type="button"
218
+ className="ib brow-more"
219
+ aria-label={`Actions for ${name}`}
220
+ aria-haspopup="menu"
221
+ aria-expanded={menu === block.key}
222
+ onClick={() => setMenu(menu === block.key ? null : block.key)}
223
+ >
224
+ <Icon name="more" />
225
+ </button>
226
+ )}
227
+ {menu === block.key && !readOnly ? (
228
+ <RowMenu
229
+ first={i === 0}
230
+ last={i === blocks.length - 1}
231
+ onClose={() => {
232
+ setMenu(null);
233
+ list.current?.querySelector<HTMLElement>(`.brow[data-block-key="${block.key}"] .brow-more`)?.focus();
234
+ }}
235
+ onMove={(dir) => {
236
+ setMenu(null);
237
+ move(block.key, i + dir, true);
238
+ }}
239
+ onRemove={() => {
240
+ setMenu(null);
241
+ remove(block.key, true);
242
+ }}
243
+ />
244
+ ) : null}
245
+ </li>
246
+ );
247
+ })}
248
+ </ol>
249
+ <button type="button" className="add-row" disabled={readOnly} onClick={() => openPicker(editor, blocks.length)}>
250
+ <Icon name="plus" />
251
+ Add block
252
+ </button>
253
+ {!readOnly && drag && dragged && target && target.key !== dragged.key ? (
254
+ <p className="kb-hint" role="status" style={{ color: 'var(--select-strong)' }}>
255
+ Drop to put {label(dragged)} {drag.drop!.after ? 'below' : 'above'} {label(target)}.
256
+ </p>
257
+ ) : null}
258
+ </>
259
+ );
260
+ }
261
+
262
+ function RowMenu(props: { first: boolean; last: boolean; onClose: () => void; onMove: (dir: -1 | 1) => void; onRemove: () => void }) {
263
+ const menu = useRef<HTMLDivElement>(null);
264
+ useEffect(() => {
265
+ menu.current?.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();
266
+ }, []);
267
+ const onKey = (event: KeyboardEvent<HTMLDivElement>) => {
268
+ if (event.key === 'Escape') {
269
+ // Closes the menu only; App.tsx would otherwise go up a level.
270
+ event.preventDefault();
271
+ props.onClose();
272
+ return;
273
+ }
274
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
275
+ event.preventDefault();
276
+ const items = [...(menu.current?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)') ?? [])];
277
+ const at = items.indexOf(document.activeElement as HTMLButtonElement);
278
+ items[(at + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length]?.focus();
279
+ }
280
+ };
281
+ return (
282
+ <div ref={menu} className="menu" role="menu" style={{ right: 4, top: 'calc(100% + 2px)' }} onKeyDown={onKey}>
283
+ <button type="button" role="menuitem" disabled={props.first} onClick={() => props.onMove(-1)}>
284
+ <Icon name="up" />
285
+ Move up
286
+ <span className="k">
287
+ <kbd>⌥</kbd>
288
+ <kbd>↑</kbd>
289
+ </span>
290
+ </button>
291
+ <button type="button" role="menuitem" disabled={props.last} onClick={() => props.onMove(1)}>
292
+ <Icon name="down" />
293
+ Move down
294
+ <span className="k">
295
+ <kbd>⌥</kbd>
296
+ <kbd>↓</kbd>
297
+ </span>
298
+ </button>
299
+ <hr />
300
+ <button type="button" role="menuitem" className="danger" onClick={props.onRemove}>
301
+ <Icon name="trash" />
302
+ Remove
303
+ <span className="k">
304
+ <kbd>⌫</kbd>
305
+ </span>
306
+ </button>
307
+ </div>
308
+ );
309
+ }
310
+
311
+ /** A block with a lockup that has its own phone lines (Decision 10's object form), at the top level or inside a group. */
312
+ function hasPhoneLines(site: EditorSite, block: EditorBlock): boolean {
313
+ if (!block.known) return false;
314
+ const fields = own(site.blocks, block.type)?.fields ?? {};
315
+ const phoned = (field: Field, value: unknown) => {
316
+ const phone = field.kind === 'lockup' && !!value && typeof value === 'object' ? (value as { phone?: unknown }).phone : undefined;
317
+ return Array.isArray(phone) && phone.length > 0;
318
+ };
319
+ return Object.entries(fields).some(([name, field]) => {
320
+ const value = block.data[name];
321
+ if (field.kind !== 'group') return phoned(field, value);
322
+ const inner = value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
323
+ return Object.entries(field.fields).some(([key, f]) => phoned(f, inner[key]));
324
+ });
325
+ }
326
+
327
+ /**
328
+ * How many images in a block's rendered slots have no alt text (M5b Decision 9: the field's
329
+ * own `missing-alt` warning, so a decorative image never counts). The mockup's browHTML
330
+ * marks such a block (its "Alt" chip); it blocks nothing. An
331
+ * unknown block isn't rendered, and a shared entry's images are its shared block's.
332
+ */
333
+ export function altGaps(site: EditorSite, block: EditorBlock): number {
334
+ if (!block.known || block.shared) return 0;
335
+ const fields = own(site.blocks, block.type)?.fields ?? {};
336
+ let n = 0;
337
+ for (const [name, field] of Object.entries(fields)) {
338
+ if (!fieldHoldsMedia(field)) continue;
339
+ n += fieldWarnings(field, block.data[name]).filter((w) => w.code === 'missing-alt').length;
340
+ }
341
+ return n;
342
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The add-block picker (the mockup's #picker): on the document level, in place of
3
+ * the block list, while `pickerAt(editor)` holds an index (link.ts → openPicker).
4
+ * The site's block types, each with its fields; picking one adds an empty block of
5
+ * that type at the index and opens it. Back, or Esc, returns to the list.
6
+ *
7
+ * M5a: the Shared blocks section inserts a shared block's entry (`{ "shared": name }`),
8
+ * not a copy (Decision 8).
9
+ */
10
+ import { own } from '../../../document.ts';
11
+ import { useEffect, useRef } from 'react';
12
+ import { useEditor, useStore } from '../context.tsx';
13
+ import { Icon } from '../shell/Icons.tsx';
14
+ import { blockLabel } from '../state/document-store.ts';
15
+ import { offeredBlocks, offersBlock } from '../state/schema.ts';
16
+ import { CurrentHead, DocumentsTab, GlobalNotes, LevelTab, RecentStrip, Trail } from '../pane/parts.tsx';
17
+ import { closePicker } from './link.ts';
18
+ import { blockIcon, fieldsSummary, sharedOf, usedOnLine } from './summary.ts';
19
+
20
+ export function Picker({ at }: { at: number }) {
21
+ const editor = useEditor();
22
+ const blocks = useStore(editor.doc.state, (s) => s.document.blocks);
23
+ const title = useStore(editor.doc.state, (s) => s.document.title || s.entry.label);
24
+ const documents = useStore(editor.documents);
25
+ const collection = useStore(editor.doc.state, (s) => s.ref.collection);
26
+ const body = useRef<HTMLDivElement>(null);
27
+ const before = blocks[at - 1];
28
+ const n = blocks.length;
29
+
30
+ useEffect(() => {
31
+ body.current?.querySelector<HTMLElement>('.ptype')?.focus();
32
+ // Esc closes the picker, before App.tsx's Esc would go up a level.
33
+ const onKey = (event: KeyboardEvent) => {
34
+ if (event.key !== 'Escape' || event.defaultPrevented || editor.view.get().pop) return;
35
+ event.preventDefault();
36
+ closePicker(editor);
37
+ };
38
+ window.addEventListener('keydown', onKey, true);
39
+ return () => window.removeEventListener('keydown', onKey, true);
40
+ }, [editor]);
41
+
42
+ // The collection's offered blocks (config.ts `blocks`), else every block.
43
+ const offered = offeredBlocks(editor.site, collection);
44
+ const types = offered ? offered.map((type) => own(editor.site.blocks, type)).filter((b) => !!b) : Object.values(editor.site.blocks);
45
+ // M5a Decision 8: the shared blocks that render (their type is one the site knows), of a type the collection offers.
46
+ const shared = documents.filter((d) => d.collection === 'shared' && !d.warning && !!d.blockType && !!own(editor.site.blocks, d.blockType) && offersBlock(editor.site, collection, d.blockType));
47
+ const pickShared = (name: string, label: string) => {
48
+ const key = editor.doc.addShared(name, at);
49
+ closePicker(editor);
50
+ editor.openBlock(key);
51
+ editor.announce(`Added ${label}, a shared block`);
52
+ };
53
+
54
+ const pick = (type: string) => {
55
+ const key = editor.doc.addBlock(type, at);
56
+ closePicker(editor);
57
+ editor.openBlock(key);
58
+ editor.announce(`Added ${own(editor.site.blocks, type)?.label ?? type}`);
59
+ };
60
+
61
+ return (
62
+ <>
63
+ <Trail>
64
+ <DocumentsTab />
65
+ <LevelTab name={title} kind="Blocks" count={`${n} block${n === 1 ? '' : 's'}`} onClick={() => closePicker(editor)} />
66
+ <CurrentHead
67
+ back={{ label: `Back to ${title}`, onClick: () => closePicker(editor) }}
68
+ crumb={title}
69
+ title="Add a block"
70
+ sub={before ? `After ${sharedOf(editor.site, documents, before)?.label ?? blockLabel(editor.site, before)}` : n ? 'At the top of the page' : 'The first block on the page'}
71
+ />
72
+ </Trail>
73
+ <div className="side-body" ref={body} data-picker-at={at}>
74
+ <GlobalNotes />
75
+ <p className="sec-h">Blocks</p>
76
+ <div className="picker-grid">
77
+ {types.map((schema) => (
78
+ <button key={schema.type} type="button" className="ptype" data-type={schema.type} onClick={() => pick(schema.type)}>
79
+ <span className="sk">
80
+ <Icon name={blockIcon({ type: schema.type, known: true })} />
81
+ </span>
82
+ <b>{schema.label}</b>
83
+ <small>{fieldsSummary(schema)}</small>
84
+ </button>
85
+ ))}
86
+ </div>
87
+ {shared.length ? (
88
+ <>
89
+ <p className="sec-h" data-section="shared-blocks">
90
+ Shared blocks
91
+ </p>
92
+ {shared.map((d) => (
93
+ <button key={d.path} type="button" className="drow" data-shared={d.path} onClick={() => pickShared(d.path, d.label)}>
94
+ <Icon name="shared" />
95
+ <span className="nm">{d.label}</span>
96
+ <span className="meta">{usedOnLine(d)}</span>
97
+ </button>
98
+ ))}
99
+ </>
100
+ ) : null}
101
+ </div>
102
+ <RecentStrip />
103
+ </>
104
+ );
105
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * An unknown block's panel (the mockup's #unknown, its unknownPanel; Decisions 7
3
+ * and 21), for the block level to show in place of fields when the open block's
4
+ * type isn't in the site's config:
5
+ *
6
+ * {block.known ? <fields…/> : <UnknownBlockPanel block={block} />}
7
+ *
8
+ * It says the block is kept in the file and skipped on the page, lists what's kept
9
+ * (its top-level keys, each with a short preview of its value), and offers Remove,
10
+ * with Undo. It can't be edited.
11
+ */
12
+ import { useEditor, useStore } from '../context.tsx';
13
+ import { Icon } from '../shell/Icons.tsx';
14
+ import type { EditorBlock } from '../../types.ts';
15
+ import { keptSummary, sharedOf } from './summary.ts';
16
+ import { sharedEntryOf } from '../../../document.ts';
17
+
18
+ export function UnknownBlockPanel({ block }: { block: EditorBlock }) {
19
+ const editor = useEditor();
20
+ const readOnly = useStore(editor.access, (a) => a.readOnly);
21
+ const documents = useStore(editor.documents);
22
+ const kept = keptSummary(block.data);
23
+ const remove = () => {
24
+ editor.doc.removeBlock(block.key);
25
+ editor.goTo('document');
26
+ editor.announce('Removed Unknown block. Undo with Command Z.');
27
+ };
28
+ const removeEntry = () => {
29
+ editor.doc.removeBlock(block.key, { label: `Removed ${sharedOf(editor.site, documents, block)?.label ?? 'shared block'}` });
30
+ editor.goTo('document');
31
+ editor.announce('Removed the shared block from this page. Undo with Command Z.');
32
+ };
33
+ // A shared-block entry (M5a Decision 8): selected, moved and removed like a block, never
34
+ // edited here. One that renders offers its own document (the mockup's #shared panel).
35
+ const entry = sharedEntryOf(block);
36
+ const shared = entry !== undefined ? sharedOf(editor.site, documents, block) : null;
37
+ if (shared) {
38
+ const used = (shared.usedOn ?? []).map((u) => documents.find((d) => d.collection === u.collection && d.path === u.path)?.label ?? u.path);
39
+ const n = used.length;
40
+ return (
41
+ <div className="panel" data-shared-entry={entry} data-shared-panel="">
42
+ <h3>{shared.label} is a shared block</h3>
43
+ <p>Edit it in Shared blocks; it's on every page that uses it.</p>
44
+ <div className="used">
45
+ {used.map((t, i) => (
46
+ <span key={`${i}:${t}`} className="chip shared">
47
+ {t}
48
+ </span>
49
+ ))}
50
+ </div>
51
+ <div className="acts">
52
+ <button type="button" className="btn ink" data-act="edit-shared" onClick={() => void editor.openDocument({ collection: 'shared', path: shared.path })}>
53
+ <Icon name="shared" size="sm" />
54
+ Edit {shared.label} (used on {n} page{n === 1 ? '' : 's'})
55
+ </button>
56
+ <button type="button" className="btn" disabled={readOnly} onClick={removeEntry}>
57
+ Remove from this page
58
+ </button>
59
+ </div>
60
+ </div>
61
+ );
62
+ }
63
+ if (entry !== undefined) {
64
+ // Skipped on the page (Phase 2's warning): no such shared block, or a type the code doesn't know.
65
+ const listedShared = documents.find((d) => d.collection === 'shared' && d.path === entry && !d.warning);
66
+ const name = listedShared?.label ?? entry;
67
+ return (
68
+ <div data-unknown-block={block.key} data-shared-entry={entry}>
69
+ <div className="note warn">
70
+ <Icon name="warn" />
71
+ <div>
72
+ <b>{!listedShared ? `There's no shared block "${entry}"` : `${name}'s type isn't in the site's code any more`}</b>
73
+ <p>Skipped on the page; kept in the file.</p>
74
+ </div>
75
+ </div>
76
+ <div className="panel">
77
+ <div className="acts">
78
+ <button type="button" className="btn" disabled={readOnly} onClick={remove}>
79
+ Remove from this page
80
+ </button>
81
+ </div>
82
+ </div>
83
+ </div>
84
+ );
85
+ }
86
+ return (
87
+ <div data-unknown-block={block.key}>
88
+ <div className="note warn">
89
+ <Icon name="warn" />
90
+ <div>
91
+ <b>This block's type isn't in the site's code any more</b>
92
+ <p>
93
+ <span className="mono">{block.type || 'No type'}</span> is skipped on the page; kept in the file.
94
+ </p>
95
+ </div>
96
+ </div>
97
+ <div className="panel">
98
+ <h3>What's kept</h3>
99
+ {kept.length ? (
100
+ <dl className="kept">
101
+ {kept.map((k) => (
102
+ <div key={k.key}>
103
+ <dt>{k.label}</dt>
104
+ <dd>{k.preview}</dd>
105
+ </div>
106
+ ))}
107
+ </dl>
108
+ ) : (
109
+ <p>Nothing but its type.</p>
110
+ )}
111
+ <div className="acts">
112
+ <button type="button" className="btn" disabled={readOnly} onClick={remove}>
113
+ Remove block
114
+ </button>
115
+ </div>
116
+ </div>
117
+ </div>
118
+ );
119
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The empty page's invitation in the preview (the mockup's renderPreview:
3
+ * `.cms-empty .cms-add`), painted into Phase 3's overlay layer (client/overlays): a
4
+ * dashed area where its blocks would go (Blocks.astro's preview-only
5
+ * `data-cms-empty`) with "+ Add the first block", in every frame. A click opens the
6
+ * add-block picker at 0 (link.ts → openPicker).
7
+ *
8
+ * (The "+ Add block" points on a selected block's edges went in the pre-M7 polish:
9
+ * the sidebar's Add block row is the one way to add after the first.) Nothing is
10
+ * drawn while the preview shows another document than the sidebar (one is opening).
11
+ *
12
+ * startEmptyPageAdd(editor) // a stop; DocumentsLevel mounts it once (it's always mounted)
13
+ */
14
+ import type { Editor } from '../editor.ts';
15
+ import { overlaysFor } from '../overlays/index.ts';
16
+ import { sameDoc } from '../../types.ts';
17
+ import { openPicker } from './link.ts';
18
+
19
+ /** Whether the preview offers "+ Add the first block" now (pure apart from reading stores). */
20
+ export function offersFirstBlock(editor: Pick<Editor, 'doc' | 'previewStatus'>): boolean {
21
+ const status = editor.previewStatus.get();
22
+ const session = editor.doc.get();
23
+ if (status.opening || !sameDoc(status.showing, session.ref)) return false;
24
+ // Only a section builder has blocks (M5a: not a form or a shared block).
25
+ return session.document.kind === 'sections' && session.document.blocks.length === 0;
26
+ }
27
+
28
+ export function startEmptyPageAdd(editor: Editor): () => void {
29
+ const overlays = overlaysFor(editor);
30
+ if (!overlays) return () => {};
31
+
32
+ const remove = overlays.addPainter((ctx) => {
33
+ if (!offersFirstBlock(editor)) return null;
34
+ const area = ctx.doc.querySelector('[data-cms-empty]');
35
+ if (!area) return null;
36
+ const box = ctx.el('div', 'cms-empty');
37
+ box.setAttribute('style', ctx.place(ctx.rect(area, -24)));
38
+ const b = ctx.el('button', 'cms-add');
39
+ b.type = 'button';
40
+ b.dataset.ins = '0';
41
+ b.textContent = '+ Add the first block';
42
+ // The page's own handlers (click to edit) ignore the layer; keep the press here anyway.
43
+ b.addEventListener('mousedown', (event) => event.stopPropagation());
44
+ b.addEventListener('click', (event) => {
45
+ event.preventDefault();
46
+ event.stopPropagation();
47
+ openPicker(editor, 0);
48
+ });
49
+ box.appendChild(b);
50
+ return box;
51
+ });
52
+
53
+ const redraw = () => overlays.redraw();
54
+ const stops = [remove, editor.doc.state.subscribe(redraw), editor.previewStatus.subscribe(redraw)];
55
+ return () => stops.forEach((stop) => stop());
56
+ }