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,156 @@
1
+ /**
2
+ * Rich text's value (rich-text.ts: blocks of runs) and the editor's ProseMirror
3
+ * document (JSON), both ways. Pure, so it's tested in node.
4
+ *
5
+ * p ↔ paragraph
6
+ * h2, h3 ↔ heading { level }
7
+ * ul, ol ↔ bulletList / orderedList of listItem, each a textblock (its text
8
+ * directly, as `<li>` holds it); `fromDoc` also reads an item holding
9
+ * paragraphs, the shape before on-page rich text
10
+ * a run ↔ text with marks bold, italic, link { href }
11
+ * "\n" ↔ hardBreak (Shift+Enter: a break inside the paragraph, heading or item)
12
+ *
13
+ * A heading field (M9 Decision 11, `options.heading`) has no blocks: its document's
14
+ * content is inline, as its heading tag holds it, each paragraph a line, the lines
15
+ * joined by hardBreaks. Read back, every break starts a paragraph.
16
+ *
17
+ * `fromDoc` reads back through Phase 1's `parseRichText`, so what the store holds
18
+ * is always the field's own value: empty paragraphs, items and lists dropped,
19
+ * breaks at a unit's start or end trimmed, kinds the field doesn't allow as `p`,
20
+ * links dropped where the field has them off (Decision 3).
21
+ */
22
+ import { parseRichText, type RichTextOptions, type RichTextValue, type TextRun } from '../../../rich-text.ts';
23
+
24
+ export interface PMMark {
25
+ type: 'bold' | 'italic' | 'link';
26
+ attrs?: { href: string };
27
+ }
28
+
29
+ export interface PMNode {
30
+ type: string;
31
+ attrs?: Record<string, unknown>;
32
+ content?: PMNode[];
33
+ text?: string;
34
+ marks?: PMMark[];
35
+ }
36
+
37
+ function marksOf(run: TextRun): PMMark[] | undefined {
38
+ const marks: PMMark[] = [];
39
+ if (run.bold) marks.push({ type: 'bold' });
40
+ if (run.italic) marks.push({ type: 'italic' });
41
+ if (run.href !== undefined) marks.push({ type: 'link', attrs: { href: run.href } });
42
+ return marks.length ? marks : undefined;
43
+ }
44
+
45
+ /** A unit's runs as inline nodes: text with marks, and a hardBreak for each `\n`. */
46
+ export function inlineNodes(runs: readonly TextRun[]): PMNode[] {
47
+ const out: PMNode[] = [];
48
+ for (const run of runs) {
49
+ const marks = marksOf(run);
50
+ run.text.split('\n').forEach((part, i) => {
51
+ if (i > 0) out.push({ type: 'hardBreak' });
52
+ if (part !== '') out.push(marks ? { type: 'text', text: part, marks } : { type: 'text', text: part });
53
+ });
54
+ }
55
+ return out;
56
+ }
57
+
58
+ const textblock = (type: string, runs: readonly TextRun[], attrs?: Record<string, unknown>): PMNode => {
59
+ const content = inlineNodes(runs);
60
+ return { type, ...(attrs ? { attrs } : {}), ...(content.length ? { content } : {}) };
61
+ };
62
+
63
+ /** A value as the editor's document. Never empty: an empty value is one empty paragraph (a heading's: no content). */
64
+ export function toDoc(value: RichTextValue, options?: Pick<RichTextOptions, 'heading'>): PMNode {
65
+ if (options?.heading) {
66
+ const lines = value.flatMap((block) => ('items' in block ? block.items : [block.runs]));
67
+ const content = lines.flatMap((runs, i) => [...(i > 0 ? [{ type: 'hardBreak' }] : []), ...inlineNodes(runs)]);
68
+ return { type: 'doc', ...(content.length ? { content } : {}) };
69
+ }
70
+ const content: PMNode[] = value.map((block) => {
71
+ if ('items' in block) {
72
+ return {
73
+ type: block.kind === 'ol' ? 'orderedList' : 'bulletList',
74
+ content: block.items.map((runs) => textblock('listItem', runs)),
75
+ };
76
+ }
77
+ if (block.kind === 'h2' || block.kind === 'h3') return textblock('heading', block.runs, { level: block.kind === 'h2' ? 2 : 3 });
78
+ return textblock('paragraph', block.runs);
79
+ });
80
+ return { type: 'doc', content: content.length ? content : [{ type: 'paragraph' }] };
81
+ }
82
+
83
+ /** Inline nodes as runs: a hardBreak is a `\n` (with no marks). */
84
+ export function runsOf(content: readonly PMNode[] | undefined): TextRun[] {
85
+ const runs: TextRun[] = [];
86
+ for (const node of content ?? []) {
87
+ if (node.type === 'hardBreak') {
88
+ runs.push({ text: '\n' });
89
+ continue;
90
+ }
91
+ if (node.type !== 'text' || !node.text) continue;
92
+ const run: TextRun = { text: node.text };
93
+ for (const mark of node.marks ?? []) {
94
+ if (mark.type === 'bold') run.bold = true;
95
+ else if (mark.type === 'italic') run.italic = true;
96
+ else if (mark.type === 'link' && typeof mark.attrs?.href === 'string') run.href = mark.attrs.href;
97
+ }
98
+ runs.push(run);
99
+ }
100
+ return runs;
101
+ }
102
+
103
+ /** Runs split at each `\n`: a heading field's lines (M9 Decision 11). */
104
+ export function splitLines(runs: readonly TextRun[]): TextRun[][] {
105
+ const lines: TextRun[][] = [[]];
106
+ for (const run of runs) {
107
+ run.text.split('\n').forEach((part, i) => {
108
+ if (i > 0) lines.push([]);
109
+ if (part !== '') lines.at(-1)!.push({ ...run, text: part });
110
+ });
111
+ }
112
+ return lines;
113
+ }
114
+
115
+ const isBlock = (node: PMNode) => node.type === 'paragraph' || node.type === 'heading';
116
+
117
+ /**
118
+ * A list item's runs: its inline content (a textblock item), or, in the older shape,
119
+ * its paragraphs joined by breaks should it hold more than one.
120
+ */
121
+ const itemRuns = (item: PMNode): TextRun[] => {
122
+ const content = item.content ?? [];
123
+ if (!content.some(isBlock)) return runsOf(content);
124
+ return content.filter(isBlock).flatMap((child, i) => [...(i > 0 ? [{ text: '\n' }] : []), ...runsOf(child.content)]);
125
+ };
126
+
127
+ /** The editor's document as the field's value (normalised by the field's rules). */
128
+ export function fromDoc(doc: PMNode, options: RichTextOptions): RichTextValue {
129
+ if (options.heading) {
130
+ // A heading's document is inline (or, from older content, paragraphs): each line a paragraph.
131
+ const content = (doc.content ?? []).some(isBlock) ? (doc.content ?? []).filter(isBlock).flatMap((child, i) => [...(i > 0 ? [{ type: 'hardBreak' }] : []), ...(child.content ?? [])]) : doc.content;
132
+ return parseRichText(splitLines(runsOf(content)).map((runs) => ({ kind: 'p', runs })), options);
133
+ }
134
+ const blocks: unknown[] = [];
135
+ for (const node of doc.content ?? []) {
136
+ if (node.type === 'paragraph') blocks.push({ kind: 'p', runs: runsOf(node.content) });
137
+ else if (node.type === 'heading') blocks.push({ kind: node.attrs?.level === 3 ? 'h3' : 'h2', runs: runsOf(node.content) });
138
+ else if (node.type === 'bulletList' || node.type === 'orderedList') {
139
+ blocks.push({ kind: node.type === 'orderedList' ? 'ol' : 'ul', items: (node.content ?? []).map(itemRuns) });
140
+ }
141
+ }
142
+ return parseRichText(blocks, options);
143
+ }
144
+
145
+ /**
146
+ * Pasted plain text (Spec → Text): a blank line starts a new paragraph, a single
147
+ * newline is a break. The paragraphs' inline content, one array per paragraph.
148
+ */
149
+ export function pastedParagraphs(text: string): PMNode[][] {
150
+ return text
151
+ .replace(/\r\n?/g, '\n')
152
+ .split(/\n[ \t]*\n/)
153
+ .map((chunk) => chunk.replace(/^\n+|\n+$/g, ''))
154
+ .filter((chunk) => chunk.trim() !== '')
155
+ .map((chunk) => inlineNodes([{ text: chunk }]));
156
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * The rich-text editor's schema (M5a Decision 3): Tiptap (ProseMirror) with
3
+ * exactly the nodes and marks the field allows, and nothing else. Browser only.
4
+ *
5
+ * - Nodes: paragraph, heading (only when the field allows `h2` or `h2`/`h3`, and
6
+ * only those levels), bulletList / orderedList of listItem, hardBreak. **A list
7
+ * item is a textblock** (on-page rich text, Decision 3): `<li>` holds its text
8
+ * directly, as `richTextHtml` renders it, so the editor's DOM is the render's on
9
+ * the page too. No lists inside lists. Its commands: rich-text-commands.ts.
10
+ * Marks: bold, italic, link (only when the field allows links; it never spans
11
+ * into new text typed at its end).
12
+ * - **No input rules, no paste rules, no autolink, no link-on-paste, no slash
13
+ * menu:** typing `**` gives two asterisks. Formatting is the toolbar, ⌘B, ⌘I and ⌘K.
14
+ * - **The keys follow Spec → Text:** Enter is a new paragraph (in a heading, a new
15
+ * paragraph after it; in a list, a new item); Shift+Enter is a line break in the
16
+ * paragraph, heading or item. Where the table is silent, Google Docs: Enter in an
17
+ * empty item leaves the list; Backspace at the start of an item takes it out of
18
+ * the list; Backspace at the start of a heading joins it to the block above (and
19
+ * an empty heading at the very start becomes a paragraph).
20
+ * - **Paste keeps only what the field allows:** HTML is read through this schema
21
+ * (anything else is dropped or becomes text); plain text has a blank line start a
22
+ * paragraph and a single newline make a break.
23
+ * - **A heading field** (M9 Decision 11): the document is inline content (no paragraph,
24
+ * list or heading nodes), with bold and hardBreak only; Enter is a line break too.
25
+ * - Undo inside the field is its own (⌘Z / ⇧⌘Z), as every text field's is (Decision 14).
26
+ */
27
+ import { Extension, Mark, Node } from '@tiptap/core';
28
+ import { history, redo, undo } from '@tiptap/pm/history';
29
+ import { Fragment, Slice } from '@tiptap/pm/model';
30
+ import type { EditorView } from '@tiptap/pm/view';
31
+ import { isLinkHref } from '../../../links.ts';
32
+ import type { HeadingKind } from '../../../rich-text.ts';
33
+ import { liftListItem, splitListItem } from './rich-text-commands.ts';
34
+ import { pastedParagraphs, type PMNode } from './rich-text-doc.ts';
35
+
36
+ const Doc = Node.create({ name: 'doc', topNode: true, content: 'block+' });
37
+ const Text = Node.create({ name: 'text', group: 'inline' });
38
+
39
+ const Paragraph = Node.create({
40
+ name: 'paragraph',
41
+ group: 'block',
42
+ content: 'inline*',
43
+ priority: 1000,
44
+ parseHTML: () => [{ tag: 'p' }],
45
+ renderHTML: () => ['p', 0],
46
+ });
47
+
48
+ const heading = (levels: readonly number[]) =>
49
+ Node.create({
50
+ name: 'heading',
51
+ group: 'block',
52
+ content: 'inline*',
53
+ defining: true,
54
+ addAttributes: () => ({ level: { default: levels[0] ?? 2, rendered: false } }),
55
+ parseHTML: () => levels.map((level) => ({ tag: `h${level}`, attrs: { level } })),
56
+ renderHTML: ({ node }) => [`h${node.attrs.level as number}`, 0],
57
+ });
58
+
59
+ const list = (name: string, tag: string) =>
60
+ Node.create({
61
+ name,
62
+ group: 'block',
63
+ content: 'listItem+',
64
+ parseHTML: () => [{ tag }],
65
+ renderHTML: () => [tag, 0],
66
+ });
67
+
68
+ const ListItem = Node.create({
69
+ name: 'listItem',
70
+ content: 'inline*',
71
+ defining: true,
72
+ parseHTML: () => [
73
+ { tag: 'li' },
74
+ // A pasted item holding a paragraph (Google Docs, most web pages: <li><p>…</p></li>): its text is the item's.
75
+ // Above the paragraph's own rule (rules without a priority are 50).
76
+ { tag: 'li > p', skip: true, priority: 60 },
77
+ ],
78
+ renderHTML: () => ['li', 0],
79
+ });
80
+
81
+ const HardBreak = Node.create({
82
+ name: 'hardBreak',
83
+ group: 'inline',
84
+ inline: true,
85
+ selectable: false,
86
+ linebreakReplacement: true,
87
+ parseHTML: () => [{ tag: 'br' }],
88
+ renderHTML: () => ['br'],
89
+ renderText: () => '\n',
90
+ });
91
+
92
+ const Bold = Mark.create({
93
+ name: 'bold',
94
+ parseHTML: () => [
95
+ { tag: 'strong' },
96
+ // Google Docs wraps a paste in <b style="font-weight:normal">.
97
+ { tag: 'b', getAttrs: (node) => (node as HTMLElement).style.fontWeight !== 'normal' && null },
98
+ { style: 'font-weight=400', clearMark: (mark) => mark.type.name === 'bold' },
99
+ { style: 'font-weight', getAttrs: (value) => /^(bold(er)?|[5-9]\d{2,})$/.test(value as string) && null },
100
+ ],
101
+ renderHTML: () => ['strong', 0],
102
+ });
103
+
104
+ const Italic = Mark.create({
105
+ name: 'italic',
106
+ parseHTML: () => [
107
+ { tag: 'em' },
108
+ { tag: 'i', getAttrs: (node) => (node as HTMLElement).style.fontStyle !== 'normal' && null },
109
+ { style: 'font-style=normal', clearMark: (mark) => mark.type.name === 'italic' },
110
+ { style: 'font-style=italic' },
111
+ ],
112
+ renderHTML: () => ['em', 0],
113
+ });
114
+
115
+ const Link = Mark.create({
116
+ name: 'link',
117
+ // Typing at a link's end doesn't extend it; a link can't hold a link (a mark excludes its own type).
118
+ inclusive: false,
119
+ addAttributes: () => ({ href: { default: '' } }),
120
+ parseHTML: () => [{ tag: 'a[href]', getAttrs: (dom) => ({ href: (dom as HTMLElement).getAttribute('href') ?? '' }) }],
121
+ // The editor shows a link as a link; an href that fails the rule isn't put in the DOM (it's kept in the value).
122
+ renderHTML: ({ mark }) => ['a', { href: isLinkHref(mark.attrs.href) ? (mark.attrs.href as string) : null, 'data-href': mark.attrs.href as string }, 0],
123
+ });
124
+
125
+ /** The keys (Spec → Text) and the field's own undo. ⌘K is the component's (it must not reach the switcher). */
126
+ const Keys = () =>
127
+ Extension.create({
128
+ name: 'caracmsKeys',
129
+ priority: 1000,
130
+ addProseMirrorPlugins: () => [history()],
131
+ addKeyboardShortcuts() {
132
+ const editor = this.editor;
133
+ const inItem = () => editor.state.selection.$from.parent.type.name === 'listItem';
134
+ return {
135
+ 'Shift-Enter': () =>
136
+ editor.commands.command(({ tr, state, dispatch }) => {
137
+ const br = state.schema.nodes.hardBreak!;
138
+ if (dispatch) tr.replaceSelectionWith(br.create(), false).scrollIntoView();
139
+ return true;
140
+ }),
141
+ Enter: () => (inItem() ? splitListItem(editor.state, editor.view.dispatch) : false),
142
+ Backspace: () => {
143
+ const { empty, $from } = editor.state.selection;
144
+ if (!empty || $from.parentOffset !== 0 || !inItem()) return false;
145
+ return liftListItem(editor.state, editor.view.dispatch);
146
+ },
147
+ 'Mod-b': () => editor.commands.toggleMark('bold'),
148
+ 'Mod-i': () => editor.commands.toggleMark('italic'),
149
+ 'Mod-z': () => undo(editor.state, editor.view.dispatch),
150
+ 'Shift-Mod-z': () => redo(editor.state, editor.view.dispatch),
151
+ 'Mod-y': () => redo(editor.state, editor.view.dispatch),
152
+ // No sinking an item into a nested list, and no tab characters: Tab moves focus on.
153
+ Tab: () => false,
154
+ };
155
+ },
156
+ });
157
+
158
+ /**
159
+ * A heading field's document (M9 Decision 11): inline content straight inside the
160
+ * heading tag, as RichText.astro draws it, so there's no paragraph to put a `<p>` in.
161
+ */
162
+ const InlineDoc = Node.create({ name: 'doc', topNode: true, content: 'inline*' });
163
+
164
+ /** A heading field's keys: Enter and Shift+Enter both start a new line; bold only. */
165
+ const HeadingKeys = () =>
166
+ Extension.create({
167
+ name: 'caracmsHeadingKeys',
168
+ priority: 1000,
169
+ addProseMirrorPlugins: () => [history()],
170
+ addKeyboardShortcuts() {
171
+ const editor = this.editor;
172
+ const lineBreak = () =>
173
+ editor.commands.command(({ tr, state, dispatch }) => {
174
+ if (dispatch) tr.replaceSelectionWith(state.schema.nodes.hardBreak!.create(), false).scrollIntoView();
175
+ return true;
176
+ });
177
+ return {
178
+ 'Shift-Enter': lineBreak,
179
+ Enter: lineBreak,
180
+ 'Mod-b': () => editor.commands.toggleMark('bold'),
181
+ 'Mod-i': () => true,
182
+ 'Mod-z': () => undo(editor.state, editor.view.dispatch),
183
+ 'Shift-Mod-z': () => redo(editor.state, editor.view.dispatch),
184
+ 'Mod-y': () => redo(editor.state, editor.view.dispatch),
185
+ Tab: () => false,
186
+ };
187
+ },
188
+ });
189
+
190
+ /** Every extension the field's editor runs with. */
191
+ export function richTextExtensions(options: { headings: readonly HeadingKind[]; links: boolean; heading?: boolean }) {
192
+ if (options.heading) return [InlineDoc, Text, HardBreak, Bold, HeadingKeys()];
193
+ const levels = options.headings.map((h) => (h === 'h2' ? 2 : 3));
194
+ return [
195
+ Doc,
196
+ Text,
197
+ Paragraph,
198
+ ...(levels.length ? [heading(levels)] : []),
199
+ list('bulletList', 'ul'),
200
+ list('orderedList', 'ol'),
201
+ ListItem,
202
+ HardBreak,
203
+ Bold,
204
+ Italic,
205
+ ...(options.links ? [Link] : []),
206
+ Keys(),
207
+ ];
208
+ }
209
+
210
+ /** Plain text pasted (Spec → Text): a blank line starts a new paragraph, a single newline is a break. In a heading (no paragraphs): each a line. */
211
+ export function clipboardTextParser(text: string, _context: unknown, _plain: boolean, view: EditorView): Slice {
212
+ const schema = view.state.schema;
213
+ if (!schema.nodes.paragraph) {
214
+ const inline = pastedParagraphs(text).flatMap((nodes, i) => [...(i > 0 ? [{ type: 'hardBreak' } as PMNode] : []), ...nodes]);
215
+ return new Slice(Fragment.fromArray(inline.map((n) => (n.type === 'hardBreak' ? schema.nodes.hardBreak!.create() : schema.text(n.text!)))), 0, 0);
216
+ }
217
+ const paragraphs = pastedParagraphs(text).map((inline) =>
218
+ schema.nodes.paragraph!.create(
219
+ null,
220
+ inline.map((n: PMNode) => (n.type === 'hardBreak' ? schema.nodes.hardBreak!.create() : schema.text(n.text!))),
221
+ ),
222
+ );
223
+ return Slice.maxOpen(Fragment.fromArray(paragraphs));
224
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Fields changed since the preview last rendered (the mockup's #preview-failed: the
3
+ * field says "Not in the preview yet" while the preview is behind). A field is added
4
+ * when it changes and dropped when a render lands; the field frame shows the chip
5
+ * only while the latest render failed (previewStatus.failed).
6
+ *
7
+ * staleFields(editor) Store<ReadonlySet<string>> of `${blockKey} ${field}`
8
+ * staleKey(key, field)
9
+ *
10
+ * One store per editor (a WeakMap); nothing on the server, where there's no preview.
11
+ */
12
+ import { isIndexPart, splitPath } from '../../../fields.ts';
13
+ import type { Editor } from '../editor.ts';
14
+ import { createStore, type Store } from '../state/store.ts';
15
+
16
+ const stores = new WeakMap<Editor, Store<ReadonlySet<string>>>();
17
+
18
+ export const staleKey = (key: string, field: string) => `${key} ${field}`;
19
+
20
+ /**
21
+ * The field frames a change at `path` marks: a list marks itself (`credits.1.name` →
22
+ * `credits`, M5a Decision 16), and a group is see-through, so its fields mark their own
23
+ * frames (`left.heading` → `left`, `left.heading`; `left.gallery.0.alt` → `left`,
24
+ * `left.gallery`). Every key part up to the first row index.
25
+ */
26
+ export function staleNames(path: string): string[] {
27
+ const out: string[] = [];
28
+ let at = '';
29
+ for (const part of splitPath(path)) {
30
+ if (isIndexPart(part)) break;
31
+ at = at ? `${at}.${part}` : part;
32
+ out.push(at);
33
+ }
34
+ return out;
35
+ }
36
+
37
+ export function staleFields(editor: Editor): Store<ReadonlySet<string>> {
38
+ let store = stores.get(editor);
39
+ if (store) return store;
40
+ store = createStore<ReadonlySet<string>>(new Set());
41
+ stores.set(editor, store);
42
+ const driver = editor.preview;
43
+ if (!driver) return store;
44
+ const s = store;
45
+ editor.doc.subscribe((_, change) => {
46
+ if (change.kind === 'field') {
47
+ // A change inside a list (`credits.1.name`) marks the list; inside a group, the group's field.
48
+ const keys = staleNames(change.field).map((name) => staleKey(change.key, name));
49
+ if (keys.some((k) => !s.get().has(k))) s.set(new Set([...s.get(), ...keys]));
50
+ } else if (change.kind === 'load') {
51
+ if (s.get().size) s.set(new Set());
52
+ }
53
+ });
54
+ driver.onMorph(() => {
55
+ if (s.get().size) s.set(new Set());
56
+ });
57
+ return s;
58
+ }