@gogitcms/editor 0.23.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 (143) hide show
  1. package/app/index.html +38 -0
  2. package/app/src/App.tsx +2665 -0
  3. package/app/src/apollo.ts +119 -0
  4. package/app/src/auth.ts +248 -0
  5. package/app/src/config.ts +88 -0
  6. package/app/src/main.tsx +18 -0
  7. package/app/src/media.ts +151 -0
  8. package/app/src/navigation.tsx +321 -0
  9. package/app/src/plugins.ts +11 -0
  10. package/app/src/previewHost.tsx +263 -0
  11. package/app/src/previewToken.ts +68 -0
  12. package/app/src/queries.ts +591 -0
  13. package/app/src/virtual-cms-plugins.d.ts +6 -0
  14. package/app/vendor/analytics/src/__tests__/config.test.ts +91 -0
  15. package/app/vendor/analytics/src/config.ts +94 -0
  16. package/app/vendor/analytics/src/events.ts +56 -0
  17. package/app/vendor/analytics/src/index.ts +23 -0
  18. package/app/vendor/analytics/src/provider.tsx +196 -0
  19. package/app/vendor/design-system/src/ThemeProvider.tsx +86 -0
  20. package/app/vendor/design-system/src/__tests__/ApplyChangesModal.test.tsx +148 -0
  21. package/app/vendor/design-system/src/__tests__/BranchImport.test.tsx +46 -0
  22. package/app/vendor/design-system/src/__tests__/Button.test.tsx +45 -0
  23. package/app/vendor/design-system/src/__tests__/ChangeRequestSummary.test.tsx +57 -0
  24. package/app/vendor/design-system/src/__tests__/ContentBrowser.changes.test.tsx +611 -0
  25. package/app/vendor/design-system/src/__tests__/ContentBrowser.collab.test.tsx +322 -0
  26. package/app/vendor/design-system/src/__tests__/ContentBrowser.collabsync.test.tsx +264 -0
  27. package/app/vendor/design-system/src/__tests__/ContentBrowser.contentslot.test.tsx +53 -0
  28. package/app/vendor/design-system/src/__tests__/ContentBrowser.discriminator.test.tsx +142 -0
  29. package/app/vendor/design-system/src/__tests__/ContentBrowser.drafts.test.tsx +271 -0
  30. package/app/vendor/design-system/src/__tests__/ContentBrowser.fields.test.tsx +117 -0
  31. package/app/vendor/design-system/src/__tests__/ContentBrowser.media.test.tsx +140 -0
  32. package/app/vendor/design-system/src/__tests__/ContentBrowser.mixedcollab.test.tsx +63 -0
  33. package/app/vendor/design-system/src/__tests__/ContentBrowser.mixedvalues.test.tsx +38 -0
  34. package/app/vendor/design-system/src/__tests__/ContentBrowser.pagination.test.tsx +62 -0
  35. package/app/vendor/design-system/src/__tests__/ContentBrowser.previewtab.test.tsx +212 -0
  36. package/app/vendor/design-system/src/__tests__/ContentBrowser.reorder.test.tsx +45 -0
  37. package/app/vendor/design-system/src/__tests__/ContentBrowser.search.test.tsx +135 -0
  38. package/app/vendor/design-system/src/__tests__/ContentBrowser.selectvalue.test.tsx +185 -0
  39. package/app/vendor/design-system/src/__tests__/ContentBrowser.staged.test.tsx +132 -0
  40. package/app/vendor/design-system/src/__tests__/ContentBrowser.usermenu.test.tsx +56 -0
  41. package/app/vendor/design-system/src/__tests__/MediaBrowser.test.tsx +353 -0
  42. package/app/vendor/design-system/src/__tests__/MediaField.test.tsx +185 -0
  43. package/app/vendor/design-system/src/__tests__/Notifications.test.tsx +69 -0
  44. package/app/vendor/design-system/src/__tests__/Onboarding.test.tsx +287 -0
  45. package/app/vendor/design-system/src/__tests__/cssTokens.test.ts +201 -0
  46. package/app/vendor/design-system/src/__tests__/fieldComponents.test.ts +43 -0
  47. package/app/vendor/design-system/src/__tests__/reorder.test.ts +58 -0
  48. package/app/vendor/design-system/src/components/ApplyChangesModal.tsx +348 -0
  49. package/app/vendor/design-system/src/components/BranchImport.tsx +157 -0
  50. package/app/vendor/design-system/src/components/BranchMenu.tsx +192 -0
  51. package/app/vendor/design-system/src/components/Button.tsx +131 -0
  52. package/app/vendor/design-system/src/components/ChangeDetail.tsx +472 -0
  53. package/app/vendor/design-system/src/components/ChangeRequestSummary.tsx +173 -0
  54. package/app/vendor/design-system/src/components/CollabField.tsx +388 -0
  55. package/app/vendor/design-system/src/components/ContentBrowser.tsx +5073 -0
  56. package/app/vendor/design-system/src/components/Icon.tsx +28 -0
  57. package/app/vendor/design-system/src/components/Icon.web.tsx +31 -0
  58. package/app/vendor/design-system/src/components/Input.tsx +106 -0
  59. package/app/vendor/design-system/src/components/MediaBrowser.tsx +766 -0
  60. package/app/vendor/design-system/src/components/MediaField.tsx +670 -0
  61. package/app/vendor/design-system/src/components/MediaPreview.tsx +91 -0
  62. package/app/vendor/design-system/src/components/MediaPreview.web.tsx +169 -0
  63. package/app/vendor/design-system/src/components/NavRow.tsx +105 -0
  64. package/app/vendor/design-system/src/components/Notifications.tsx +301 -0
  65. package/app/vendor/design-system/src/components/Onboarding.tsx +751 -0
  66. package/app/vendor/design-system/src/components/ProjectMenu.tsx +124 -0
  67. package/app/vendor/design-system/src/components/Segment.tsx +87 -0
  68. package/app/vendor/design-system/src/components/Skeleton.tsx +216 -0
  69. package/app/vendor/design-system/src/components/Spinner.tsx +44 -0
  70. package/app/vendor/design-system/src/components/Text.tsx +85 -0
  71. package/app/vendor/design-system/src/components/documentDrafts.ts +213 -0
  72. package/app/vendor/design-system/src/components/layout.tsx +284 -0
  73. package/app/vendor/design-system/src/components/primitives.tsx +143 -0
  74. package/app/vendor/design-system/src/components/reorder.ts +40 -0
  75. package/app/vendor/design-system/src/fieldComponents.ts +63 -0
  76. package/app/vendor/design-system/src/icons.ts +102 -0
  77. package/app/vendor/design-system/src/index.ts +198 -0
  78. package/app/vendor/design-system/src/media.ts +229 -0
  79. package/app/vendor/design-system/src/theme.ts +116 -0
  80. package/app/vendor/design-system/src/web/Button.tsx +110 -0
  81. package/app/vendor/design-system/src/web/Icon.tsx +52 -0
  82. package/app/vendor/design-system/src/web/Input.tsx +39 -0
  83. package/app/vendor/design-system/src/web/index.ts +43 -0
  84. package/app/vendor/design-system/src/web/primitives.tsx +119 -0
  85. package/app/vendor/markdown-editor/src/MarkdownEditor.tsx +248 -0
  86. package/app/vendor/markdown-editor/src/Toolbar.tsx +157 -0
  87. package/app/vendor/markdown-editor/src/field.tsx +67 -0
  88. package/app/vendor/markdown-editor/src/flavors/commonmark/index.ts +27 -0
  89. package/app/vendor/markdown-editor/src/flavors/gfm/index.ts +30 -0
  90. package/app/vendor/markdown-editor/src/flavors/gfm/parser.ts +73 -0
  91. package/app/vendor/markdown-editor/src/flavors/gfm/schema.ts +70 -0
  92. package/app/vendor/markdown-editor/src/flavors/gfm/serializer.ts +82 -0
  93. package/app/vendor/markdown-editor/src/flavors/gfm/taskList.ts +39 -0
  94. package/app/vendor/markdown-editor/src/flavors/registry.ts +13 -0
  95. package/app/vendor/markdown-editor/src/flavors/shared/inputrules.ts +79 -0
  96. package/app/vendor/markdown-editor/src/flavors/shared/keymap.ts +89 -0
  97. package/app/vendor/markdown-editor/src/flavors/shared/placeholder.ts +24 -0
  98. package/app/vendor/markdown-editor/src/flavors/shared/plugins.ts +58 -0
  99. package/app/vendor/markdown-editor/src/flavors/types.ts +22 -0
  100. package/app/vendor/markdown-editor/src/formats/registry.ts +45 -0
  101. package/app/vendor/markdown-editor/src/icons.tsx +120 -0
  102. package/app/vendor/markdown-editor/src/index.ts +23 -0
  103. package/app/vendor/markdown-editor/src/theme.ts +249 -0
  104. package/app/vendor/markdown-editor/src/types.ts +58 -0
  105. package/app/vendor/plugin-sdk/src/__tests__/documentActions.test.ts +108 -0
  106. package/app/vendor/plugin-sdk/src/__tests__/loader.test.ts +75 -0
  107. package/app/vendor/plugin-sdk/src/__tests__/registry.test.ts +148 -0
  108. package/app/vendor/plugin-sdk/src/index.ts +33 -0
  109. package/app/vendor/plugin-sdk/src/loader.ts +45 -0
  110. package/app/vendor/plugin-sdk/src/navkeys.ts +17 -0
  111. package/app/vendor/plugin-sdk/src/react.tsx +155 -0
  112. package/app/vendor/plugin-sdk/src/registry.ts +337 -0
  113. package/app/vendor/plugin-sdk/src/types.ts +230 -0
  114. package/app/vendor/realtime/src/__tests__/pure.test.ts +58 -0
  115. package/app/vendor/realtime/src/__tests__/seeded-room.base64 +1 -0
  116. package/app/vendor/realtime/src/__tests__/seeding.test.ts +70 -0
  117. package/app/vendor/realtime/src/collab.ts +308 -0
  118. package/app/vendor/realtime/src/hooks.ts +57 -0
  119. package/app/vendor/realtime/src/index.ts +12 -0
  120. package/app/vendor/realtime/src/pure.ts +43 -0
  121. package/app/vite.config.mjs +59 -0
  122. package/bin/gogitcms-editor.mjs +168 -0
  123. package/npm-shrinkwrap.json +5696 -0
  124. package/package.json +74 -0
  125. package/src/commands/build.mjs +100 -0
  126. package/src/commands/dev.mjs +186 -0
  127. package/src/commands/init.mjs +144 -0
  128. package/src/commands/login.mjs +116 -0
  129. package/src/commands/status.mjs +68 -0
  130. package/src/lib/api.mjs +122 -0
  131. package/src/lib/appRoot.mjs +51 -0
  132. package/src/lib/baked.json +3 -0
  133. package/src/lib/config.mjs +108 -0
  134. package/src/lib/credentials.mjs +91 -0
  135. package/src/lib/defaults.mjs +26 -0
  136. package/src/lib/graphql.mjs +28 -0
  137. package/src/lib/localServer.mjs +206 -0
  138. package/src/lib/open.mjs +23 -0
  139. package/src/lib/plugins.mjs +164 -0
  140. package/src/lib/tokenBroker.mjs +85 -0
  141. package/src/tui/prompts.mjs +92 -0
  142. package/src/tui/screen.mjs +282 -0
  143. package/src/tui/theme.mjs +40 -0
