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,406 @@
1
+ /**
2
+ * A flat collection as a table over the stage (pre-M7 polish; Rich's "table over the
3
+ * stage"): at a collection's level (Case studies, the Journal, Shared blocks) the
4
+ * stage shows its documents full width in place of the preview, which stays behind it.
5
+ *
6
+ * The head: the collection's name and count, a find box, a filter for each select,
7
+ * toggle or reference column, and **+ New**. The table: the title, then the columns the
8
+ * config picks (collections/table.ts). A column's header sorts by it (again: the other
9
+ * way); `aria-sort` says which. A list of references doesn't sort, so its header is a
10
+ * plain label (posts and tags, Decision 13). The view opens in the config's `sort`. A row opens its document in the editor (the row, or its
11
+ * title's button from the keyboard), and keeps the marks a sidebar row has: a file
12
+ * that can't be opened (it says why, and doesn't open), and in live mode the merge
13
+ * mark, someone's lock, presence and the waiting dot (live/marks.tsx). The open
14
+ * document's row is a wash with a ring, as the sidebar's (no side rails).
15
+ *
16
+ * The find, filters and order are one view with the sidebar's list (tableViewFor),
17
+ * so the two never disagree. Nothing loads: the rows are the listing the editor
18
+ * already has, field values included (entry.columns).
19
+ *
20
+ * M6c (G5): a collection whose documents can hold an image (its `view`, Decision 13)
21
+ * gets a List / Grid switch at the end of the tools row; Grid shows the same view's
22
+ * documents as cards (CollectionGrid.tsx), with an order select in place of the
23
+ * headers. The choice is the editor's, per collection, in the UI cookie (Decision 18;
24
+ * the config's `view.default` until they pick), so the view is right the moment it's drawn.
25
+ */
26
+ import { Fragment, useMemo, type KeyboardEvent, type MouseEvent } from 'react';
27
+ import { useEditor, useStore } from '../context.tsx';
28
+ import type { Editor } from '../editor.ts';
29
+ import { createStore, type Store } from '../state/store.ts';
30
+ import { Icon } from '../shell/Icons.tsx';
31
+ import { docIcon } from '../nav/DocRow.tsx';
32
+ import { DocMarksView } from '../live/marks.tsx';
33
+ import { when } from '../live/format.ts';
34
+ import { openDialog } from '../documents/dialogs.tsx';
35
+ import { PAGELESS_SUB, singular } from '../documents/actions.ts';
36
+ import { usedOnLine } from '../block-list/summary.ts';
37
+ import { docId, type EditorAnyCollection, type ListedDocument } from '../../types.ts';
38
+ import { captionColumns, cellText, cellValue, defaultView, filterable, MISSING, nextSort, orderOptions, orderValue, refIndex, refParts, sortable, tableColumns, viewRows, type FilterValue, type RefIndex, type TableColumn, type TableView } from './table.ts';
39
+ import { useCollection } from '../fields/ReferenceField.tsx';
40
+ import { CollectionGrid } from './CollectionGrid.tsx';
41
+ import { viewModeOf } from '../../ui-state.ts';
42
+ import type { ViewMode } from '../../types.ts';
43
+
44
+ const views = new WeakMap<Editor, Store<Record<string, TableView>>>();
45
+
46
+ /** The editor's table views, one per collection: kept while the editor runs, so going back finds it as left. */
47
+ export function tableViewsFor(editor: Editor): Store<Record<string, TableView>> {
48
+ let store = views.get(editor);
49
+ if (!store) views.set(editor, (store = createStore<Record<string, TableView>>({})));
50
+ return store;
51
+ }
52
+
53
+ /** One collection's view, and a way to change it. It opens in the config's `sort` (Decision 13), else the title A to Z. */
54
+ export function useTableView(name: string): [TableView, (patch: Partial<TableView>) => void] {
55
+ const editor = useEditor();
56
+ const store = tableViewsFor(editor);
57
+ const sort = editor.site.collections.find((c) => c.name === name)?.sort;
58
+ const view = useStore(store, (all) => all[name]) ?? defaultView(sort);
59
+ const update = (patch: Partial<TableView>) => store.set((all) => ({ ...all, [name]: { ...(all[name] ?? defaultView(sort)), ...patch } }));
60
+ return [view, update];
61
+ }
62
+
63
+ /** Every listed document by collection and path, for reference columns (table.ts → refIndex). */
64
+ export function useRefIndex(): RefIndex {
65
+ const editor = useEditor();
66
+ const documents = useStore(editor.documents);
67
+ return useMemo(() => refIndex(documents), [documents]);
68
+ }
69
+
70
+ /** The collection's table, when the stage shows one: a flat collection at its own level. */
71
+ export function useTableCollection(): EditorAnyCollection | null {
72
+ const editor = useEditor();
73
+ const name = useStore(editor.view, (v) => (v.level === 'collection' ? v.collection : null));
74
+ const collection = name ? editor.site.collections.find((c) => c.name === name) : undefined;
75
+ return collection && !collection.nested ? collection : null;
76
+ }
77
+
78
+ /** M6c (G5): the view a collection shows, list or grid; always list without the switch (Decision 13). */
79
+ export function useViewMode(collection: EditorAnyCollection): ViewMode {
80
+ const editor = useEditor();
81
+ const view = collection.kind === 'shared' ? null : collection.view;
82
+ return useStore(editor.ui, (ui) => (view ? viewModeOf(ui, collection.name, view.default) : 'list'));
83
+ }
84
+
85
+ const openDoc = (editor: Editor, doc: ListedDocument) => {
86
+ if (doc.warning) {
87
+ editor.announce(`${doc.label} can't be opened. ${doc.warning.message}`);
88
+ return;
89
+ }
90
+ void editor.openDocument({ collection: doc.collection, path: doc.path });
91
+ };
92
+
93
+ export function CollectionTable({ collection }: { collection: EditorAnyCollection }) {
94
+ const editor = useEditor();
95
+ const documents = useStore(editor.documents);
96
+ const currentId = useStore(editor.doc.state, (s) => docId(s.ref));
97
+ const readOnly = useStore(editor.access, (a) => !!a.signedOut);
98
+ const [view, update] = useTableView(collection.name);
99
+ const columns = tableColumns(collection);
100
+ const refs = useRefIndex();
101
+ const docs = documents.filter((d) => d.collection === collection.name);
102
+ const rows = viewRows(docs, columns, view, refs);
103
+ const shared = collection.kind === 'shared';
104
+ const one = singular(collection);
105
+ const create = () => openDialog(editor, shared ? { kind: 'new-shared' } : { kind: 'new', collection: collection.name });
106
+ const now = new Date().toISOString();
107
+ const zone = editor.timeZone();
108
+ const edited = (ms: number) => when(new Date(ms).toISOString(), zone, now);
109
+ const filters = filterable(columns);
110
+ const filtered = view.query.trim() !== '' || Object.keys(view.filters).length > 0;
111
+ const mode = useViewMode(collection);
112
+ const switches = !!collection.view && !shared;
113
+ const grid = mode === 'grid';
114
+ const orders = grid ? orderOptions(columns, view.sort) : [];
115
+
116
+ const setFilter = (key: string, value: FilterValue | null) => {
117
+ const next = { ...view.filters };
118
+ if (value === null) delete next[key];
119
+ else next[key] = value;
120
+ update({ filters: next });
121
+ };
122
+
123
+ return (
124
+ <div className="ctable" role="region" aria-label={`${collection.label}, as ${grid ? 'a grid' : 'a table'}`} data-collection-table={collection.name} data-view={mode}>
125
+ <div className="ct-head">
126
+ <div className="ct-title">
127
+ <Icon name={docIcon(collection)} />
128
+ <h2>{collection.label}</h2>
129
+ <span className="n-chip">{filtered ? `${rows.length} of ${docs.length}` : docs.length}</span>
130
+ {shared ? null : collection.urlBase === null ? <span className="ct-sub">{PAGELESS_SUB}</span> : <span className="ct-sub mono">{`${editor.site.host}${collection.urlBase}…`}</span>}
131
+ </div>
132
+ <button type="button" className="btn sm" data-act="table-new" disabled={readOnly} onClick={create}>
133
+ <Icon name="plus" size="sm" />
134
+ {shared ? 'New shared block' : `New ${one}`}
135
+ </button>
136
+ {docs.length > 0 ? (
137
+ <div className="ct-tools">
138
+ <div className="find">
139
+ <Icon name="search" />
140
+ <input
141
+ className="input"
142
+ type="search"
143
+ placeholder={shared ? 'Find a shared block' : `Find in ${collection.label}`}
144
+ aria-label={shared ? 'Find a shared block' : `Find in ${collection.label}`}
145
+ data-filter="table"
146
+ autoComplete="off"
147
+ spellCheck={false}
148
+ value={view.query}
149
+ onChange={(e) => update({ query: e.target.value })}
150
+ onKeyDown={(e) => {
151
+ if (e.key === 'Escape' && view.query) {
152
+ e.preventDefault();
153
+ update({ query: '' });
154
+ }
155
+ }}
156
+ />
157
+ </div>
158
+ {filters.map((c) => (
159
+ <FilterSelect key={c.key} column={c} value={view.filters[c.key]} onChange={(v) => setFilter(c.key, v)} />
160
+ ))}
161
+ {grid ? (
162
+ <select
163
+ className="input ct-filter ct-order"
164
+ aria-label="Order"
165
+ data-order=""
166
+ value={orderValue(view.sort)}
167
+ onChange={(e) => {
168
+ const picked = orders.find((o) => o.value === e.target.value);
169
+ if (picked) update({ sort: picked.sort });
170
+ }}
171
+ >
172
+ {orders.map((o) => (
173
+ <option key={o.value} value={o.value}>
174
+ Order: {o.label}
175
+ </option>
176
+ ))}
177
+ </select>
178
+ ) : null}
179
+ {switches ? (
180
+ <div className="mini-seg ct-view" role="radiogroup" aria-label="Show as">
181
+ {(['list', 'grid'] as const).map((m) => (
182
+ <button
183
+ key={m}
184
+ type="button"
185
+ role="radio"
186
+ aria-checked={mode === m}
187
+ data-view-mode={m}
188
+ onClick={() => editor.setViewMode(collection.name, m)}
189
+ >
190
+ <Icon name={m} size="sm" />
191
+ {m === 'list' ? 'List' : 'Grid'}
192
+ </button>
193
+ ))}
194
+ </div>
195
+ ) : null}
196
+ </div>
197
+ ) : null}
198
+ </div>
199
+ {docs.length === 0 ? (
200
+ <div className="empty ct-empty" data-empty="collection-table">
201
+ <b>{shared ? 'No shared blocks yet' : `Nothing in ${collection.label} yet`}</b>
202
+ {!shared && collection.urlBase === null ? <span>No pages of their own.</span> : null}
203
+ </div>
204
+ ) : (
205
+ <div className="ct-scroll">
206
+ {grid ? (
207
+ rows.length > 0 ? <CollectionGrid collection={collection} rows={rows} captions={captionColumns(collection, columns)} currentId={currentId} edited={edited} refs={refs} open={(d) => openDoc(editor, d)} /> : null
208
+ ) : (
209
+ <table className="ct">
210
+ <thead>
211
+ <tr>
212
+ {columns.map((c) => {
213
+ const on = view.sort.key === c.key;
214
+ if (!sortable(c)) {
215
+ return (
216
+ <th key={c.key} scope="col" className={`ct-${c.kind}`}>
217
+ <span className="ct-sort ct-label">{c.label}</span>
218
+ </th>
219
+ );
220
+ }
221
+ return (
222
+ <th key={c.key} scope="col" className={`ct-${c.kind}`} aria-sort={on ? (view.sort.dir === 'asc' ? 'ascending' : 'descending') : undefined}>
223
+ <button type="button" className={`ct-sort${on ? ' on' : ''}`} onClick={() => update({ sort: nextSort(view.sort, c.key) })}>
224
+ <span>{c.label}</span>
225
+ <Icon name={on && view.sort.dir === 'desc' ? 'down' : 'up'} size="xs" />
226
+ </button>
227
+ </th>
228
+ );
229
+ })}
230
+ </tr>
231
+ </thead>
232
+ <tbody>
233
+ {rows.map((d) => (
234
+ <Row key={d.path} doc={d} columns={columns} current={docId(d) === currentId} edited={edited} refs={refs} extra={shared ? usedOnLine(d) : null} />
235
+ ))}
236
+ </tbody>
237
+ </table>
238
+ )}
239
+ {rows.length === 0 ? (
240
+ <p className="no-hits ct-none" role="status">
241
+ <b>Nothing in {collection.label} matches</b>
242
+ <button type="button" className="linkbtn" onClick={() => update({ query: '', filters: {} })}>
243
+ Show all {docs.length}
244
+ </button>
245
+ </p>
246
+ ) : null}
247
+ </div>
248
+ )}
249
+ </div>
250
+ );
251
+ }
252
+
253
+ function FilterSelect({ column, value, onChange }: { column: TableColumn; value: FilterValue | undefined; onChange: (value: FilterValue | null) => void }) {
254
+ if (column.kind === 'reference' || column.kind === 'references') return <RefFilterSelect column={column} value={value} onChange={onChange} />;
255
+ const field = column.field;
256
+ const options: { value: string; label: string }[] =
257
+ field?.kind === 'select'
258
+ ? [{ value: '', label: `Any ${column.label.toLowerCase()}` }, ...field.options.map((o) => ({ value: `s:${o.value}`, label: o.label }))]
259
+ : [
260
+ { value: '', label: `${column.label}: any` },
261
+ { value: 'b:1', label: `${column.label}: on` },
262
+ { value: 'b:0', label: `${column.label}: off` },
263
+ ];
264
+ const current = value === undefined ? '' : typeof value === 'boolean' ? (value ? 'b:1' : 'b:0') : `s:${value}`;
265
+ return (
266
+ <select
267
+ className={`input ct-filter${value === undefined ? '' : ' is-set'}`}
268
+ aria-label={`Filter by ${column.label.toLowerCase()}`}
269
+ data-filter-key={column.key}
270
+ value={current}
271
+ onChange={(e) => {
272
+ const v = e.target.value;
273
+ onChange(v === '' ? null : v.startsWith('b:') ? v === 'b:1' : v.slice(2));
274
+ }}
275
+ >
276
+ {options.map((o) => (
277
+ <option key={o.value} value={o.value}>
278
+ {o.label}
279
+ </option>
280
+ ))}
281
+ </select>
282
+ );
283
+ }
284
+
285
+ /**
286
+ * A reference column's filter (Decision 13): one referenced document, by title ("Tag:
287
+ * Print", the column's label as one of them), keeping the rows that point at it (for a list, among others). The options are
288
+ * the referenced collection's documents, in its listing order; a filter on one that's
289
+ * since gone stays listed, as Missing, so the select never lies.
290
+ */
291
+ function RefFilterSelect({ column, value, onChange }: { column: TableColumn; value: FilterValue | undefined; onChange: (value: FilterValue | null) => void }) {
292
+ const { documents } = useCollection(column.to ?? '');
293
+ // "Tag: Print" for a list called Tags; a single reference by its own label ("Author: Ada").
294
+ const one = column.kind === 'references' ? singularLabel(column.label) : column.label;
295
+ const options = [{ value: '', label: `Any ${one.toLowerCase()}` }, ...documents.map((d) => ({ value: `r:${d.path}`, label: `${one}: ${d.label || d.title}` }))];
296
+ const current = value === undefined ? '' : `r:${String(value)}`;
297
+ if (value !== undefined && !options.some((o) => o.value === current)) options.push({ value: current, label: `${one}: ${MISSING}` });
298
+ return (
299
+ <select
300
+ className={`input ct-filter${value === undefined ? '' : ' is-set'}`}
301
+ aria-label={`Filter by ${one.toLowerCase()}`}
302
+ data-filter-key={column.key}
303
+ value={current}
304
+ onChange={(e) => onChange(e.target.value === '' ? null : e.target.value.slice(2))}
305
+ >
306
+ {options.map((o) => (
307
+ <option key={o.value} value={o.value}>
308
+ {o.label}
309
+ </option>
310
+ ))}
311
+ </select>
312
+ );
313
+ }
314
+
315
+ /** A list's label as one of its items: "Tags" → "Tag", "Categories" → "Category". */
316
+ const singularLabel = (label: string) => (/ies$/i.test(label) ? label.replace(/ies$/i, 'y') : /[^s]s$/i.test(label) ? label.slice(0, -1) : label);
317
+
318
+ /** A reference cell (Decision 13): the title, or each of a list's joined by commas; one that's gone is "Missing", in the warning colour. */
319
+ function RefCell({ doc, column, refs }: { doc: ListedDocument; column: TableColumn; refs: RefIndex }) {
320
+ const parts = refParts(doc, column, refs);
321
+ if (parts.length === 0) {
322
+ return (
323
+ <td className={`ct-${column.kind}`}>
324
+ <span className="ct-nil" aria-label="None">–</span>
325
+ </td>
326
+ );
327
+ }
328
+ return (
329
+ <td className={`ct-${column.kind}`} title={cellText(doc, column, () => '', refs)}>
330
+ {parts.map((p, i) => (
331
+ <Fragment key={`${i}:${p.path}`}>
332
+ {i > 0 ? ', ' : null}
333
+ {p.title === null ? (
334
+ <span className="ct-missing" data-missing={p.path}>
335
+ {MISSING}
336
+ </span>
337
+ ) : (
338
+ p.title
339
+ )}
340
+ </Fragment>
341
+ ))}
342
+ </td>
343
+ );
344
+ }
345
+
346
+ function Row(props: { doc: ListedDocument; columns: readonly TableColumn[]; current: boolean; edited: (ms: number) => string; refs: RefIndex; extra: string | null }) {
347
+ const editor = useEditor();
348
+ const { doc } = props;
349
+ const open = () => openDoc(editor, doc);
350
+ const onClick = (event: MouseEvent) => {
351
+ // The title's own button handles its click; a selection being made isn't a click on the row.
352
+ if ((event.target as Element).closest('button') || String(window.getSelection() ?? '') !== '') return;
353
+ open();
354
+ };
355
+ const onKeyDown = (event: KeyboardEvent) => {
356
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
357
+ const row = (event.currentTarget as HTMLElement).closest('tr');
358
+ const next = (event.key === 'ArrowDown' ? row?.nextElementSibling : row?.previousElementSibling)?.querySelector<HTMLElement>('.ct-open');
359
+ if (next) {
360
+ event.preventDefault();
361
+ next.focus();
362
+ }
363
+ }
364
+ };
365
+ return (
366
+ <tr className={[props.current ? 'is-current' : '', doc.warning ? 'is-warned' : ''].filter(Boolean).join(' ') || undefined} data-doc={docId(doc)} onClick={onClick}>
367
+ {props.columns.map((c) => {
368
+ if (c.kind === 'title') {
369
+ return (
370
+ <th key={c.key} scope="row" className="ct-title-cell">
371
+ <button type="button" className="ct-open" aria-current={props.current ? 'page' : undefined} title={doc.warning?.message} onClick={open} onKeyDown={onKeyDown}>
372
+ <span className="nm">{doc.label}</span>
373
+ </button>
374
+ <span className="meta">
375
+ {doc.warning ? (
376
+ <span className="wmark" data-warning={doc.warning.code} title={doc.warning.message}>
377
+ <Icon name="warn" size="xs" />
378
+ <span className="sr">, can't be opened: {doc.warning.message}</span>
379
+ </span>
380
+ ) : null}
381
+ {editor.mode === 'live' ? <DocMarksView file={doc.file} /> : <span data-slot="presence" hidden />}
382
+ </span>
383
+ {props.extra ? <span className="ct-extra">{props.extra}</span> : null}
384
+ </th>
385
+ );
386
+ }
387
+ if (c.kind === 'toggle') {
388
+ const on = cellValue(doc, c) === true;
389
+ return (
390
+ <td key={c.key} className="ct-toggle">
391
+ {on ? <Icon name="check" size="sm" /> : null}
392
+ <span className="sr">{on ? 'Yes' : 'No'}</span>
393
+ </td>
394
+ );
395
+ }
396
+ if (c.kind === 'reference' || c.kind === 'references') return <RefCell key={c.key} doc={doc} column={c} refs={props.refs} />;
397
+ const text = cellText(doc, c, props.edited);
398
+ return (
399
+ <td key={c.key} className={`ct-${c.kind}`}>
400
+ {text ? text : <span className="ct-nil" aria-label="None">–</span>}
401
+ </td>
402
+ );
403
+ })}
404
+ </tr>
405
+ );
406
+ }
@@ -0,0 +1,270 @@
1
+ /**
2
+ * The collections table's rows and columns (pre-M7 polish: a flat collection's
3
+ * documents as a table over the stage). Pure, so the sidebar's list and the table
4
+ * read one view: the same find, filters and order.
5
+ *
6
+ * Columns: the title always, then what the collection's config names in `columns`
7
+ * (its fields, by key, and the two built-ins `address` and `edited`); without
8
+ * `columns`, the address and when it was last edited. A field column's values ride
9
+ * on each listed document (`entry.columns`, content.ts), so the table draws at once.
10
+ *
11
+ * Posts and tags (Decision 13): a section builder's page fields can be columns too,
12
+ * and two more kinds, for both collection kinds: a reference, shown as the referenced
13
+ * document's title ("Missing" when it's gone), and a list of references, its titles
14
+ * joined by commas. Titles come from the editor's listing of every collection (`RefIndex`).
15
+ *
16
+ * Filters: one per select, toggle, reference or list-of-references column, an exact
17
+ * match on its value (a reference's: the document's path; a list's: one of them).
18
+ * Sorting: by any column but a list of references, either way; documents without a
19
+ * value go last whichever way. The view starts in the config's `sort`, else the title A to Z.
20
+ */
21
+ import type { Field, Fields } from '../../../fields.ts';
22
+ import { titleCase } from '../../../fields.ts';
23
+ import type { EditorAnyCollection, ListedDocument } from '../../types.ts';
24
+ import { formatDay } from '../fields/date.ts';
25
+ import { filterCollection } from '../nav/search.ts';
26
+ import { DEFAULT_COLUMNS, columnKindOf, type SortConfig } from '../../../columns.ts';
27
+
28
+ export type ColumnKind = 'title' | 'address' | 'edited' | 'text' | 'select' | 'toggle' | 'date' | 'number' | 'reference' | 'references';
29
+
30
+ export interface TableColumn {
31
+ key: string;
32
+ label: string;
33
+ kind: ColumnKind;
34
+ field?: Field;
35
+ /** A reference or list-of-references column's: the collection its documents are in. */
36
+ to?: string;
37
+ }
38
+
39
+ /** The listed documents a reference can point at, by `collection` and `path` (refIndex). */
40
+ export type RefIndex = ReadonlyMap<string, ListedDocument>;
41
+ const refKey = (collection: string, path: string) => `${collection}:${path}`;
42
+
43
+ /** Every listed document by collection and path, for reference columns' titles. */
44
+ export function refIndex(documents: readonly ListedDocument[]): RefIndex {
45
+ return new Map(documents.map((d) => [refKey(d.collection, d.path), d]));
46
+ }
47
+
48
+ const NO_REFS: RefIndex = new Map();
49
+
50
+ /** A reference column's paths for a document: one (a reference), or each of a list's; empty for none. */
51
+ export function refPaths(doc: ListedDocument, column: TableColumn): string[] {
52
+ const v = doc.columns?.[column.key];
53
+ const paths = column.kind === 'reference' ? [v] : column.kind === 'references' && Array.isArray(v) ? v : [];
54
+ return paths.filter((p): p is string => typeof p === 'string' && p.trim() !== '').map((p) => p.trim());
55
+ }
56
+
57
+ /** One referenced document's title, or null when it's missing (gone from its collection). */
58
+ export function refTitle(refs: RefIndex, column: TableColumn, path: string): string | null {
59
+ const doc = column.to ? refs.get(refKey(column.to, path)) : undefined;
60
+ return doc ? doc.label || doc.title : null;
61
+ }
62
+
63
+ /** A reference cell's parts, in order: each referenced document's title, or `missing`. */
64
+ export function refParts(doc: ListedDocument, column: TableColumn, refs: RefIndex): { path: string; title: string | null }[] {
65
+ return refPaths(doc, column).map((path) => ({ path, title: refTitle(refs, column, path) }));
66
+ }
67
+
68
+ /** What a reference that's gone shows (in the warning colour). */
69
+ export const MISSING = 'Missing';
70
+
71
+ export type SortDir = 'asc' | 'desc';
72
+
73
+ /** A filter's value: a select's option value, or a toggle's on/off. */
74
+ export type FilterValue = string | boolean;
75
+
76
+ export interface TableView {
77
+ query: string;
78
+ sort: { key: string; dir: SortDir };
79
+ filters: Record<string, FilterValue>;
80
+ }
81
+
82
+ /** A view as it opens: the config's sort (Decision 13), else the title A to Z. */
83
+ export const defaultView = (sort?: SortConfig | null): TableView => ({ query: '', sort: sort ? { key: sort.column, dir: sort.order } : { key: 'title', dir: 'asc' }, filters: {} });
84
+
85
+ export function tableColumns(collection: Pick<EditorAnyCollection, 'fields' | 'columns'> & { urlBase?: string | null }): TableColumn[] {
86
+ const fields: Fields = collection.fields ?? {};
87
+ const named = collection.columns ?? DEFAULT_COLUMNS;
88
+ const columns: TableColumn[] = [{ key: 'title', label: 'Title', kind: 'title' }];
89
+ for (const key of named) {
90
+ // Shared blocks have no address (no page of their own): no column for it.
91
+ if (key === 'address') {
92
+ if (collection.urlBase !== null) columns.push({ key, label: 'Address', kind: 'address' });
93
+ }
94
+ else if (key === 'edited') columns.push({ key, label: 'Last edited', kind: 'edited' });
95
+ else {
96
+ const field = fields[key];
97
+ const kind = field ? columnKindOf(field) : null;
98
+ if (!field || !kind) continue;
99
+ const to = field.kind === 'reference' ? field.to : field.kind === 'list' && field.item?.kind === 'reference' ? field.item.to : undefined;
100
+ columns.push({ key, label: field.label ?? titleCase(key), kind, field, ...(to ? { to } : {}) });
101
+ }
102
+ }
103
+ return columns;
104
+ }
105
+
106
+ /** A field column's stored value for a document (undefined when it has none). */
107
+ const raw = (doc: ListedDocument, key: string): unknown => doc.columns?.[key];
108
+
109
+ /**
110
+ * The value a column sorts and filters by: a string, number or boolean, or null (none).
111
+ * A reference's is its document's title (a missing one has none, so it sorts last); a
112
+ * list of references' is its titles joined (it doesn't sort, and filters by refPaths).
113
+ */
114
+ export function cellValue(doc: ListedDocument, column: TableColumn, refs: RefIndex = NO_REFS): string | number | boolean | null {
115
+ switch (column.kind) {
116
+ case 'reference':
117
+ case 'references': {
118
+ const titles = refParts(doc, column, refs).flatMap((p) => (p.title === null ? [] : [p.title]));
119
+ return titles.length ? titles.join(', ') : null;
120
+ }
121
+ case 'title':
122
+ return doc.label;
123
+ case 'address':
124
+ return doc.url;
125
+ case 'edited':
126
+ return doc.mtimeMs;
127
+ case 'toggle': {
128
+ const v = raw(doc, column.key);
129
+ return typeof v === 'boolean' ? v : column.field?.kind === 'toggle' ? column.field.default : null;
130
+ }
131
+ case 'number': {
132
+ const v = raw(doc, column.key);
133
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
134
+ }
135
+ default: {
136
+ const v = raw(doc, column.key);
137
+ return typeof v === 'string' && v.trim() !== '' ? v : null;
138
+ }
139
+ }
140
+ }
141
+
142
+ /**
143
+ * What a cell shows, as text ('' for nothing). A toggle's is "Yes" or ''; the table
144
+ * draws it as a mark. `edited` writes a time (the editor's absolute times, live/format.ts → when).
145
+ */
146
+ export function cellText(doc: ListedDocument, column: TableColumn, edited: (ms: number) => string, refs: RefIndex = NO_REFS): string {
147
+ if (column.kind === 'reference' || column.kind === 'references') return refParts(doc, column, refs).map((p) => p.title ?? MISSING).join(', ');
148
+ const v = cellValue(doc, column);
149
+ if (v === null) return '';
150
+ switch (column.kind) {
151
+ case 'select': {
152
+ const field = column.field;
153
+ return field?.kind === 'select' ? (field.options.find((o) => o.value === v)?.label ?? String(v)) : String(v);
154
+ }
155
+ case 'date':
156
+ return formatDay(v);
157
+ case 'toggle':
158
+ return v ? 'Yes' : '';
159
+ case 'edited':
160
+ return edited(v as number);
161
+ default:
162
+ return String(v);
163
+ }
164
+ }
165
+
166
+ const collator = new Intl.Collator('en', { numeric: true, sensitivity: 'base' });
167
+
168
+ function compare(a: string | number | boolean, b: string | number | boolean): number {
169
+ if (typeof a === 'string' && typeof b === 'string') return collator.compare(a, b);
170
+ if (typeof a === 'boolean' && typeof b === 'boolean') return a === b ? 0 : a ? -1 : 1; // on first
171
+ return Number(a) - Number(b);
172
+ }
173
+
174
+ /** The documents in the view's order: by its column either way, the title breaking ties; no value last. */
175
+ export function sortRows(docs: readonly ListedDocument[], columns: readonly TableColumn[], sort: TableView['sort'], refs: RefIndex = NO_REFS): ListedDocument[] {
176
+ // A list of references doesn't order (Decision 13): the title does instead.
177
+ const column = columns.find((c) => c.key === sort.key && sortable(c)) ?? columns[0]!;
178
+ const title = columns[0]!;
179
+ const sign = sort.dir === 'asc' ? 1 : -1;
180
+ return [...docs].sort((x, y) => {
181
+ const a = cellValue(x, column, refs);
182
+ const b = cellValue(y, column, refs);
183
+ if (a === null || b === null) {
184
+ if (a !== b) return a === null ? 1 : -1;
185
+ } else {
186
+ const c = compare(a, b);
187
+ if (c !== 0) return sign * c;
188
+ }
189
+ return collator.compare(String(cellValue(x, title)), String(cellValue(y, title)));
190
+ });
191
+ }
192
+
193
+ /** Whether a column's header sorts: every column but a list of references. */
194
+ export const sortable = (column: TableColumn) => column.kind !== 'references';
195
+
196
+ const isFilterable = (c: TableColumn) => c.kind === 'select' || c.kind === 'toggle' || c.kind === 'reference' || c.kind === 'references';
197
+
198
+ /** The columns a filter can be set on: select, toggle, reference and list-of-references columns. */
199
+ export const filterable = (columns: readonly TableColumn[]) => columns.filter(isFilterable);
200
+
201
+ /** Whether a document passes a column's filter: its value, or for a reference column, one of its paths. */
202
+ function matches(doc: ListedDocument, column: TableColumn, want: FilterValue): boolean {
203
+ if (column.kind === 'reference' || column.kind === 'references') return refPaths(doc, column).includes(String(want));
204
+ return cellValue(doc, column) === want;
205
+ }
206
+
207
+ /** The view's documents: found (filterCollection), filtered, in order. */
208
+ export function viewRows(docs: readonly ListedDocument[], columns: readonly TableColumn[], view: TableView, refs: RefIndex = NO_REFS): ListedDocument[] {
209
+ const byFilter = Object.entries(view.filters).flatMap(([key, want]) => {
210
+ const column = columns.find((c) => c.key === key && isFilterable(c));
211
+ return column ? [[column, want] as const] : [];
212
+ });
213
+ const found = filterCollection(view.query, docs).filter((d) => byFilter.every(([column, want]) => matches(d, column, want)));
214
+ return sortRows(found, columns, view.sort, refs);
215
+ }
216
+
217
+ /** The next sort for a click on a column's header: that column ascending, or the other way when it's already the sort. */
218
+ export const nextSort = (sort: TableView['sort'], key: string): TableView['sort'] => (sort.key === key ? { key, dir: sort.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: 'asc' });
219
+
220
+ // ── M6c (G5): the grid view ───────────────────────────────────────────────────
221
+
222
+ export interface OrderOption {
223
+ value: string;
224
+ label: string;
225
+ sort: TableView['sort'];
226
+ }
227
+
228
+ /** A sort's `<option>` value: `<key>:<dir>`. */
229
+ export const orderValue = (sort: TableView['sort']) => `${sort.key}:${sort.dir}`;
230
+
231
+ /**
232
+ * Grid's order select (it has no headers to click, Decision 16): the title either way,
233
+ * then each column in the words its kind reads in (dates newest or oldest first,
234
+ * numbers lowest or highest, text and choices A to Z). A toggle doesn't order a grid.
235
+ * The view's sort is always one of them, set from List's headers or not, so the select
236
+ * never lies about the order.
237
+ */
238
+ export function orderOptions(columns: readonly TableColumn[], current: TableView['sort']): OrderOption[] {
239
+ const out: OrderOption[] = [];
240
+ const add = (column: TableColumn, dir: SortDir, words: string) => out.push({ value: orderValue({ key: column.key, dir }), label: `${column.label}, ${words}`, sort: { key: column.key, dir } });
241
+ for (const c of columns) {
242
+ if (c.kind === 'toggle' || c.kind === 'references') continue;
243
+ if (c.kind === 'title') {
244
+ add(c, 'asc', 'A to Z');
245
+ add(c, 'desc', 'Z to A');
246
+ } else if (c.kind === 'date' || c.kind === 'edited') {
247
+ add(c, 'desc', 'newest first');
248
+ add(c, 'asc', 'oldest first');
249
+ } else if (c.kind === 'number') {
250
+ add(c, 'asc', 'lowest first');
251
+ add(c, 'desc', 'highest first');
252
+ } else add(c, 'asc', 'A to Z');
253
+ }
254
+ if (!out.some((o) => o.value === orderValue(current))) {
255
+ const column = columns.find((c) => c.key === current.key);
256
+ if (column) out.push({ value: orderValue(current), label: `${column.label}, ${current.dir === 'asc' ? 'A to Z' : 'Z to A'}`, sort: current });
257
+ }
258
+ return out;
259
+ }
260
+
261
+ /**
262
+ * A card's captions (Decision 16): up to two of the configured columns after the
263
+ * title, as the table formats them; a toggle has no words, so it's skipped. A section
264
+ * builder whose columns are the built-ins: its card says when it was last edited. One
265
+ * whose columns name page fields (posts and tags, Decision 13) captions as a list of fields does.
266
+ */
267
+ export function captionColumns(collection: Pick<EditorAnyCollection, 'kind'>, columns: readonly TableColumn[]): TableColumn[] {
268
+ if (collection.kind === 'sections' && !columns.some((c) => c.field)) return [columns.find((c) => c.kind === 'edited') ?? { key: 'edited', label: 'Last edited', kind: 'edited' }];
269
+ return columns.filter((c) => c.kind !== 'title' && c.kind !== 'toggle').slice(0, 2);
270
+ }