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,85 @@
1
+ /**
2
+ * A list row's words (M5a Decision 5), pure for the unit tests: what one item is
3
+ * called ("Add credit"), and what a collapsed row shows: the field's `itemLabel`,
4
+ * else its first text field's value (and a second one, muted); for `list(text())`
5
+ * the string itself; for `list(reference())` the referenced document's title. An item
6
+ * with an inner list (M7) adds how many it holds.
7
+ */
8
+ import type { Field, Fields, LinkValue, ListField as ListDescriptor } from '../../../fields.ts';
9
+
10
+ /** "Credits" → "credit"; what one item is called ("Add credit", "Removed credit: …"). */
11
+ export function itemNoun(label: string): string {
12
+ const word = label.trim().toLowerCase();
13
+ if (/ies$/.test(word)) return word.replace(/ies$/, 'y');
14
+ if (/[^s]s$/.test(word)) return word.slice(0, -1);
15
+ return word || 'item';
16
+ }
17
+
18
+ const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
19
+
20
+ /** A value as one line of text for a summary: a string, a link's label, a referenced title. */
21
+ function lineOf(field: Field, value: unknown, titleOf: (to: string, path: string) => string | null): string {
22
+ switch (field.kind) {
23
+ case 'text':
24
+ case 'select':
25
+ return typeof value === 'string' ? value.replace(/\s*\n\s*/g, ' ').trim() : '';
26
+ case 'link':
27
+ // G9: an address-only link is named by where it goes.
28
+ if (field.addressOnly) return linkDestination(value);
29
+ return isObject(value) ? String((value as unknown as LinkValue).label ?? '').trim() : '';
30
+ case 'reference':
31
+ return typeof value === 'string' ? (titleOf(field.to, value) ?? value) : '';
32
+ case 'lockup':
33
+ return isObject(value) && Array.isArray(value.lines) ? value.lines.join(' ') : '';
34
+ case 'date':
35
+ return typeof value === 'string' ? value : '';
36
+ case 'number':
37
+ return typeof value === 'number' ? String(value) : '';
38
+ case 'media': {
39
+ // M5b: an image by its alt text, else its file name; a clip by its name.
40
+ if (!isObject(value)) return '';
41
+ if (typeof value.key === 'string') return (typeof value.alt === 'string' && value.alt.trim()) || (value.key.split('/').pop() ?? '');
42
+ return typeof value.name === 'string' ? value.name.trim() : '';
43
+ }
44
+ default:
45
+ return '';
46
+ }
47
+ }
48
+
49
+ /** Where a link goes, short (the mockup's linkText): an address without `https://` or `mailto:`. */
50
+ export function linkDestination(value: unknown): string {
51
+ if (!isObject(value)) return '';
52
+ return String((value as unknown as LinkValue).href ?? '')
53
+ .trim()
54
+ .replace(/^https:\/\//, '')
55
+ .replace(/^mailto:/, '');
56
+ }
57
+
58
+ /**
59
+ * A collapsed row's summary: the label field's value, and a second text field's, muted.
60
+ * A list of links shows each link's words, then where it goes.
61
+ */
62
+ export function itemSummary(list: ListDescriptor, item: unknown, titleOf: (to: string, path: string) => string | null): { main: string; more: string } {
63
+ if (list.item) return { main: lineOf(list.item, item, titleOf), more: list.item.kind === 'link' && !list.item.addressOnly ? linkDestination(item) : '' };
64
+ return fieldsSummary((list.fields ?? {}) as Fields, item, list.itemLabel, titleOf);
65
+ }
66
+
67
+ /** A row of fields' summary; a group in the label's place is looked inside (a row whose only field is a group). */
68
+ function fieldsSummary(fields: Fields, item: unknown, itemLabel: string | undefined, titleOf: (to: string, path: string) => string | null): { main: string; more: string } {
69
+ const entries = Object.entries(fields);
70
+ const data = isObject(item) ? item : {};
71
+ const labelKey = itemLabel && Object.hasOwn(fields, itemLabel) ? itemLabel : entries.find(([, f]) => f.kind === 'text')?.[0] ?? entries[0]?.[0];
72
+ const labelField = labelKey ? fields[labelKey]! : null;
73
+ if (labelField?.kind === 'group') return fieldsSummary(labelField.fields, data[labelKey!], undefined, titleOf);
74
+ const main = labelKey ? lineOf(labelField!, data[labelKey], titleOf) : '';
75
+ // M7 Decision 15: an item holding an inner list with items counts them ("Services · 2 dropdown links").
76
+ const inner = entries.find(([k, f]) => f.kind === 'list' && Array.isArray(data[k]) && (data[k] as unknown[]).length > 0);
77
+ if (inner) {
78
+ const n = (data[inner[0]] as unknown[]).length;
79
+ const noun = itemNoun(inner[1].label ?? inner[0]);
80
+ return { main, more: `${n} ${n === 1 ? noun : `${noun}s`}` };
81
+ }
82
+ const otherKey = entries.find(([k, f]) => k !== labelKey && (f.kind === 'text' || f.kind === 'link' || f.kind === 'reference'))?.[0];
83
+ const more = otherKey ? lineOf(fields[otherKey]!, data[otherKey], titleOf) : '';
84
+ return { main, more };
85
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * The lockup field's rules (M2 → Rules, "Keys" and "Phone lines"; Spec → Text),
3
+ * as pure functions so they can be tested without a browser (Phase 3 → Verify 5).
4
+ * LockupField.tsx calls these and nothing else decides what a key does.
5
+ *
6
+ * lineKey(set, key, at) what a key does in a line: split, join, move, refuse, or pass
7
+ * splitLine(lines, set, at) Enter: split the line at the caret (Phone lines: only between words)
8
+ * joinUp(lines, line) ⌫ at a line's start: join it to the line above
9
+ * splitEvenly(lines) "Give Phone its own lines": the longest line split at the most even space
10
+ * reflowPhone(main, phone) the main lines' words changed: keep the phone breaks if the word count holds
11
+ * withMainLines(value, lines) a lockup's value after its main lines changed (phone lines kept or dropped)
12
+ * withoutPhone(value) "Use main lines": no phone lines, so the file holds the plain list (Decision 10)
13
+ *
14
+ * Phone lines are the same words as the main lines, broken differently: in Phone
15
+ * lines only Enter (between words) and ⌫ (at a line's start) work, so the words
16
+ * can't change there. Words change in the main lines.
17
+ */
18
+ import type { LockupValue } from '../../../fields.ts';
19
+
20
+ export type LineSet = 'main' | 'phone';
21
+
22
+ /** Where the caret is: a line, and a character in it. */
23
+ export interface LineCaret {
24
+ line: number;
25
+ caret: number;
26
+ }
27
+
28
+ export interface LineEdit {
29
+ lines: string[];
30
+ /** Where the caret goes after the edit. */
31
+ focus: LineCaret;
32
+ }
33
+
34
+ /** The words of some lines, in order. */
35
+ export const wordsOf = (lines: readonly string[]): string[] => lines.join(' ').split(/\s+/).filter(Boolean);
36
+
37
+ export type LineKeyAction =
38
+ /** Enter with a caret: split the line (splitLine). */
39
+ | 'split'
40
+ /** ⌫ at the start of a line below the first: join it up (joinUp). */
41
+ | 'join'
42
+ /** ↑/↓: the line above or below. */
43
+ | 'up'
44
+ | 'down'
45
+ /** Swallowed with a nudge: a key that would change Phone lines' words, or Enter over a selection. */
46
+ | 'refuse'
47
+ /** Left to the input. */
48
+ | 'pass';
49
+
50
+ /**
51
+ * What a key does in a lockup line (M2's keys rule). `at.start`/`at.end` are the
52
+ * input's selectionStart/End; `at.line` the line's index.
53
+ */
54
+ export function lineKey(
55
+ set: LineSet,
56
+ key: string,
57
+ at: { line: number; start: number; end: number; length?: number },
58
+ modifiers: { meta?: boolean; ctrl?: boolean; alt?: boolean } = {},
59
+ ): LineKeyAction {
60
+ const collapsed = at.start === at.end;
61
+ if (key === 'Enter') return collapsed ? 'split' : 'refuse';
62
+ if (key === 'Backspace' && collapsed && at.start === 0 && at.line > 0) return 'join';
63
+ if (key === 'ArrowUp') return 'up';
64
+ if (key === 'ArrowDown') return 'down';
65
+ if (set === 'phone') {
66
+ // Shortcuts (copy, select all, undo…) and caret movement pass; anything that types or deletes doesn't.
67
+ if (modifiers.meta || modifiers.ctrl) return key.toLowerCase() === 'x' || key.toLowerCase() === 'v' || key.toLowerCase() === 'z' ? 'refuse' : 'pass';
68
+ if (key.length === 1 || key === 'Backspace' || key === 'Delete') return 'refuse';
69
+ }
70
+ return 'pass';
71
+ }
72
+
73
+ /**
74
+ * Enter in a line: the text before the caret stays, the text after it starts the
75
+ * next line (spaces at the break dropped). Main lines split anywhere, an empty line
76
+ * included. Phone lines split only between two words, never inside one, so their
77
+ * words stay the same: null means refused.
78
+ */
79
+ export function splitLine(lines: readonly string[], set: LineSet, at: LineCaret): LineEdit | null {
80
+ const line = lines[at.line];
81
+ if (line === undefined) return null;
82
+ const caret = Math.max(0, Math.min(line.length, at.caret));
83
+ const left = line.slice(0, caret).trimEnd();
84
+ const right = line.slice(caret).trimStart();
85
+ if (set === 'phone') {
86
+ if (!left || !right) return null;
87
+ if (line[caret - 1] !== ' ' && line[caret] !== ' ') return null;
88
+ }
89
+ const out = [...lines];
90
+ out.splice(at.line, 1, left, right);
91
+ return { lines: out, focus: { line: at.line + 1, caret: 0 } };
92
+ }
93
+
94
+ /** ⌫ at the start of a line: it joins the line above, with one space between. The caret lands at the join. */
95
+ export function joinUp(lines: readonly string[], line: number): LineEdit | null {
96
+ if (line <= 0 || line >= lines.length) return null;
97
+ const above = lines[line - 1]!.trimEnd();
98
+ const below = lines[line]!.trimStart();
99
+ const joined = above && below ? `${above} ${below}` : above || below;
100
+ const out = [...lines];
101
+ out.splice(line - 1, 2, joined);
102
+ return { lines: out, focus: { line: line - 1, caret: above.length + (above && below ? 1 : 0) } };
103
+ }
104
+
105
+ /**
106
+ * "Give Phone its own lines": a copy of the main lines with the longest one split
107
+ * in two at the space that makes the halves most even (the shorter of the two
108
+ * longer halves; the first such space on a tie). A longest line with no space is
109
+ * left as it is. Blank lines are dropped, as the lockup drops them.
110
+ *
111
+ * ["Building a better mousetrap", "For You"] → ["Building a", "better mousetrap", "For You"]
112
+ */
113
+ export function splitEvenly(lines: readonly string[]): string[] {
114
+ const clean = lines.map((l) => l.trim().replace(/\s+/g, ' ')).filter(Boolean);
115
+ if (clean.length === 0) return [];
116
+ let longest = 0;
117
+ clean.forEach((l, i) => {
118
+ if (l.length > clean[longest]!.length) longest = i;
119
+ });
120
+ const text = clean[longest]!;
121
+ let best: { a: string; b: string; worst: number } | null = null;
122
+ for (let i = 0; i < text.length; i++) {
123
+ if (text[i] !== ' ') continue;
124
+ const a = text.slice(0, i);
125
+ const b = text.slice(i + 1);
126
+ const worst = Math.max(a.length, b.length);
127
+ if (!best || worst < best.worst) best = { a, b, worst };
128
+ }
129
+ if (!best) return clean;
130
+ const out = [...clean];
131
+ out.splice(longest, 1, best.a, best.b);
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * The main lines' words changed while phone lines exist. If the number of words
137
+ * holds, the phone lines keep their breaks, filled with the new words in order;
138
+ * otherwise null: the phone lines drop (and the field says so).
139
+ */
140
+ export function reflowPhone(main: readonly string[], phone: readonly string[]): string[] | null {
141
+ const words = wordsOf(main);
142
+ const counts = phone.map((l) => wordsOf([l]).length);
143
+ if (counts.reduce((a, b) => a + b, 0) !== words.length) return null;
144
+ let k = 0;
145
+ return counts
146
+ .map((count) => {
147
+ const line = words.slice(k, k + count).join(' ');
148
+ k += count;
149
+ return line;
150
+ })
151
+ .filter(Boolean);
152
+ }
153
+
154
+ /**
155
+ * A lockup's value once its main lines are `lines`. Phone lines are re-filled with
156
+ * the new words if the word count holds (reflowPhone), else dropped: `dropped`.
157
+ */
158
+ export function withMainLines(value: LockupValue, lines: string[]): { value: LockupValue; dropped: boolean } {
159
+ if (!value.phone) return { value: { lines, phone: null }, dropped: false };
160
+ const phone = reflowPhone(lines, value.phone);
161
+ return phone ? { value: { lines, phone }, dropped: false } : { value: { lines, phone: null }, dropped: true };
162
+ }
163
+
164
+ /** A lockup with its own phone lines, split from the main lines (splitEvenly). */
165
+ export function withPhone(value: LockupValue): LockupValue {
166
+ return { lines: value.lines, phone: splitEvenly(value.lines) };
167
+ }
168
+
169
+ /** "Use main lines": the phone lines go, and the file holds the plain list again (Decision 10). */
170
+ export function withoutPhone(value: LockupValue): LockupValue {
171
+ return { lines: value.lines, phone: null };
172
+ }
173
+
174
+ /** The value as the store holds it, from whatever the block's data has. */
175
+ export function lockupValue(raw: unknown): LockupValue {
176
+ if (raw && typeof raw === 'object' && Array.isArray((raw as LockupValue).lines)) {
177
+ const v = raw as LockupValue;
178
+ return { lines: v.lines.map(String), phone: Array.isArray(v.phone) && v.phone.length > 0 ? v.phone.map(String) : null };
179
+ }
180
+ return { lines: [], phone: null };
181
+ }