@@ -0,0 +1,248 @@
1
+ "use dom";
2
+
3
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { EditorState, type Transaction } from "prosemirror-state";
5
+ import {
6
+ ProseMirror,
7
+ ProseMirrorDoc,
8
+ reactKeys,
9
+ } from "@handlewithcare/react-prosemirror";
10
+ import * as Y from "yjs";
11
+ import { prosemirrorToYXmlFragment } from "y-prosemirror";
12
+ import type { Awareness } from "y-protocols/awareness";
13
+ import { resolveFlavor } from "./flavors/registry";
14
+ import type { EditorCollabBinding } from "./flavors/shared/plugins";
15
+ import { placeholderPlugin } from "./flavors/shared/placeholder";
16
+ import { ensureEditorStyles } from "./theme";
17
+ import { Toolbar } from "./Toolbar";
18
+ import type { MarkdownEditorProps } from "./types";
19
+
20
+ // A markdown editor authored as web React and marked as an Expo DOM component.
21
+ // Under a web bundler (Vite) the `"use dom"` directive is inert and this renders
22
+ // inline; under Expo's Metro it runs inside a WebView on native. Every prop is
23
+ // serializable and `onChange` is treated as an async native action.
24
+ export default function MarkdownEditor({
25
+ value,
26
+ onChange,
27
+ flavor: flavorId,
28
+ readOnly = false,
29
+ placeholder = "",
30
+ theme = "light",
31
+ debounceMs = 200,
32
+ expandable = true,
33
+ toolbar = true,
34
+ collab,
35
+ }: MarkdownEditorProps) {
36
+ const flavor = useMemo(() => resolveFlavor(flavorId), [flavorId]);
37
+ const [expanded, setExpanded] = useState(false);
38
+ const wrapperRef = useRef<HTMLDivElement>(null);
39
+
40
+ // Focus mode covers the whole viewport. It used to measure the nearest
41
+ // host-designated container and cover only that, which left the editor
42
+ // stretched full-height beside the field list — the very layout focus mode
43
+ // exists to get out of.
44
+ const toggleExpanded = useCallback(() => setExpanded((current) => !current), []);
45
+
46
+ // Resolve the collaborative binding once per doc/flavor: the shared fragment
47
+ // and awareness.
48
+ const collabBinding = useMemo<EditorCollabBinding | undefined>(() => {
49
+ if (!collab) return undefined;
50
+ return {
51
+ fragment: (collab.ydoc as Y.Doc).getXmlFragment(collab.fragmentName),
52
+ awareness: collab.awareness as Awareness,
53
+ };
54
+ // eslint-disable-next-line react-hooks/exhaustive-deps
55
+ }, [collab, flavor]);
56
+
57
+ // Seed the shared fragment from the current markdown when the room turns out
58
+ // to be empty (server-side seeding of the body supersedes this in a later
59
+ // phase). ySyncPlugin observes the fragment, so seeded content flows into the
60
+ // mounted editor.
61
+ //
62
+ // The decision waits for the provider's initial sync when the host exposes it
63
+ // (collab.synced/subscribeSynced): before sync the local doc is ALWAYS empty
64
+ // and awareness holds only ourselves, so an eager check both seeds over a
65
+ // room that already has content (duplicating the body after the merge) and
66
+ // reads its "single client" guard from a list that cannot contain anyone
67
+ // else yet. After sync the fragment and peer set are real: an empty fragment
68
+ // means the room is genuinely fresh, and if several clients arrive at one
69
+ // together, the lowest client id seeds — deterministic, exactly one writer,
70
+ // and re-evaluated on awareness/fragment changes so a lingering awareness
71
+ // entry from a crashed tab only defers seeding until it times out instead of
72
+ // leaving the body empty for good.
73
+ const seededRef = useRef(false);
74
+ useEffect(() => {
75
+ if (!collabBinding || seededRef.current) return;
76
+ const { fragment, awareness } = collabBinding;
77
+ const trySeed = () => {
78
+ if (seededRef.current) return;
79
+ if (collab?.synced && !collab.synced()) return;
80
+ if (fragment.length > 0 || !value) {
81
+ // Non-empty room, or nothing to seed: either way the decision is made.
82
+ seededRef.current = true;
83
+ return;
84
+ }
85
+ const states = awareness.getStates();
86
+ if (states.size > 1 && awareness.clientID !== Math.min(...states.keys())) return;
87
+ prosemirrorToYXmlFragment(flavor.parse(value), fragment);
88
+ seededRef.current = true;
89
+ };
90
+ trySeed();
91
+ if (seededRef.current) return;
92
+ const unsubSync = collab?.subscribeSynced?.(trySeed);
93
+ const onFragment = () => trySeed();
94
+ fragment.observe(onFragment);
95
+ const onAwareness = () => trySeed();
96
+ awareness.on("change", onAwareness);
97
+ return () => {
98
+ unsubSync?.();
99
+ fragment.unobserve(onFragment);
100
+ awareness.off("change", onAwareness);
101
+ };
102
+ // eslint-disable-next-line react-hooks/exhaustive-deps
103
+ }, [collabBinding]);
104
+
105
+ const makeState = useCallback(
106
+ (markdown: string) =>
107
+ EditorState.create(
108
+ collabBinding
109
+ ? {
110
+ // The shared fragment is the source of truth; ySyncPlugin fills
111
+ // the (initially empty) doc from it.
112
+ schema: flavor.schema,
113
+ plugins: [...flavor.plugins(collabBinding), placeholderPlugin(placeholder), reactKeys()],
114
+ }
115
+ : {
116
+ doc: flavor.parse(markdown),
117
+ plugins: [...flavor.plugins(), placeholderPlugin(placeholder), reactKeys()],
118
+ },
119
+ ),
120
+ [flavor, placeholder, collabBinding],
121
+ );
122
+
123
+ const [state, setState] = useState<EditorState>(() => makeState(value));
124
+ const stateRef = useRef(state);
125
+ stateRef.current = state;
126
+
127
+ // Inject the stylesheet once the component is mounted in a real document.
128
+ useEffect(() => {
129
+ ensureEditorStyles(typeof document !== "undefined" ? document : undefined);
130
+ }, []);
131
+
132
+ // Rebuild the document when the flavor changes.
133
+ useEffect(() => {
134
+ setState(makeState(value));
135
+ // eslint-disable-next-line react-hooks/exhaustive-deps
136
+ }, [flavor]);
137
+
138
+ // Adopt external value changes without clobbering in-progress edits: only
139
+ // reparse when the incoming markdown differs from what the current doc holds.
140
+ // In collab mode the shared fragment is the source of truth, so `value` is
141
+ // ignored (the CRDT drives the content).
142
+ useEffect(() => {
143
+ if (collabBinding) return;
144
+ if (value !== flavor.serialize(stateRef.current.doc)) {
145
+ setState(makeState(value));
146
+ }
147
+ // eslint-disable-next-line react-hooks/exhaustive-deps
148
+ }, [value]);
149
+
150
+ // Debounced serialize -> onChange.
151
+ //
152
+ // Suppressed when the serialized doc is merely the canonical form of the
153
+ // value this editor was given: serialize(parse(value)) normalizes formatting
154
+ // the author never touched (a leading blank line, bullet or emphasis
155
+ // markers), and reporting that as a change marks an untouched document
156
+ // unsaved. The collab fill is the case that matters — ySyncPlugin's initial
157
+ // sync is a doc-changing transaction, so without this guard every document
158
+ // whose markdown is not already in canonical form opens dirty. A fill that
159
+ // really does differ from `value` (a peer edited before we joined) still
160
+ // emits, which is what keeps the host's map — and the next save — current.
161
+ const valueRef = useRef(value);
162
+ valueRef.current = value;
163
+ const canonicalCache = useRef<{ flavor: typeof flavor; raw: string; canonical: string } | null>(null);
164
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
165
+ const emit = useCallback(
166
+ (doc: EditorState["doc"]) => {
167
+ if (!onChange) return;
168
+ if (timer.current) clearTimeout(timer.current);
169
+ timer.current = setTimeout(() => {
170
+ const markdown = flavor.serialize(doc);
171
+ const raw = valueRef.current;
172
+ if (markdown === raw) return;
173
+ if (canonicalCache.current?.flavor !== flavor || canonicalCache.current.raw !== raw) {
174
+ let canonical = raw;
175
+ try {
176
+ canonical = flavor.serialize(flavor.parse(raw));
177
+ } catch {
178
+ /* unparseable value: the raw comparison above already ran */
179
+ }
180
+ canonicalCache.current = { flavor, raw, canonical };
181
+ }
182
+ if (markdown === canonicalCache.current.canonical) return;
183
+ void onChange(markdown);
184
+ }, debounceMs);
185
+ },
186
+ [onChange, flavor, debounceMs],
187
+ );
188
+ useEffect(() => () => {
189
+ if (timer.current) clearTimeout(timer.current);
190
+ }, []);
191
+
192
+ const dispatchTransaction = useCallback(
193
+ (tr: Transaction) => {
194
+ setState((prev) => {
195
+ const next = prev.apply(tr);
196
+ if (tr.docChanged) emit(next.doc);
197
+ return next;
198
+ });
199
+ },
200
+ [emit],
201
+ );
202
+
203
+ const editable = useCallback(() => !readOnly, [readOnly]);
204
+
205
+ // Focus mode: close on Escape and lock background scroll while expanded.
206
+ useEffect(() => {
207
+ if (!expanded || typeof document === "undefined") return;
208
+ const onKey = (e: KeyboardEvent) => {
209
+ if (e.key === "Escape") setExpanded(false);
210
+ };
211
+ document.addEventListener("keydown", onKey);
212
+ const prevOverflow = document.body.style.overflow;
213
+ document.body.style.overflow = "hidden";
214
+ return () => {
215
+ document.removeEventListener("keydown", onKey);
216
+ document.body.style.overflow = prevOverflow;
217
+ };
218
+ }, [expanded]);
219
+
220
+ return (
221
+ <div
222
+ ref={wrapperRef}
223
+ className={expanded ? "cms-editor cms-editor--expanded" : "cms-editor"}
224
+ data-theme={theme}
225
+ // Publish presence (which field this user is editing) as the contenteditable
226
+ // gains/loses focus. Capture-phase so it fires for the inner editor node.
227
+ onFocusCapture={collab?.onFocusChange ? () => collab.onFocusChange!(true) : undefined}
228
+ onBlurCapture={collab?.onFocusChange ? () => collab.onFocusChange!(false) : undefined}
229
+ >
230
+ <ProseMirror
231
+ state={state}
232
+ dispatchTransaction={dispatchTransaction}
233
+ editable={editable}
234
+ >
235
+ {toolbar || expandable ? (
236
+ <Toolbar
237
+ schema={flavor.schema}
238
+ readOnly={readOnly || !toolbar}
239
+ expandable={expandable}
240
+ expanded={expanded}
241
+ onToggleExpand={toggleExpanded}
242
+ />
243
+ ) : null}
244
+ <ProseMirrorDoc />
245
+ </ProseMirror>
246
+ </div>
247
+ );
248
+ }
@@ -0,0 +1,157 @@
1
+ import React, { useMemo } from "react";
2
+ import { setBlockType, toggleMark, wrapIn } from "prosemirror-commands";
3
+ import { wrapInList } from "prosemirror-schema-list";
4
+ import {
5
+ useEditorEventCallback,
6
+ useEditorState,
7
+ } from "@handlewithcare/react-prosemirror";
8
+ import type { MarkType, NodeType, Schema } from "prosemirror-model";
9
+ import type { Command, EditorState } from "prosemirror-state";
10
+ import type { EditorView } from "prosemirror-view";
11
+ import {
12
+ BoldIcon,
13
+ BulletListIcon,
14
+ CodeIcon,
15
+ ExpandIcon,
16
+ H1Icon,
17
+ H2Icon,
18
+ ItalicIcon,
19
+ OrderedListIcon,
20
+ QuoteIcon,
21
+ StrikethroughIcon,
22
+ } from "./icons";
23
+
24
+ function markActive(state: EditorState, type: MarkType): boolean {
25
+ const { from, $from, to, empty } = state.selection;
26
+ if (empty) return !!type.isInSet(state.storedMarks || $from.marks());
27
+ return state.doc.rangeHasMark(from, to, type);
28
+ }
29
+
30
+ function blockActive(state: EditorState, type: NodeType, attrs: Record<string, unknown> = {}): boolean {
31
+ const { $from, to } = state.selection;
32
+ return to <= $from.end() && $from.parent.hasMarkup(type, attrs);
33
+ }
34
+
35
+ // True when any ancestor of the selection is a node of the given type (for
36
+ // list/blockquote wrappers, which aren't the direct textblock parent).
37
+ function ancestorActive(state: EditorState, type: NodeType): boolean {
38
+ const { $from } = state.selection;
39
+ for (let d = $from.depth; d > 0; d--) {
40
+ if ($from.node(d).type === type) return true;
41
+ }
42
+ return false;
43
+ }
44
+
45
+ type ToolItem = {
46
+ key: string;
47
+ title: string;
48
+ Icon: React.ComponentType<{ size?: number }>;
49
+ command: Command;
50
+ isActive?: (state: EditorState) => boolean;
51
+ };
52
+
53
+ function buildGroups(schema: Schema): ToolItem[][] {
54
+ const groups: ToolItem[][] = [];
55
+
56
+ const marks: ToolItem[] = [];
57
+ const { strong, em, strikethrough, code } = schema.marks;
58
+ if (strong) marks.push({ key: "bold", title: "Bold (⌘B)", Icon: BoldIcon, command: toggleMark(strong), isActive: (s) => markActive(s, strong) });
59
+ if (em) marks.push({ key: "italic", title: "Italic (⌘I)", Icon: ItalicIcon, command: toggleMark(em), isActive: (s) => markActive(s, em) });
60
+ if (strikethrough) marks.push({ key: "strike", title: "Strikethrough (⌘⇧X)", Icon: StrikethroughIcon, command: toggleMark(strikethrough), isActive: (s) => markActive(s, strikethrough) });
61
+ if (code) marks.push({ key: "code", title: "Inline code", Icon: CodeIcon, command: toggleMark(code), isActive: (s) => markActive(s, code) });
62
+ if (marks.length) groups.push(marks);
63
+
64
+ const blocks: ToolItem[] = [];
65
+ const heading = schema.nodes.heading;
66
+ const paragraph = schema.nodes.paragraph;
67
+ const toggleHeading = (level: number): Command => (state, dispatch, view) => {
68
+ const cmd = blockActive(state, heading, { level })
69
+ ? setBlockType(paragraph)
70
+ : setBlockType(heading, { level });
71
+ return cmd(state, dispatch, view);
72
+ };
73
+ if (heading) {
74
+ blocks.push({ key: "h1", title: "Heading 1", Icon: H1Icon, command: toggleHeading(1), isActive: (s) => blockActive(s, heading, { level: 1 }) });
75
+ blocks.push({ key: "h2", title: "Heading 2", Icon: H2Icon, command: toggleHeading(2), isActive: (s) => blockActive(s, heading, { level: 2 }) });
76
+ }
77
+ if (schema.nodes.bullet_list) {
78
+ const t = schema.nodes.bullet_list;
79
+ blocks.push({ key: "ul", title: "Bullet list", Icon: BulletListIcon, command: wrapInList(t), isActive: (s) => ancestorActive(s, t) });
80
+ }
81
+ if (schema.nodes.ordered_list) {
82
+ const t = schema.nodes.ordered_list;
83
+ blocks.push({ key: "ol", title: "Numbered list", Icon: OrderedListIcon, command: wrapInList(t), isActive: (s) => ancestorActive(s, t) });
84
+ }
85
+ if (schema.nodes.blockquote) {
86
+ const t = schema.nodes.blockquote;
87
+ blocks.push({ key: "quote", title: "Blockquote", Icon: QuoteIcon, command: wrapIn(t), isActive: (s) => ancestorActive(s, t) });
88
+ }
89
+ if (blocks.length) groups.push(blocks);
90
+
91
+ return groups;
92
+ }
93
+
94
+ function ToolButton({ item }: { item: ToolItem }) {
95
+ const state = useEditorState();
96
+ const run = useEditorEventCallback((view: EditorView, command: Command) => {
97
+ command(view.state, view.dispatch, view);
98
+ view.focus();
99
+ });
100
+ const active = item.isActive?.(state) ?? false;
101
+ const enabled = item.command(state); // dry run: whether the command applies
102
+ return (
103
+ <button
104
+ type="button"
105
+ className="cms-editor__tool"
106
+ aria-pressed={active}
107
+ aria-label={item.title}
108
+ title={item.title}
109
+ disabled={!enabled}
110
+ // Keep the editor selection while clicking the toolbar.
111
+ onMouseDown={(e) => e.preventDefault()}
112
+ onClick={() => run(item.command)}
113
+ >
114
+ <item.Icon />
115
+ </button>
116
+ );
117
+ }
118
+
119
+ export function Toolbar({
120
+ schema,
121
+ readOnly,
122
+ expandable,
123
+ expanded,
124
+ onToggleExpand,
125
+ }: {
126
+ schema: Schema;
127
+ readOnly: boolean;
128
+ expandable: boolean;
129
+ expanded: boolean;
130
+ onToggleExpand: () => void;
131
+ }) {
132
+ const groups = useMemo(() => buildGroups(schema), [schema]);
133
+ return (
134
+ <div className="cms-editor__toolbar" role="toolbar" aria-label="Formatting">
135
+ {!readOnly &&
136
+ groups.map((group, gi) => (
137
+ <React.Fragment key={gi}>
138
+ {gi > 0 ? <span className="cms-editor__toolbar-sep" /> : null}
139
+ {group.map((item) => (
140
+ <ToolButton key={item.key} item={item} />
141
+ ))}
142
+ </React.Fragment>
143
+ ))}
144
+ {expandable ? (
145
+ <button
146
+ type="button"
147
+ className="cms-editor__tool cms-editor__tool--expand"
148
+ onClick={onToggleExpand}
149
+ aria-label={expanded ? "Exit focus mode" : "Expand to focus mode"}
150
+ title={expanded ? "Exit focus mode (Esc)" : "Focus mode"}
151
+ >
152
+ <ExpandIcon expanded={expanded} />
153
+ </button>
154
+ ) : null}
155
+ </div>
156
+ );
157
+ }
@@ -0,0 +1,67 @@
1
+ import React from "react";
2
+ import MarkdownEditor from "./MarkdownEditor";
3
+ import type { EditorTheme, MarkdownFlavor } from "./types";
4
+
5
+ // Minimal shape of a field-slot argument — intentionally structural so this
6
+ // package stays decoupled from @gogitcms/design-system. It is assignable from the
7
+ // design-system's richer FieldSlotArgs.
8
+ export type MarkdownFieldArgs = {
9
+ field: { component?: string; format?: string; flavor?: string };
10
+ value: unknown;
11
+ onChange: (value: string) => void;
12
+ // When true, render the editor non-editable (host is in read-only mode).
13
+ readOnly?: boolean;
14
+ // Live collaboration handle (Yjs Doc/Awareness are opaque here) + the field's
15
+ // dotted path. When present, the body binds to the shared CRDT fragment
16
+ // "body:<path>" and publishes presence via setFocus.
17
+ collab?: {
18
+ ydoc: unknown;
19
+ awareness: unknown;
20
+ setFocus(path: string | null): void;
21
+ synced?(): boolean;
22
+ subscribeSynced?(cb: () => void): () => void;
23
+ };
24
+ path?: string;
25
+ };
26
+
27
+ export type MarkdownFieldOptions = {
28
+ theme?: EditorTheme;
29
+ defaultFlavor?: MarkdownFlavor;
30
+ };
31
+
32
+ // Builds a `renderField`-compatible function that renders the markdown editor
33
+ // for `component: "body"` (markdown) fields and defers everything else back to
34
+ // the host's default control. Wire it into <ContentBrowser renderField={…}> in
35
+ // the web and native apps.
36
+ export function createMarkdownRenderField(options: MarkdownFieldOptions = {}) {
37
+ const { theme = "light", defaultFlavor = "gfm" } = options;
38
+ return ({ field, value, onChange, readOnly, collab, path }: MarkdownFieldArgs): React.ReactNode => {
39
+ if (field.component !== "body") return undefined;
40
+ if (field.format && field.format !== "markdown") return undefined;
41
+ const flavor = (field.flavor as MarkdownFlavor) || defaultFlavor;
42
+ // In a live session the body binds to a shared Y.XmlFragment keyed by the
43
+ // field path and reports focus for presence.
44
+ const collabProp =
45
+ collab && path
46
+ ? {
47
+ ydoc: collab.ydoc,
48
+ awareness: collab.awareness,
49
+ fragmentName: `body:${path}`,
50
+ onFocusChange: (focused: boolean) => collab.setFocus(focused ? path : null),
51
+ synced: collab.synced?.bind(collab),
52
+ subscribeSynced: collab.subscribeSynced?.bind(collab),
53
+ }
54
+ : undefined;
55
+ return (
56
+ <MarkdownEditor
57
+ value={value == null ? "" : String(value)}
58
+ onChange={onChange}
59
+ flavor={flavor}
60
+ theme={theme}
61
+ readOnly={readOnly}
62
+ dom={{ matchContents: true }}
63
+ collab={collabProp}
64
+ />
65
+ );
66
+ };
67
+ }
@@ -0,0 +1,27 @@
1
+ import {
2
+ defaultMarkdownParser,
3
+ defaultMarkdownSerializer,
4
+ schema as markdownSchema,
5
+ } from "prosemirror-markdown";
6
+ import type { Node as PMNode } from "prosemirror-model";
7
+ import type { Plugin } from "prosemirror-state";
8
+ import type { Flavor } from "../types";
9
+ import { basePlugins, type EditorCollabBinding } from "../shared/plugins";
10
+
11
+ // CommonMark: the stock prosemirror-markdown schema, parser, and serializer.
12
+ export const commonmark: Flavor = {
13
+ id: "commonmark",
14
+ label: "CommonMark",
15
+ schema: markdownSchema,
16
+ parse(markdown: string): PMNode {
17
+ // The parser returns null for input it cannot handle; fall back to an empty
18
+ // document so the editor always has a valid doc to render.
19
+ return defaultMarkdownParser.parse(markdown) ?? markdownSchema.topNodeType.createAndFill()!;
20
+ },
21
+ serialize(doc: PMNode): string {
22
+ return defaultMarkdownSerializer.serialize(doc);
23
+ },
24
+ plugins(collab?: EditorCollabBinding): Plugin[] {
25
+ return basePlugins(markdownSchema, [], collab);
26
+ },
27
+ };
@@ -0,0 +1,30 @@
1
+ import { columnResizing, tableEditing } from "prosemirror-tables";
2
+ import type { Node as PMNode } from "prosemirror-model";
3
+ import type { Plugin } from "prosemirror-state";
4
+ import type { Flavor } from "../types";
5
+ import { basePlugins, type EditorCollabBinding } from "../shared/plugins";
6
+ import { gfmSchema } from "./schema";
7
+ import { parseGfm } from "./parser";
8
+ import { serializeGfm } from "./serializer";
9
+ import { taskListPlugin } from "./taskList";
10
+
11
+ // GitHub Flavored Markdown: CommonMark plus strikethrough, tables, autolinks,
12
+ // and task lists.
13
+ export const gfm: Flavor = {
14
+ id: "gfm",
15
+ label: "GitHub Flavored Markdown",
16
+ schema: gfmSchema,
17
+ parse(markdown: string): PMNode {
18
+ return parseGfm(markdown);
19
+ },
20
+ serialize(doc: PMNode): string {
21
+ return serializeGfm(doc);
22
+ },
23
+ plugins(collab?: EditorCollabBinding): Plugin[] {
24
+ return basePlugins(gfmSchema, [
25
+ columnResizing(),
26
+ tableEditing(),
27
+ taskListPlugin(),
28
+ ], collab);
29
+ },
30
+ };
@@ -0,0 +1,73 @@
1
+ import MarkdownIt from "markdown-it";
2
+ import type Token from "markdown-it/lib/token.mjs";
3
+ import { MarkdownParser, defaultMarkdownParser } from "prosemirror-markdown";
4
+ import type { Node as PMNode } from "prosemirror-model";
5
+ import { gfmSchema } from "./schema";
6
+
7
+ // A markdown-it configured for GFM: tables and strikethrough are on by default;
8
+ // linkify turns bare URLs into autolinks. HTML is disabled (rendered as text).
9
+ const md = MarkdownIt({ html: false, linkify: true });
10
+
11
+ // Detect GFM task-list items (`- [ ] …` / `- [x] …`). markdown-it has no native
12
+ // support, so a small core rule stamps `checked` onto the list_item_open token
13
+ // and strips the `[ ] ` marker from the item's first inline run.
14
+ md.core.ruler.after("inline", "gfm-task-lists", (state) => {
15
+ const tokens = state.tokens;
16
+ for (let i = 0; i < tokens.length; i++) {
17
+ if (tokens[i].type !== "list_item_open") continue;
18
+ for (let j = i + 1; j < tokens.length; j++) {
19
+ const t = tokens[j];
20
+ if (t.type === "list_item_close") break;
21
+ if (t.type === "paragraph_open" || t.type === "paragraph_close") continue;
22
+ if (t.type !== "inline") break;
23
+ const m = /^\[([ xX])\]\s+/.exec(t.content);
24
+ if (m) {
25
+ tokens[i].meta = { ...(tokens[i].meta || {}), task: true, checked: m[1] !== " " };
26
+ t.content = t.content.slice(m[0].length);
27
+ const child = t.children && t.children[0];
28
+ if (child && child.type === "text") {
29
+ child.content = child.content.replace(/^\[([ xX])\]\s+/, "");
30
+ }
31
+ }
32
+ break;
33
+ }
34
+ }
35
+ return false;
36
+ });
37
+
38
+ // prosemirror-tables models `table > table_row > cell`, with no thead/tbody
39
+ // wrappers. Drop those structural tokens so the rows become direct children.
40
+ const tokenizer = {
41
+ parse(src: string, env: unknown): Token[] {
42
+ return md
43
+ .parse(src, env)
44
+ .filter(
45
+ (t) =>
46
+ t.type !== "thead_open" &&
47
+ t.type !== "thead_close" &&
48
+ t.type !== "tbody_open" &&
49
+ t.type !== "tbody_close",
50
+ );
51
+ },
52
+ };
53
+
54
+ const tokens = {
55
+ ...defaultMarkdownParser.tokens,
56
+ s: { mark: "strikethrough" },
57
+ table: { block: "table" },
58
+ tr: { block: "table_row" },
59
+ th: { block: "table_header" },
60
+ td: { block: "table_cell" },
61
+ list_item: {
62
+ block: "list_item",
63
+ getAttrs: (tok: Token) => ({
64
+ checked: tok.meta && tok.meta.task ? Boolean(tok.meta.checked) : null,
65
+ }),
66
+ },
67
+ };
68
+
69
+ const parser = new MarkdownParser(gfmSchema, tokenizer as unknown as MarkdownIt, tokens);
70
+
71
+ export function parseGfm(markdown: string): PMNode {
72
+ return parser.parse(markdown) ?? gfmSchema.topNodeType.createAndFill()!;
73
+ }
@@ -0,0 +1,70 @@
1
+ import { schema as base } from "prosemirror-markdown";
2
+ import { Schema, type DOMOutputSpec, type Node as PMNode } from "prosemirror-model";
3
+ import { tableNodes } from "prosemirror-tables";
4
+
5
+ // GFM schema = the CommonMark (prosemirror-markdown) schema extended with:
6
+ // - a `strikethrough` mark (`~~text~~`)
7
+ // - GitHub pipe tables (via prosemirror-tables)
8
+ // - task-list items: `list_item` gains a `checked` attr (null = plain item,
9
+ // true/false = a `[x]`/`[ ]` task) rendered with a real checkbox.
10
+
11
+ const tNodes = tableNodes({
12
+ tableGroup: "block",
13
+ // GFM table cells only hold inline content, so cells are simple textblocks.
14
+ cellContent: "inline*",
15
+ cellAttributes: {},
16
+ });
17
+
18
+ // Extend list_item with the task `checked` attribute + checkbox rendering.
19
+ const baseListItem = base.spec.nodes.get("list_item")!;
20
+ const listItem = {
21
+ ...baseListItem,
22
+ attrs: { checked: { default: null as boolean | null } },
23
+ parseDOM: [
24
+ {
25
+ tag: "li",
26
+ getAttrs: (dom: HTMLElement) => {
27
+ const raw = dom.getAttribute("data-checked");
28
+ return { checked: raw == null ? null : raw === "true" };
29
+ },
30
+ },
31
+ ],
32
+ toDOM(node: PMNode): DOMOutputSpec {
33
+ const checked = node.attrs.checked as boolean | null;
34
+ if (checked === null) return ["li", 0];
35
+ // A span (not a real <input>) styled as a checkbox: react-prosemirror
36
+ // renders toDOM output through React, and a controlled <input checked> with
37
+ // no onChange trips React's form-field warning. The taskListPlugin toggles
38
+ // the node's `checked` attr on click.
39
+ return [
40
+ "li",
41
+ { class: "task-list-item", "data-checked": String(checked) },
42
+ [
43
+ "span",
44
+ {
45
+ class: "task-list-item-checkbox",
46
+ contenteditable: "false",
47
+ role: "checkbox",
48
+ "aria-checked": String(checked),
49
+ },
50
+ ],
51
+ ["div", { class: "task-list-item-content" }, 0],
52
+ ];
53
+ },
54
+ };
55
+
56
+ const nodes = base.spec.nodes.update("list_item", listItem).append(tNodes);
57
+
58
+ const marks = base.spec.marks.addToEnd("strikethrough", {
59
+ parseDOM: [
60
+ { tag: "s" },
61
+ { tag: "del" },
62
+ { tag: "strike" },
63
+ { style: "text-decoration=line-through" },
64
+ ],
65
+ toDOM(): DOMOutputSpec {
66
+ return ["s", 0];
67
+ },
68
+ });
69
+
70
+ export const gfmSchema = new Schema({ nodes, marks });