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,122 @@
1
+ /**
2
+ * One document's row (the mockup's `.drow`), wherever documents are listed: the
3
+ * Pages tree, a collection's level, "Find a document" and the switcher.
4
+ *
5
+ * A file that can't be opened (invalid JSON, no title, a nested `index.json`;
6
+ * Decisions 8–9) is listed with a warning mark and doesn't open: clicking it shows
7
+ * why, in a note under the row (`aria-expanded`), and never opens it. No side rails (M2 Decision 10): the current row is a
8
+ * wash with a full thin ring, a warned one an icon.
9
+ *
10
+ * M4 (live mode): the merge notice's mark, someone's lock ("Aoife is editing"),
11
+ * presence (avatars) and the waiting dot go in the row's meta (live/marks.tsx). In
12
+ * local mode the slot stays empty (`data-slot="presence"`).
13
+ */
14
+ import { useState, type CSSProperties, type ReactNode } from 'react';
15
+ import { useEditor, useStore } from '../context.tsx';
16
+ import { Icon } from '../shell/Icons.tsx';
17
+ import { docId, sameDoc, type ListedDocument } from '../../types.ts';
18
+ import { DocMarksView } from '../live/marks.tsx';
19
+
20
+ /** A document's icon by its collection: a page, a case study (any list-of-fields document), a shared block, Site Settings, else a post. */
21
+ export function docIcon(collection: { nested: boolean; kind?: string } | undefined): string {
22
+ if (collection?.nested) return 'page';
23
+ if (collection?.kind === 'fields') return 'case';
24
+ if (collection?.kind === 'shared') return 'shared';
25
+ if (collection?.kind === 'settings') return 'gear';
26
+ return 'journal';
27
+ }
28
+
29
+ export function DocRow(props: {
30
+ doc: ListedDocument;
31
+ crumb?: string;
32
+ className?: string;
33
+ style?: CSSProperties;
34
+ /** Before the icon: the tree's chevron column. */
35
+ lead?: ReactNode;
36
+ /** After the meta: a bubbled mark for a closed parent. */
37
+ extra?: ReactNode;
38
+ /** Called after the document starts opening (the switcher closes itself). */
39
+ onOpen?: () => void;
40
+ /** The switcher's keyboard highlight. */
41
+ active?: boolean;
42
+ id?: string;
43
+ }) {
44
+ const editor = useEditor();
45
+ const { doc } = props;
46
+ const id = docId(doc);
47
+ const current = useStore(editor.doc.state, (s) => docId(s.ref) === id);
48
+ // The open document with a lockup too long for mobile says so in its row (the mockup's metaFor).
49
+ const tooLong = useTooLong(current);
50
+ const [why, setWhy] = useState(false);
51
+ const collection = editor.site.collections.find((c) => c.name === doc.collection);
52
+ const classes = ['drow', props.className, current ? 'is-current' : '', doc.warning ? 'is-warned' : '', props.active ? 'is-active' : ''].filter(Boolean).join(' ');
53
+ const open = () => {
54
+ if (doc.warning) {
55
+ setWhy((w) => !w);
56
+ editor.announce(`${doc.label} can't be opened. ${doc.warning.message}`);
57
+ return;
58
+ }
59
+ void editor.openDocument({ collection: doc.collection, path: doc.path });
60
+ props.onOpen?.();
61
+ };
62
+ return (
63
+ <>
64
+ <button
65
+ type="button"
66
+ id={props.id}
67
+ className={classes}
68
+ style={props.style}
69
+ data-doc={id}
70
+ aria-current={current ? 'page' : undefined}
71
+ aria-expanded={doc.warning ? why : undefined}
72
+ title={doc.warning?.message}
73
+ onClick={open}
74
+ >
75
+ {props.lead}
76
+ <Icon name={docIcon(collection)} />
77
+ <span className="nm">
78
+ {props.crumb ? <span className="crumb">{props.crumb} / </span> : null}
79
+ {doc.label}
80
+ </span>
81
+ <span className="meta">
82
+ {doc.warning ? (
83
+ <span className="wmark" data-warning={doc.warning.code}>
84
+ <Icon name="warn" size="xs" />
85
+ <span className="sr">, can't be opened</span>
86
+ </span>
87
+ ) : null}
88
+ {/* M9 Decision 7: hidden from the site (or under a hidden page). */}
89
+ {doc.hidden ? (
90
+ <span className="hmark" data-mark="hidden" title={doc.hiddenBy ? 'Hidden from the site with the page it’s under' : 'Hidden from the site'}>
91
+ Hidden
92
+ </span>
93
+ ) : null}
94
+ {tooLong ? (
95
+ <span className="wmark" title="Too long for mobile" data-mark="too-long">
96
+ <Icon name="phone" size="xs" />
97
+ <span className="sr">, too long for mobile</span>
98
+ </span>
99
+ ) : null}
100
+ {editor.mode === 'live' ? <DocMarksView file={doc.file} /> : <span data-slot="presence" hidden />}
101
+ {props.extra}
102
+ </span>
103
+ </button>
104
+ {doc.warning && why ? (
105
+ <div className="note warn tight doc-why" role="note" data-why={id}>
106
+ <Icon name="warn" />
107
+ <div>
108
+ <b>{doc.label} can't be opened</b>
109
+ <p>{doc.warning.message}</p>
110
+ <p className="mono">{doc.file}</p>
111
+ </div>
112
+ </div>
113
+ ) : null}
114
+ </>
115
+ );
116
+ }
117
+
118
+ /** Whether the open document (when `current`) has a lockup too long for mobile, as the preview measured it. */
119
+ export function useTooLong(current: boolean): boolean {
120
+ const editor = useEditor();
121
+ return useStore(editor.previewStatus, (s) => current && sameDoc(s.showing, editor.doc.get().ref) && s.warnings.some((w) => w.tooLong));
122
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * "Find a document" and the ⌘K switcher (Decision 9): **every document's title and
3
+ * address, collections included, grouped by collection.** A nested page shows its
4
+ * parent in front ("Services / Branding") and matches on its parent's name too (the
5
+ * mockup revision's accepted reading). Pure, for the unit tests.
6
+ */
7
+ import type { EditorCollection, ListedDocument } from '../../types.ts';
8
+
9
+ export interface SearchHit {
10
+ doc: ListedDocument;
11
+ /** The parent page's or folder's label for a nested page (`Services`), else ''. */
12
+ crumb: string;
13
+ }
14
+
15
+ export interface SearchGroup {
16
+ collection: EditorCollection;
17
+ hits: SearchHit[];
18
+ /** How many documents the collection has in all (for "2 of 40"). */
19
+ total: number;
20
+ }
21
+
22
+ /** A document's parent label for crumbs: its parent page's label, or its folder's name title-cased (`Services`). */
23
+ export function parentLabel(ref: { collection: string; path: string }, documents: readonly { collection: string; path: string; label: string }[]): string {
24
+ const slash = ref.path.lastIndexOf('/');
25
+ if (slash < 0) return '';
26
+ const parent = ref.path.slice(0, slash);
27
+ const page = documents.find((d) => d.collection === ref.collection && d.path === parent);
28
+ if (page) return page.label;
29
+ const name = parent.split('/').pop()!.replace(/[-_]+/g, ' ').trim();
30
+ return name.charAt(0).toUpperCase() + name.slice(1);
31
+ }
32
+
33
+ /** Lower-cased and with accents folded, so "cafe" finds "Café". */
34
+ export const fold = (text: string) => text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
35
+
36
+ /** The text a query is matched against: title, label, address, path, and the parent's label. */
37
+ function haystack(doc: ListedDocument, crumb: string): string {
38
+ return fold([doc.title, doc.label, doc.url ?? '', doc.path, crumb].join('\n'));
39
+ }
40
+
41
+ /**
42
+ * Every collection's matches for `query`, in config order, each in the list's own
43
+ * order (tree order for Pages). Empty groups are left out. A blank query matches
44
+ * nothing (the caller shows its unfiltered list instead).
45
+ */
46
+ export function searchDocuments(query: string, documents: readonly ListedDocument[], collections: readonly EditorCollection[]): SearchGroup[] {
47
+ const words = fold(query).split(/\s+/).filter(Boolean);
48
+ if (words.length === 0) return [];
49
+ const groups: SearchGroup[] = [];
50
+ for (const collection of collections) {
51
+ const docs = documents.filter((d) => d.collection === collection.name);
52
+ const hits: SearchHit[] = [];
53
+ for (const doc of docs) {
54
+ const crumb = collection.nested ? parentLabel(doc, documents) : '';
55
+ const text = haystack(doc, crumb);
56
+ if (words.every((w) => text.includes(w))) hits.push({ doc, crumb });
57
+ }
58
+ if (hits.length) groups.push({ collection, hits, total: docs.length });
59
+ }
60
+ return groups;
61
+ }
62
+
63
+ /** One collection's documents matching `query` (its own level's filter). A blank query keeps them all. */
64
+ export function filterCollection(query: string, documents: readonly ListedDocument[]): ListedDocument[] {
65
+ const words = fold(query).split(/\s+/).filter(Boolean);
66
+ if (words.length === 0) return [...documents];
67
+ return documents.filter((d) => {
68
+ const text = fold([d.title, d.label, d.url ?? '', d.path].join('\n'));
69
+ return words.every((w) => text.includes(w));
70
+ });
71
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The Pages tree as rows (Decision 8), built from the document list. Pure, so the
3
+ * server render, the browser and the unit tests agree.
4
+ *
5
+ * The reader (content.ts → listDocuments) already returns a nested collection in
6
+ * tree order: each level Home first, then alphabetical by title, with a folder that
7
+ * has no page of its own sorted by its label. So the rows keep the list's order; a
8
+ * row's depth is its path's, and a folder with no page becomes a group row just
9
+ * before its first child. (The browser can't import content.ts's pageTree: it reads
10
+ * the disk.)
11
+ */
12
+ import { titleCase } from '../../../fields.ts';
13
+ import type { ListedDocument, UiState } from '../../types.ts';
14
+ import { groupKey } from '../../ui-state.ts';
15
+
16
+ export type TreeRow =
17
+ | {
18
+ kind: 'page';
19
+ /** The document's path, which is also its tree position. */
20
+ path: string;
21
+ depth: number;
22
+ doc: ListedDocument;
23
+ /** It has pages under it: it gets a show/hide chevron as well as opening. */
24
+ parent: boolean;
25
+ /** Its children are shown (only meaningful when `parent`). */
26
+ open: boolean;
27
+ /** Its group key in the cookie (`pages:about`). */
28
+ key: string;
29
+ /** Documents under it (any depth), for a bubbled mark when it's closed. */
30
+ under: ListedDocument[];
31
+ }
32
+ | {
33
+ /** A folder with no page of its own: shows or hides its children only. */
34
+ kind: 'group';
35
+ path: string;
36
+ depth: number;
37
+ /** The folder name, hyphens as spaces, capital first letter (`case-studies` → "Case studies"). */
38
+ label: string;
39
+ open: boolean;
40
+ key: string;
41
+ under: ListedDocument[];
42
+ };
43
+
44
+ /**
45
+ * The rows a collection's tree shows, given which groups are open. A row under a
46
+ * closed group (at any depth) isn't returned. `documents` must be one collection's,
47
+ * in the reader's order.
48
+ */
49
+ export function treeRows(collection: string, documents: readonly ListedDocument[], open: UiState['open']): TreeRow[] {
50
+ const paths = new Set(documents.map((d) => d.path));
51
+ const isOpen = (path: string) => open[groupKey(collection, path)] === true;
52
+ const under = (path: string) => documents.filter((d) => d.path.startsWith(`${path}/`));
53
+ /** Hidden when any folder above it is closed. */
54
+ const hidden = (path: string) => {
55
+ const segments = path.split('/');
56
+ for (let i = 1; i < segments.length; i++) if (!isOpen(segments.slice(0, i).join('/'))) return true;
57
+ return false;
58
+ };
59
+
60
+ const rows: TreeRow[] = [];
61
+ const emitted = new Set<string>();
62
+ for (const doc of documents) {
63
+ const segments = doc.path.split('/');
64
+ for (let i = 1; i < segments.length; i++) {
65
+ const folder = segments.slice(0, i).join('/');
66
+ if (paths.has(folder) || emitted.has(folder)) continue;
67
+ emitted.add(folder);
68
+ if (hidden(folder)) continue;
69
+ rows.push({
70
+ kind: 'group',
71
+ path: folder,
72
+ depth: i - 1,
73
+ label: titleCase(segments[i - 1]!),
74
+ open: isOpen(folder),
75
+ key: groupKey(collection, folder),
76
+ under: under(folder),
77
+ });
78
+ }
79
+ if (hidden(doc.path)) continue;
80
+ const kids = under(doc.path);
81
+ rows.push({
82
+ kind: 'page',
83
+ path: doc.path,
84
+ depth: segments.length - 1,
85
+ doc,
86
+ parent: kids.length > 0,
87
+ open: isOpen(doc.path),
88
+ key: groupKey(collection, doc.path),
89
+ under: kids,
90
+ });
91
+ }
92
+ return rows;
93
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Mounted once in the shell (App.tsx): starts the overlay layer, editing from the
3
+ * page (click to edit, typing on the page, hover), the editor's own marks and the
4
+ * formatting bar of rich text typed on the page (rich-text-bar.ts), and
5
+ * following links from the page (follow-links.ts). Renders
6
+ * nothing; on the server it does nothing.
7
+ */
8
+ import { useEffect } from 'react';
9
+ import { useEditor } from '../context.tsx';
10
+ import { pageEditFor } from '../preview/page-edit.ts';
11
+ import { startFollowLinks } from '../preview/follow-links.ts';
12
+ import { startEditorMarks } from './editor-marks.ts';
13
+ import { overlaysFor } from './index.ts';
14
+ import { startRichTextBar } from './rich-text-bar.ts';
15
+
16
+ export function OverlayMount() {
17
+ const editor = useEditor();
18
+ useEffect(() => {
19
+ const overlays = overlaysFor(editor);
20
+ const pageEdit = pageEditFor(editor);
21
+ if (!overlays || !pageEdit) return;
22
+ const stops = [startEditorMarks(editor, overlays, pageEdit), startRichTextBar(editor, overlays, pageEdit), startFollowLinks(editor, overlays)];
23
+ return () => stops.forEach((stop) => stop());
24
+ }, [editor]);
25
+ return null;
26
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The editor's own overlay marks (M2 → Overlays), drawn into the shared layer on the
3
+ * `editor` channel, from the editor's state:
4
+ *
5
+ * selected the open block (the block level): 2px, a filled tag with its type
6
+ * hover the block under the pointer on the page, or its row in the block list
7
+ * (block-list/link.ts → blockHover, which this also sets from the page)
8
+ * field-hover a field under the pointer inside the open block
9
+ * picked the picked field, ringed with its name; teal "· Phone lines" in the
10
+ * phone frame when the lockup has its own phone lines
11
+ * typing the field being typed on the page: "Typing on the page" (rich text: the
12
+ * ring alone, its formatting bar sits there; rich-text-bar.ts)
13
+ * warn a lockup too long for mobile, in the phone frame (previewStatus.warnings)
14
+ * skipped an unknown block, where it would be on the page; its pill opens it. A
15
+ * shared-block entry (M5a Decision 8) only when the listing has no such
16
+ * shared block: one that exists renders on the page (its own screen is Phase 5's)
17
+ *
18
+ * None while the preview shows another document than the sidebar (one is opening).
19
+ */
20
+ import { blockHover, setBlockHover } from '../block-list/link.ts';
21
+ import type { Editor } from '../editor.ts';
22
+ import type { PageEdit } from '../preview/page-edit.ts';
23
+ import { blockLabel } from '../state/document-store.ts';
24
+ import { fieldAt, fieldAtPath, getAtPath, titleCase } from '../../../fields.ts';
25
+ import { own, sharedEntryOf } from '../../../document.ts';
26
+ import { sameDoc, type EditorBlock } from '../../types.ts';
27
+ import type { Fields } from '../../../fields.ts';
28
+ import { fieldsOfBlock } from '../state/schema.ts';
29
+ import type { Mark, Overlays } from './layer.ts';
30
+
31
+ export const EDITOR_CHANNEL = 'editor';
32
+
33
+ /**
34
+ * The name a picked field is ringed with. A dotted path (M5a Decision 16) names the
35
+ * field and the item: `credits.1.name` → "Credits 2 · Name", `cta.label` → "Cta",
36
+ * `left.heading` (a group's field) → "Left · Heading".
37
+ */
38
+ export function fieldLabel(fields: Fields | undefined, field: string): string {
39
+ const schema = fields ? { fields } : undefined;
40
+ const descriptor = schema ? own(schema.fields, field) : undefined;
41
+ if (descriptor || !field.includes('.')) return descriptor?.label ?? titleCase(field);
42
+ const [top, index, sub] = field.split('.');
43
+ const outer = schema ? own(schema.fields, top!) : undefined;
44
+ const name = outer?.label ?? titleCase(top!);
45
+ // A field in a group: `left.heading` → "Left · Heading".
46
+ if (outer?.kind === 'group' && index !== undefined && Object.hasOwn(outer.fields, index)) return `${name} · ${fieldLabel(outer.fields, field.slice(top!.length + 1))}`;
47
+ if (!/^\d+$/.test(index ?? '')) return name;
48
+ const inner = schema && sub ? fieldAtPath(schema.fields, field) : undefined;
49
+ const subName = sub && inner && inner.rest.length === 0 && inner.field !== outer ? (inner.field.label ?? titleCase(sub)) : null;
50
+ return subName ? `${name} ${Number(index) + 1} · ${subName}` : `${name} ${Number(index) + 1}`;
51
+ }
52
+
53
+ /** The marks for the editor's state now. Pure apart from reading the stores. */
54
+ export function editorMarks(editor: Editor, pageEdit: PageEdit): Mark[] {
55
+ const status = editor.previewStatus.get();
56
+ const session = editor.doc.get();
57
+ if (status.opening || !sameDoc(status.showing, session.ref)) return [];
58
+ const view = editor.view.get();
59
+ const blocks = session.document.blocks;
60
+ const marks: Mark[] = [];
61
+
62
+ const listed = editor.documents.get();
63
+ const sharedExists = (name: string) => listed.some((d) => d.collection === 'shared' && d.path === name && !d.warning && d.blockType && own(editor.site.blocks, d.blockType));
64
+ /** A block's tag: its type's label, or a shared entry's shared block's title (M5a). */
65
+ const entryLabel = (block: EditorBlock) => {
66
+ const shared = sharedEntryOf(block);
67
+ return shared !== undefined ? (listed.find((d) => d.collection === 'shared' && d.path === shared)?.label ?? shared) : blockLabel(editor.site, block);
68
+ };
69
+ blocks.forEach((block, index) => {
70
+ if (block.known) return;
71
+ const shared = sharedEntryOf(block);
72
+ if (shared !== undefined && sharedExists(shared)) return;
73
+ marks.push({
74
+ kind: 'skipped',
75
+ block: index,
76
+ label: shared !== undefined ? `Skipped on the page: shared block ${shared}` : `Skipped on the page: ${block.type || 'no type'}`,
77
+ onClick: () => editor.openBlock(block.key),
78
+ });
79
+ });
80
+
81
+ // The selected block is outlined in the block level and in the block list it's selected in
82
+ // (the mockup's #blocks and #picker); its fields' marks only while it's open.
83
+ // A form (a fields document, a shared block, Site Settings: M5a) has no block list: its
84
+ // fields' marks show at its document level, and no block is outlined as selected.
85
+ const form = session.document.kind !== 'sections';
86
+ // M5b: a slot's picker (the library in place of the block's fields) keeps the block selected and the slot ringed.
87
+ const picking = view.level === 'media' && !!view.media?.pick;
88
+ const inList = !form && (view.level === 'block' || view.level === 'document' || picking);
89
+ const inBlock = editor.fieldsOpen(view) || picking;
90
+ const selectedIndex = form ? (inBlock ? 0 : -1) : inList && view.block ? session.document.blocks.findIndex((b) => b.key === view.block) : -1;
91
+ const selected = selectedIndex >= 0 ? blocks[selectedIndex]! : null;
92
+ if (selected && !form && (selected.known || sharedExists(sharedEntryOf(selected) ?? ''))) marks.push({ kind: 'selected', block: selectedIndex, label: entryLabel(selected) });
93
+
94
+ const hoverKey = blockHover(editor).get();
95
+ const hoverIndex = hoverKey ? blocks.findIndex((b) => b.key === hoverKey) : -1;
96
+ const hovered = hoverIndex >= 0 ? blocks[hoverIndex]! : null;
97
+ if (hovered && !form && (hovered.known || sharedExists(sharedEntryOf(hovered) ?? '')) && hoverIndex !== selectedIndex) marks.push({ kind: 'hover', block: hoverIndex, label: entryLabel(hovered) });
98
+
99
+ const pageHover = pageEdit.hover.get();
100
+ if (inBlock && selected?.known && pageHover && pageHover.block === selectedIndex && pageHover.field && pageHover.field !== view.field) {
101
+ marks.push({ kind: 'field-hover', block: selectedIndex, field: pageHover.field });
102
+ }
103
+
104
+ if (inBlock && selected?.known && view.field) {
105
+ const field = view.field;
106
+ const fields = fieldsOfBlock(editor.site, session.document, session.ref.collection, selected);
107
+ const label = fieldLabel(fields, field);
108
+ const typing = pageEdit.typing.get();
109
+ const descriptor = fieldAt(fields, field);
110
+ // A field warned in the phone frame shows the amber "Too long for mobile" tag there,
111
+ // not its own name too (the mockup's #phone-main): its ring stays, its tag goes.
112
+ const warned = status.warnings.some((w) => w.tooLong && w.block === selectedIndex && w.field === field);
113
+ const phone = (mark: Mark): Mark => (warned ? { ...mark, label: undefined } : mark);
114
+ if (typing && typing.key === selected.key && typing.field === field) {
115
+ // Rich text keeps the ring but not its chip: the formatting bar sits there (on-page rich text, Decision 9).
116
+ marks.push({ kind: 'typing', block: selectedIndex, field, label: descriptor?.kind === 'richText' ? undefined : 'Typing on the page' });
117
+ } else if (descriptor?.kind === 'lockup' && hasPhoneLines(getAtPath(selected.data, field))) {
118
+ marks.push({ kind: 'picked', block: selectedIndex, field, label, frames: ['desktop', 'tablet'] });
119
+ marks.push(phone({ kind: 'picked', block: selectedIndex, field, label: `${label} · Phone lines`, tone: 'phone', frames: ['phone'] }));
120
+ } else if (warned) {
121
+ marks.push({ kind: 'picked', block: selectedIndex, field, label, frames: ['desktop', 'tablet'] });
122
+ marks.push(phone({ kind: 'picked', block: selectedIndex, field, label, frames: ['phone'] }));
123
+ } else {
124
+ marks.push({ kind: 'picked', block: selectedIndex, field, label });
125
+ }
126
+ }
127
+
128
+ for (const warning of status.warnings) {
129
+ if (warning.tooLong) marks.push({ kind: 'warn', block: warning.block, field: warning.field, label: 'Too long for mobile', frames: ['phone'] });
130
+ }
131
+ return marks;
132
+ }
133
+
134
+ const hasPhoneLines = (value: unknown) => !!value && typeof value === 'object' && Array.isArray((value as { phone?: unknown }).phone) && (value as { phone: unknown[] }).phone.length > 0;
135
+
136
+ /** Keep the `editor` channel up to date. Returns a stop. */
137
+ export function startEditorMarks(editor: Editor, overlays: Overlays, pageEdit: PageEdit): () => void {
138
+ const update = () => overlays.set(EDITOR_CHANNEL, editorMarks(editor, pageEdit));
139
+ const hover = blockHover(editor);
140
+ const stops = [
141
+ editor.view.subscribe(update),
142
+ editor.doc.state.subscribe(update),
143
+ editor.previewStatus.subscribe(update),
144
+ pageEdit.typing.subscribe(update),
145
+ pageEdit.hover.subscribe(update),
146
+ hover.subscribe(update),
147
+ // The page's hover lights up the block's row in the list too (and the list's, the page).
148
+ pageEdit.hover.subscribe((now, before) => {
149
+ if (now?.block === before?.block) return;
150
+ const key = now ? editor.doc.keyAt(now.block) ?? null : null;
151
+ if (now || hover.get() === (before ? editor.doc.keyAt(before.block) : null)) setBlockHover(editor, key);
152
+ }),
153
+ ];
154
+ update();
155
+ return () => {
156
+ for (const stop of stops) stop();
157
+ overlays.clear(EDITOR_CHANNEL);
158
+ };
159
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The overlays, one per editor (editor/README.md → Overlays).
3
+ *
4
+ * import { overlaysFor } from '../overlays/index.ts';
5
+ * const overlays = overlaysFor(editor); // null on the server
6
+ * overlays?.set('block-list', [{ kind: 'hover', block: 2, label: 'Text' }]);
7
+ * overlays?.clear('block-list');
8
+ * const remove = overlays?.addPainter((ctx) => …elements…);
9
+ *
10
+ * The first call creates the layer and attaches it to the editor's preview driver;
11
+ * every later call returns the same one. OverlayMount (mounted once in the shell)
12
+ * makes that first call and draws the editor's own marks (editor-marks.ts).
13
+ */
14
+ import type { Editor } from '../editor.ts';
15
+ import { displayName } from '../live/format.ts';
16
+ import { sameDoc } from '../../types.ts';
17
+ import { createOverlays, type Overlays } from './layer.ts';
18
+
19
+ export type { Mark, MarkKind, OverlayContext, OverlayPainter, Overlays, Rect } from './layer.ts';
20
+ export { LAYER_ID } from './layer.ts';
21
+
22
+ const made = new WeakMap<Editor, Overlays>();
23
+
24
+ export function overlaysFor(editor: Editor): Overlays | null {
25
+ if (!editor.preview || typeof window === 'undefined') return null;
26
+ let overlays = made.get(editor);
27
+ if (!overlays) {
28
+ overlays = createOverlays({
29
+ driver: editor.preview,
30
+ layout: editor.stageLayout,
31
+ visibleWidth: () => editor.view.get().width,
32
+ // M4: a read-only document has no overlays; a lock gives the veil (Decision 9).
33
+ mode: () => {
34
+ const access = editor.access.get();
35
+ if (!access.readOnly) return { kind: 'normal' };
36
+ if (!access.veil || !sameDoc(editor.previewStatus.get().showing, editor.doc.get().ref)) return { kind: 'quiet' };
37
+ const who = access.lock === 'theirs' && access.holder ? `${displayName(access.holder.person)} is editing` : "You're editing this in another tab";
38
+ return { kind: 'veil', title: who };
39
+ },
40
+ });
41
+ made.set(editor, overlays);
42
+ editor.access.subscribe(() => overlays!.redraw());
43
+ editor.previewStatus.subscribe((s, previous) => s.showing !== previous.showing && overlays!.redraw());
44
+ // The width on screen decides where hover and insert points go.
45
+ editor.view.subscribe((view, previous) => {
46
+ if (view.width !== previous.width) overlays!.redraw();
47
+ });
48
+ }
49
+ return overlays;
50
+ }