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,63 @@
1
+ /**
2
+ * Which preview iframe has a page editor mounted, and on which element (on-page rich
3
+ * text, Decisions 5–7). At most one per iframe. The driver and selection.ts read it
4
+ * so that **every writer skips the mounted element in that iframe** (ProseMirror
5
+ * reads any outside change to its DOM as typing): the morph pairs it only with its
6
+ * own field (Decision 6), and the mirror and re-apply leave it alone (Decision 7).
7
+ *
8
+ * Light on purpose (no Tiptap here): page-edit.ts mounts and registers; the driver
9
+ * only asks.
10
+ */
11
+ import { parseRichText, richHeadingHtml, richTextHtml, type RichTextOptions } from '../../../rich-text.ts';
12
+
13
+ export interface MountedField {
14
+ /** The field's element: the editor's root while mounted. */
15
+ readonly el: HTMLElement;
16
+ /** The block's key in the document store, and the field's key (always top level). */
17
+ readonly key: string;
18
+ readonly field: string;
19
+ /**
20
+ * The morph paired the element with its field in the render and left it alone
21
+ * (Decision 6): the render's own attributes are what teardown must put back, so a
22
+ * render that changed them (a class that depends on another field) isn't undone.
23
+ */
24
+ syncAttributes(from: Element): void;
25
+ /**
26
+ * Tear it down now, synchronously (Decision 6: inside idiomorph's callback, before
27
+ * the morph writes). page-edit remounts it after the morph if the session or the
28
+ * pointer is still there.
29
+ */
30
+ teardownForMorph(): void;
31
+ }
32
+
33
+ const byDoc = new WeakMap<Document, MountedField>();
34
+
35
+ /** The page editor mounted in a frame's document, or null. */
36
+ export function mountedIn(doc: Document | null | undefined): MountedField | null {
37
+ return (doc && byDoc.get(doc)) ?? null;
38
+ }
39
+
40
+ export function setMounted(doc: Document, mounted: MountedField | null): void {
41
+ if (mounted) byDoc.set(doc, mounted);
42
+ else byDoc.delete(doc);
43
+ }
44
+
45
+ /** A field's value as the render gives it: the same HTML RichText.astro writes (a heading's: its lines, M9 Decision 11). */
46
+ export function renderedHtml(raw: unknown, options: RichTextOptions): string {
47
+ const value = parseRichText(raw, options);
48
+ return options.heading ? richHeadingHtml(value) : richTextHtml(value);
49
+ }
50
+
51
+ /**
52
+ * Put a rich-text field's value into an element that isn't mounted (another width,
53
+ * or after a render that predates it), only when it differs, so an unchanged field
54
+ * keeps its nodes. The comparison goes through the element's own document's
55
+ * serialiser (a `'` in the value is `&#39;` in richTextHtml, `'` in innerHTML).
56
+ */
57
+ export function writeRichText(el: HTMLElement, raw: unknown, options: RichTextOptions): void {
58
+ if (mountedIn(el.ownerDocument)?.el === el) return;
59
+ const html = renderedHtml(raw, options);
60
+ const probe = el.ownerDocument.createElement('template');
61
+ probe.innerHTML = html;
62
+ if (probe.innerHTML !== el.innerHTML) el.innerHTML = html;
63
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * The page editor (on-page rich text, Decisions 2–5): /admin's own Tiptap, with the
3
+ * sidebar's schema (`richTextExtensions`), conversion (`toDoc`/`fromDoc`) and paste
4
+ * (`clipboardTextParser`), mounted on a rich-text field's own element in a preview
5
+ * iframe. Candidate (A) of Decision 4: one realm, /admin's; the preview page still
6
+ * carries no editor script.
7
+ *
8
+ * - **The editor's root is the field's element itself** (Decision 3):
9
+ * `element: { mount: fieldEl }`, which Tiptap passes straight to ProseMirror (a
10
+ * cast: it works at run time but isn't in Tiptap's types). No `div.ProseMirror`
11
+ * inside: the field's first child is the value's first block, as the render's is.
12
+ * - **Mounting swaps the field's children in one task** (ProseMirror's constructor
13
+ * renders the document synchronously), so nothing paints in between.
14
+ * - **Teardown leaves the element exactly as a render would** (Decision 5): the
15
+ * attributes captured at mount are put back exactly (ProseMirror strips the ones
16
+ * it set, Tiptap's `tiptap` class would stay), and the children are
17
+ * `richTextHtml(value)`, as RichText.astro renders them.
18
+ * - **Outside changes** (Decision 8) replace the content without an update, compared
19
+ * with the value this editor last stored and with `fromDoc` of its own content, so
20
+ * its own echo never replaces it (an empty paragraph from Enter survives), as
21
+ * RichTextField.tsx does; only the range that differs is replaced, so the page's
22
+ * selection maps through it.
23
+ * - **Blur with `view.dom.blur()`**, never Tiptap's `commands.blur()` (that clears
24
+ * /admin's selection, not the iframe's).
25
+ */
26
+ import { Editor } from '@tiptap/core';
27
+ import { TextSelection } from '@tiptap/pm/state';
28
+ import type { EditorView } from '@tiptap/pm/view';
29
+ import { parseRichText, richTextHtml, type RichTextOptions, type RichTextValue } from '../../../rich-text.ts';
30
+ import { fromDoc, toDoc, type PMNode } from '../fields/rich-text-doc.ts';
31
+ import { clipboardTextParser, richTextExtensions } from '../fields/rich-text-schema.ts';
32
+
33
+ export interface MountedPageEditor {
34
+ /** The field's element (the editor's root while mounted). */
35
+ readonly el: HTMLElement;
36
+ readonly editor: Editor;
37
+ readonly view: EditorView;
38
+ readonly options: RichTextOptions;
39
+ /** The value it was given or last stored. */
40
+ value(): RichTextValue;
41
+ /** A change from elsewhere (Decision 8): shown, without an update. */
42
+ setValue(raw: unknown): void;
43
+ /** The editor's selection (ProseMirror positions). */
44
+ selection(): { anchor: number; head: number };
45
+ /** Put the selection at these positions (clamped to the document), and focus the editor if asked. */
46
+ select(anchor: number, head?: number, focus?: boolean): void;
47
+ /**
48
+ * A render changed the element's own attributes while it was mounted: teardown puts
49
+ * back these (the render's) instead of the mount-time ones. The live element is
50
+ * left alone (ProseMirror owns its `class`, `contenteditable` and `translate`).
51
+ */
52
+ syncAttributes(from: Element): void;
53
+ /** Tear down (Decision 5's recipe). The element is left as a render of the current value. */
54
+ destroy(): void;
55
+ readonly destroyed: boolean;
56
+ }
57
+
58
+ export { renderedHtml } from './page-editor-registry.ts';
59
+
60
+ /**
61
+ * A point in a rendered rich-text field (before mount), as a path the editor's DOM
62
+ * has too: the top-level block's index, a list item's index inside a list, and the
63
+ * characters before the point in that textblock (a `<br>` counts as one, as a
64
+ * hardBreak does). Block boundaries count (Decision 5): that's what `selection.ts`
65
+ * doesn't know.
66
+ */
67
+ export interface FieldPoint {
68
+ path: number[];
69
+ offset: number;
70
+ }
71
+
72
+ const LISTS = new Set(['UL', 'OL']);
73
+
74
+ function charsBefore(root: Node, node: Node, offset: number): number {
75
+ const doc = root.ownerDocument!;
76
+ const range = doc.createRange();
77
+ range.setStart(root, 0);
78
+ range.setEnd(node, offset);
79
+ const frag = range.cloneContents();
80
+ let n = 0;
81
+ const walk = (x: Node) => {
82
+ for (const c of x.childNodes) {
83
+ if (c.nodeType === 3) n += (c as Text).data.length;
84
+ else if ((c as Element).tagName === 'BR') n += 1;
85
+ else walk(c);
86
+ }
87
+ };
88
+ walk(frag);
89
+ return n;
90
+ }
91
+
92
+ /** Where the iframe's (collapsed or not) selection's focus is inside the field, or null. */
93
+ export function pointInField(el: HTMLElement): FieldPoint | null {
94
+ const sel = el.ownerDocument.getSelection();
95
+ const node = sel?.focusNode;
96
+ if (!sel || !node || !el.contains(node) || node === el) return null;
97
+ let block: Node = node;
98
+ while (block.parentNode && block.parentNode !== el) block = block.parentNode;
99
+ const path = [Array.prototype.indexOf.call(el.childNodes, block) as number];
100
+ let textblock: Node = block;
101
+ if (block.nodeType === 1 && LISTS.has((block as Element).tagName)) {
102
+ let item: Node = node;
103
+ while (item.parentNode && item.parentNode !== block) item = item.parentNode;
104
+ if (item === block) return { path, offset: 0 };
105
+ path.push(Array.prototype.indexOf.call(block.childNodes, item) as number);
106
+ textblock = item;
107
+ }
108
+ return { path, offset: charsBefore(textblock, sel.focusNode!, sel.focusOffset) };
109
+ }
110
+
111
+ /** The editor's position for a FieldPoint (its DOM has the same blocks and items), or null. */
112
+ export function positionOf(view: EditorView, point: FieldPoint): number | null {
113
+ let at: Node | undefined = view.dom.childNodes[point.path[0]!];
114
+ if (point.path.length > 1) at = at?.childNodes[point.path[1]!];
115
+ if (!at) return null;
116
+ // A list item that still holds a paragraph (the older schema): its text is in there.
117
+ if (at.nodeType === 1 && (at as Element).tagName === 'LI' && (at as Element).firstElementChild?.tagName === 'P') at = (at as Element).firstElementChild!;
118
+ let left = point.offset;
119
+ const walker = at.ownerDocument!.createTreeWalker(at, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
120
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
121
+ if (n.nodeType === 3) {
122
+ const len = (n as Text).data.length;
123
+ if (left <= len) return view.posAtDOM(n, left);
124
+ left -= len;
125
+ } else if ((n as Element).tagName === 'BR' && !(n as Element).classList.contains('ProseMirror-trailingBreak')) {
126
+ if (left === 0) return view.posAtDOM(n.parentNode!, Array.prototype.indexOf.call(n.parentNode!.childNodes, n) as number);
127
+ left -= 1;
128
+ }
129
+ }
130
+ return view.posAtDOM(at, at.childNodes.length);
131
+ }
132
+
133
+ export function mountPageEditor(
134
+ el: HTMLElement,
135
+ init: {
136
+ value: unknown;
137
+ options: RichTextOptions;
138
+ /** Each edit's value (fromDoc through the field's rules), when it differs from the last. */
139
+ onChange?: (value: RichTextValue) => void;
140
+ /** Keys first (⌘K and Esc are the page's). Return true when handled. */
141
+ onKeyDown?: (event: KeyboardEvent) => boolean;
142
+ },
143
+ ): MountedPageEditor {
144
+ const options: RichTextOptions = { headings: [...init.options.headings], links: init.options.links, ...(init.options.heading ? { heading: true } : {}) };
145
+ let value = parseRichText(init.value, options);
146
+ let last = JSON.stringify(value);
147
+ // Decision 5's recipe: the attributes exactly as the render left them.
148
+ let attributes = [...el.attributes].map((a) => [a.name, a.value] as const);
149
+ let destroyed = false;
150
+
151
+ const editor = new Editor({
152
+ // Decision 3: ProseMirror uses the element itself only when given `{ mount }`.
153
+ element: { mount: el } as unknown as HTMLElement,
154
+ extensions: richTextExtensions(options),
155
+ content: toDoc(value, options),
156
+ injectCSS: false,
157
+ enableInputRules: false,
158
+ enablePasteRules: false,
159
+ editorProps: {
160
+ clipboardTextParser,
161
+ handleKeyDown: (_view, event) => init.onKeyDown?.(event) ?? false,
162
+ },
163
+ onUpdate: ({ editor: ed }) => {
164
+ const next = fromDoc(ed.getJSON() as PMNode, options);
165
+ const json = JSON.stringify(next);
166
+ if (json === last) return;
167
+ last = json;
168
+ value = next;
169
+ init.onChange?.(next);
170
+ },
171
+ });
172
+
173
+ return {
174
+ el,
175
+ editor,
176
+ get view() {
177
+ return editor.view;
178
+ },
179
+ options,
180
+ value: () => value,
181
+ setValue(raw) {
182
+ if (destroyed) return;
183
+ const next = parseRichText(raw, options);
184
+ const json = JSON.stringify(next);
185
+ if (json === last) return;
186
+ last = json;
187
+ value = next;
188
+ if (JSON.stringify(fromDoc(editor.getJSON() as PMNode, options)) === json) return;
189
+ // Only the part that differs is replaced, so the selection maps through it (a
190
+ // whole-document setContent would put it at the end), without an update and
191
+ // outside the page's own undo (Decision 11).
192
+ const target = editor.schema.nodeFromJSON(toDoc(next, options));
193
+ const current = editor.state.doc;
194
+ const start = current.content.findDiffStart(target.content);
195
+ if (start === null) return;
196
+ let { a, b } = current.content.findDiffEnd(target.content)!;
197
+ const overlap = start - Math.min(a, b);
198
+ if (overlap > 0) {
199
+ a += overlap;
200
+ b += overlap;
201
+ }
202
+ const tr = editor.state.tr.replace(start, a, target.slice(start, b));
203
+ tr.setMeta('preventUpdate', true).setMeta('addToHistory', false);
204
+ editor.view.dispatch(tr);
205
+ },
206
+ selection() {
207
+ const s = editor.state.selection;
208
+ return { anchor: s.anchor, head: s.head };
209
+ },
210
+ select(anchor, head = anchor, focus = false) {
211
+ if (destroyed) return;
212
+ const max = editor.state.doc.content.size;
213
+ const clamp = (n: number) => Math.max(0, Math.min(n, max));
214
+ const doc = editor.state.doc;
215
+ const sel = TextSelection.between(doc.resolve(clamp(anchor)), doc.resolve(clamp(head)));
216
+ editor.view.dispatch(editor.state.tr.setSelection(sel));
217
+ if (focus) editor.view.focus();
218
+ },
219
+ syncAttributes(from) {
220
+ if (destroyed) return;
221
+ attributes = [...from.attributes].map((a) => [a.name, a.value] as const);
222
+ },
223
+ destroy() {
224
+ if (destroyed) return;
225
+ destroyed = true;
226
+ editor.destroy();
227
+ for (const name of el.getAttributeNames()) el.removeAttribute(name);
228
+ for (const [name, v] of attributes) el.setAttribute(name, v);
229
+ el.innerHTML = richTextHtml(value);
230
+ },
231
+ get destroyed() {
232
+ return destroyed;
233
+ },
234
+ };
235
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Lifted as is from M1's spike (spikes/editor/src/lib/render-sequencer.ts, with its
3
+ * tests; M3 Decision 13). "Intro" below means any plain text typed on the page.
4
+ *
5
+ * M1 Decision 9, "the latest edit wins", as a small pure state machine (no DOM, no
6
+ * fetch) so it can be unit-tested. The editor (/admin) does the I/O and asks this
7
+ * what to do.
8
+ *
9
+ * - The document carries a version; every change bumps it, on-page intro edits
10
+ * included.
11
+ * - At most one render is in flight, with no debounce: a change that needs a
12
+ * render is sent at once if nothing is in flight; otherwise, when the current
13
+ * request returns, the latest version is sent.
14
+ * - A response carries the version it rendered. A response older than one already
15
+ * morphed in is dropped. The request finishing, not the echoed version, frees the
16
+ * in-flight slot; a response whose echoed version is missing or isn't the version
17
+ * sent is a failure, so a bad response can't wedge the editor.
18
+ * - When a response older than the current version is morphed in, the editor
19
+ * re-applies the current on-page text afterwards, so an intro typed meanwhile
20
+ * never flashes back.
21
+ *
22
+ * Intro edits (`'local'`) bump the version but don't ask for a render: plain text
23
+ * skips the round trip (Spec → Visual editing). The next render that does go out
24
+ * carries the latest version, intro included. Version 0 is the file, as the
25
+ * iframes' first GET rendered it.
26
+ */
27
+
28
+ export type ChangeKind = 'render' | 'local';
29
+
30
+ export interface ResponseAction {
31
+ /** Morph this response in. False: it's older than one already morphed, drop it. */
32
+ apply: boolean;
33
+ /** After the morph, put the current on-page text back (the response predates it). */
34
+ reapplyLocal: boolean;
35
+ /** Send a render of this version now (a change waited while this one was in flight). */
36
+ send: number | null;
37
+ }
38
+
39
+ export interface SequencerState {
40
+ /** The document's current version. */
41
+ version: number;
42
+ /** The version in flight, or null. */
43
+ inFlight: number | null;
44
+ /** The newest version morphed in. */
45
+ applied: number;
46
+ /** A change that needs a render has happened since the last send. */
47
+ pending: boolean;
48
+ }
49
+
50
+ export function createSequencer(initialVersion = 0) {
51
+ const state: SequencerState = {
52
+ version: initialVersion,
53
+ inFlight: null,
54
+ applied: initialVersion,
55
+ pending: false,
56
+ };
57
+
58
+ /** Send the latest version if one is waiting and nothing is in flight. */
59
+ function maybeSend(): number | null {
60
+ if (state.inFlight !== null || !state.pending) return null;
61
+ state.pending = false;
62
+ state.inFlight = state.version;
63
+ return state.version;
64
+ }
65
+
66
+ /** The request finished without a usable render; keep the change waiting. */
67
+ function failure(sent: number): void {
68
+ if (state.inFlight === sent) state.inFlight = null;
69
+ if (sent > state.applied) state.pending = true;
70
+ }
71
+
72
+ return {
73
+ state: state as Readonly<SequencerState>,
74
+
75
+ /** A change to the document. Returns the version to send now, or null. */
76
+ change(kind: ChangeKind): { version: number; send: number | null } {
77
+ state.version += 1;
78
+ if (kind === 'render') state.pending = true;
79
+ return { version: state.version, send: maybeSend() };
80
+ },
81
+
82
+ /**
83
+ * A render request finished. `sent` is the version the request carried: it frees
84
+ * the in-flight slot, whatever the response said. `echoed` is the version the
85
+ * server says it rendered (`X-Preview-Version`), or null if the header was
86
+ * missing; it only decides apply or drop. An echo that is missing or isn't the
87
+ * version sent is a failure: nothing is applied, the slot is freed, and the
88
+ * change stays pending exactly as `failure()` leaves it (nothing is sent now).
89
+ */
90
+ response(sent: number, echoed: number | null): ResponseAction {
91
+ if (echoed === null || echoed !== sent) {
92
+ failure(sent);
93
+ return { apply: false, reapplyLocal: false, send: null };
94
+ }
95
+ if (state.inFlight === sent) state.inFlight = null;
96
+ const apply = echoed > state.applied;
97
+ if (apply) state.applied = echoed;
98
+ return {
99
+ apply,
100
+ reapplyLocal: apply && echoed < state.version,
101
+ send: maybeSend(),
102
+ };
103
+ },
104
+
105
+ /**
106
+ * A render failed. The change stays waiting and goes out with the next change
107
+ * (no automatic retry, so a dead server can't loop).
108
+ */
109
+ failure,
110
+ };
111
+ }
112
+
113
+ export type Sequencer = ReturnType<typeof createSequencer>;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Each iframe's selection, saved and restored around every morph (Decision 13;
3
+ * M1 → E2). Chrome moves an iframe's activeElement to its <body> when focus leaves
4
+ * for the parent, so idiomorph's `ignoreActive` only protects a caret while focus is
5
+ * inside that iframe; restoring the selection by position covers the rest, so a caret
6
+ * on the page survives a re-render whether focus is in the iframe or the sidebar.
7
+ *
8
+ * A selection is remembered by where it is in the page's terms: the block
9
+ * (`data-cms-block`), the field (`data-cms-field`) and character offsets in the
10
+ * field's text, a `<br>` counting as one character (the line-break rule). Only
11
+ * selections inside a field are kept; anything else isn't worth restoring.
12
+ *
13
+ * **A field with a page editor mounted is left to the editor** (on-page rich text,
14
+ * Decision 6): the morph never touches it, and ProseMirror owns its selection.
15
+ */
16
+ import { blockIndexOf, blockRootOf, fieldElement } from './markers.ts';
17
+ import { mountedIn } from './page-editor-registry.ts';
18
+
19
+ export interface SavedSelection {
20
+ block: string;
21
+ field: string;
22
+ anchor: number;
23
+ focus: number;
24
+ /** The field had focus inside the iframe (focus is put back too). */
25
+ focused: boolean;
26
+ }
27
+
28
+ const fieldOf = (node: Node | null): HTMLElement | null => {
29
+ const element = node && (node.nodeType === 1 ? (node as Element) : node.parentElement);
30
+ return (element?.closest('[data-cms-field]') as HTMLElement | null) ?? null;
31
+ };
32
+
33
+ /** Characters in a node: its text, a `<br>` as one. */
34
+ function lengthOf(node: Node): number {
35
+ if (node.nodeType === 3) return (node as Text).data.length;
36
+ if ((node as Element).tagName === 'BR') return 1;
37
+ let n = 0;
38
+ for (const child of node.childNodes) n += lengthOf(child);
39
+ return n;
40
+ }
41
+
42
+ /** Characters before (node, offset) inside root. */
43
+ function offsetIn(root: Node, node: Node, offset: number): number {
44
+ // An element position is "before child N", or the element's end.
45
+ const before = node.nodeType === 1 && offset < node.childNodes.length ? node.childNodes[offset]! : null;
46
+ const endOf = node.nodeType === 1 && !before ? node : null;
47
+ const walker = root.ownerDocument!.createTreeWalker(root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
48
+ let count = 0;
49
+ for (let current: Node | null = walker.nextNode(); current; current = walker.nextNode()) {
50
+ if (current === before) return count;
51
+ if (current === endOf) return count + lengthOf(current);
52
+ if (current === node && node.nodeType === 3) return count + offset;
53
+ if (current.nodeType === 3) count += (current as Text).data.length;
54
+ else if ((current as Element).tagName === 'BR') count += 1;
55
+ }
56
+ return count;
57
+ }
58
+
59
+ /** The (node, offset) for a character offset inside root. */
60
+ function pointAt(root: Node, target: number): { node: Node; offset: number } {
61
+ const doc = root.ownerDocument!;
62
+ let count = 0;
63
+ const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
64
+ for (let current: Node | null = walker.nextNode(); current; current = walker.nextNode()) {
65
+ if (current.nodeType === 3) {
66
+ const length = (current as Text).data.length;
67
+ if (count + length >= target) return { node: current, offset: target - count };
68
+ count += length;
69
+ } else if ((current as Element).tagName === 'BR') {
70
+ if (count + 1 > target) {
71
+ const parent = current.parentNode!;
72
+ return { node: parent, offset: Array.prototype.indexOf.call(parent.childNodes, current) };
73
+ }
74
+ count += 1;
75
+ }
76
+ }
77
+ return { node: root, offset: root.childNodes.length };
78
+ }
79
+
80
+ export function saveSelection(doc: Document): SavedSelection | null {
81
+ const selection = doc.getSelection();
82
+ if (!selection || selection.rangeCount === 0 || !selection.anchorNode || !selection.focusNode) return null;
83
+ const field = fieldOf(selection.anchorNode);
84
+ if (!field || field !== fieldOf(selection.focusNode)) return null;
85
+ if (mountedIn(doc)?.el === field) return null;
86
+ const block = blockRootOf(field);
87
+ if (!block) return null;
88
+ return {
89
+ block: String(blockIndexOf(block)),
90
+ field: field.dataset.cmsField!,
91
+ anchor: offsetIn(field, selection.anchorNode, selection.anchorOffset),
92
+ focus: offsetIn(field, selection.focusNode, selection.focusOffset),
93
+ focused: doc.hasFocus() && !!doc.activeElement && (doc.activeElement === field || field.contains(doc.activeElement)),
94
+ };
95
+ }
96
+
97
+ export function restoreSelection(doc: Document, saved: SavedSelection | null): void {
98
+ if (!saved) return;
99
+ const field = fieldElement(doc, saved.block, saved.field);
100
+ const selection = doc.getSelection();
101
+ if (!field || !selection || mountedIn(doc)?.el === field) return;
102
+ const anchor = pointAt(field, saved.anchor);
103
+ const focus = pointAt(field, saved.focus);
104
+ if (saved.focused && doc.activeElement !== field) field.focus({ preventScroll: true });
105
+ const current = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
106
+ if (
107
+ current &&
108
+ selection.anchorNode === anchor.node &&
109
+ selection.anchorOffset === anchor.offset &&
110
+ selection.focusNode === focus.node &&
111
+ selection.focusOffset === focus.offset
112
+ ) {
113
+ return;
114
+ }
115
+ selection.setBaseAndExtent(anchor.node, anchor.offset, focus.node, focus.offset);
116
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The lockups' sizes as the phone iframe computed them (M1 → E7; Decision 13): read
3
+ * after every morph and when the phone iframe loads. The Phone toggle's warning dot
4
+ * comes from these; Phase 3's field warning and "Give Phone its own lines" read them
5
+ * too (driver → status → warnings).
6
+ *
7
+ * Markers come from the package's Lockup in preview renders (Lockup.astro): on the
8
+ * heading `data-cms-field`, `data-cms-min-size`; on each line set `data-cms-lines`
9
+ * and, when some characters aren't in the font, `data-cms-uncovered`. The visible
10
+ * set is the one whose computed display isn't none (phone lines below the site's
11
+ * phone-lines width, the main lines otherwise); its size is its `.cms-lockup-fit`'s
12
+ * computed font size, in px.
13
+ */
14
+ import { blockIndexOf, blockRootOf } from './markers.ts';
15
+
16
+ export interface LockupWarning {
17
+ /** The block's index in the rendered document (`data-cms-block`). */
18
+ block: number;
19
+ field: string;
20
+ /** Which set the phone shows. */
21
+ set: 'main' | 'phone';
22
+ /** The size it computed at 375, px. */
23
+ px: number;
24
+ /** The style's minimum, px. */
25
+ minPx: number;
26
+ /** px below the minimum: "Too long for mobile". */
27
+ tooLong: boolean;
28
+ /** Characters the font doesn't cover (Decision 15), in the visible set. */
29
+ uncovered: string[];
30
+ }
31
+
32
+ export function readLockups(doc: Document, win: Window): LockupWarning[] {
33
+ const out: LockupWarning[] = [];
34
+ for (const heading of doc.querySelectorAll<HTMLElement>('[data-cms-kind="lockup"][data-cms-field]')) {
35
+ const blockEl = blockRootOf(heading);
36
+ if (!blockEl) continue;
37
+ const set = [...heading.querySelectorAll<HTMLElement>('[data-cms-lines]')].find((s) => win.getComputedStyle(s).display !== 'none');
38
+ const fit = set?.querySelector<HTMLElement>('.cms-lockup-fit');
39
+ if (!set || !fit) continue;
40
+ const px = parseFloat(win.getComputedStyle(fit).fontSize);
41
+ const minPx = Number(heading.dataset.cmsMinSize);
42
+ if (!Number.isFinite(px) || !Number.isFinite(minPx)) continue;
43
+ let uncovered: string[] = [];
44
+ try {
45
+ uncovered = set.dataset.cmsUncovered ? (JSON.parse(set.dataset.cmsUncovered) as string[]) : [];
46
+ } catch {
47
+ /* none */
48
+ }
49
+ out.push({
50
+ block: blockIndexOf(blockEl),
51
+ field: heading.dataset.cmsField!,
52
+ set: set.dataset.cmsLines === 'phone' ? 'phone' : 'main',
53
+ px,
54
+ minPx,
55
+ // A hair of tolerance for sub-pixel layout (the mockup's 0.05).
56
+ tooLong: px < minPx - 0.05,
57
+ uncovered,
58
+ });
59
+ }
60
+ return out;
61
+ }