@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,230 @@
1
+ // The declarative plugin API. A plugin is an npm package whose entrypoint
2
+ // default-exports `setup(cms, options)`; every registration made through `cms`
3
+ // is scoped to that plugin (see registry.ts). Docs: docs/plugin-system.md.
4
+
5
+ import type { ComponentType } from "react";
6
+ import type { CollabApi, EntryField, IconName } from "@gogitcms/design-system";
7
+
8
+ // A component contribution is always the loader form — `() => import("./X")`
9
+ // or `async () => ({ default: X })` for an already-loaded component. A direct
10
+ // function component would be indistinguishable from a loader at runtime, so
11
+ // the loader form is the only accepted shape; it is also what makes each
12
+ // screen its own lazy chunk under a bundler.
13
+ export type PluginComponentLoader<P> = () => Promise<{ default: ComponentType<P> }>;
14
+
15
+ // Field types a plugin field component may attach to. Mirrors the schema's
16
+ // closed `type` set (packages/importer/schema).
17
+ export type PluginFieldType = "string" | "boolean" | "integer" | "float" | "array" | "object";
18
+
19
+ /**
20
+ * A document as a plugin sees it — the editor's in-flight state, not the saved
21
+ * row. `fields` is the whole document rather than a diff, because a plugin that
22
+ * renders a preview needs every value, not only the changed ones.
23
+ */
24
+ export type PluginDocument = {
25
+ id: string;
26
+ collection: string;
27
+ /** Repo-relative path, e.g. "content/blog/hello.md". */
28
+ path: string;
29
+ fields: Record<string, unknown>;
30
+ body?: string;
31
+ /** The field name the body is exposed as, when the schema declares one. */
32
+ bodyField?: string;
33
+ };
34
+
35
+ /**
36
+ * Extra props a route gets when it is rendered as a *tab* beside the editor
37
+ * rather than as a full screen at its URL.
38
+ *
39
+ * Absent on a screen. A route can serve both — it just checks whether this is
40
+ * there — which is what makes "open this route in a tab" a property of how the
41
+ * route was opened rather than a second kind of registration.
42
+ */
43
+ export type PluginTabContext = {
44
+ /** The tab's id, as passed to openTab. */
45
+ id: string;
46
+ /** The document that was open when the tab was opened, or null. */
47
+ document: PluginDocument | null;
48
+ /**
49
+ * Subscribe to that document's in-flight edits. The host debounces before
50
+ * calling (~150 ms), so a subscriber does not need its own timer. Returns an
51
+ * unsubscribe function.
52
+ */
53
+ onDraft: (cb: (doc: PluginDocument) => void) => () => void;
54
+ /** Open a document in the editor — the inverse direction, for click-to-edit. */
55
+ openDocument: (id: string, field?: string) => void;
56
+ /** Close this tab. */
57
+ close: () => void;
58
+ };
59
+
60
+ /** Props handed to a plugin route's screen. */
61
+ export type PluginScreenProps = {
62
+ workspaceId: string;
63
+ repositoryId: string;
64
+ branchId?: string;
65
+ /** Git ref name of the branch, when the host knows it. */
66
+ ref?: string;
67
+ /** Matched `:segment` params from the route path, plus any openTab params. */
68
+ params: Record<string, string>;
69
+ /** Navigate to another of this plugin's routes (a registered route path). */
70
+ navigate: (to: string) => void;
71
+ /** Base URL of the CMS API, for authenticated fetches. */
72
+ apiUrl: string;
73
+ /** Set only when this route is rendered as a tab (see PluginTabContext). */
74
+ tab?: PluginTabContext;
75
+ };
76
+
77
+ /**
78
+ * Props handed to a plugin field component — the field's data as one object.
79
+ * This is deliberately the design-system's FieldSlotArgs shape (the
80
+ * `renderField` seam), so plugin fields plug in exactly where the ProseMirror
81
+ * body editor already does.
82
+ */
83
+ export type PluginFieldProps = {
84
+ /** The full schema field: name, label, type, constraints, nested fields. */
85
+ field: EntryField;
86
+ value: unknown;
87
+ onChange: (value: unknown) => void;
88
+ readOnly?: boolean;
89
+ /** Dotted path of the field within the document (present under collab). */
90
+ path?: string;
91
+ /** Live-collaboration binding when a collab session is active. */
92
+ collab?: CollabApi;
93
+ };
94
+
95
+ export type PluginRoute = {
96
+ /** Relative path under the plugin's mount, e.g. "dashboard" or "reports/:kind". */
97
+ path: string;
98
+ title?: string;
99
+ component: PluginComponentLoader<PluginScreenProps>;
100
+ };
101
+
102
+ export type PluginSidebarLink = {
103
+ label: string;
104
+ icon?: IconName;
105
+ /** One of this plugin's route paths. */
106
+ to: string;
107
+ /** Sidebar section title to group under. Default: "Plugins". */
108
+ section?: string;
109
+ };
110
+
111
+ export type PluginUserMenuLink = {
112
+ label: string;
113
+ icon?: IconName;
114
+ /** One of this plugin's route paths… */
115
+ to?: string;
116
+ /** …or an action. Exactly one of `to`/`onSelect` is required. */
117
+ onSelect?: () => void;
118
+ };
119
+
120
+ /** Where a plugin asks the host to open one of its routes as a tab. */
121
+ export type PluginTabSpec = {
122
+ /**
123
+ * Stable tab id. Opening the same id again activates the existing tab rather
124
+ * than stacking duplicates — so pressing Preview twice on one document does
125
+ * not end up with two preview columns.
126
+ */
127
+ id: string;
128
+ /** Column header label. */
129
+ title: string;
130
+ /** One of this plugin's registered route paths. */
131
+ route: string;
132
+ /** Merged into the route component's `params`. */
133
+ params?: Record<string, string>;
134
+ };
135
+
136
+ /** What a document action is handed when the user presses its button. */
137
+ export type PluginDocumentActionContext = {
138
+ workspaceId: string;
139
+ repositoryId: string;
140
+ branchId?: string;
141
+ ref?: string;
142
+ apiUrl: string;
143
+ /** The document whose toolbar the button was pressed in. */
144
+ document: PluginDocument;
145
+ /** Open one of this plugin's routes as a column tab. */
146
+ openTab: (spec: PluginTabSpec) => void;
147
+ /** Open a document in the editor. */
148
+ openDocument: (id: string, field?: string) => void;
149
+ };
150
+
151
+ /**
152
+ * A button in a document's toolbar.
153
+ *
154
+ * `available` is what keeps the toolbar honest: a preview plugin configured for
155
+ * three of a repository's eight collections should show a Preview button on
156
+ * three of them, not eight with five that error. Returning false hides the
157
+ * button entirely rather than disabling it, because a disabled button the user
158
+ * cannot ever enable is worse than no button.
159
+ */
160
+ export type PluginDocumentAction = {
161
+ /** Stable id, unique within the plugin. */
162
+ id: string;
163
+ label: string;
164
+ icon?: IconName;
165
+ /** Restrict to some collections; omitted means all. */
166
+ collections?: string[];
167
+ /** Final say on whether the button renders for a particular document. */
168
+ available?: (doc: PluginDocument) => boolean;
169
+ onSelect: (ctx: PluginDocumentActionContext) => void | Promise<void>;
170
+ };
171
+
172
+ export type PluginFieldComponent = {
173
+ /**
174
+ * Bare component name; schemas address it as `plugin:<name>`. One global
175
+ * namespace across plugins — a collision is rejected with an error and the
176
+ * first registration wins.
177
+ */
178
+ name: string;
179
+ /** Schema field types the component accepts. */
180
+ types: PluginFieldType[];
181
+ component: PluginComponentLoader<PluginFieldProps>;
182
+ };
183
+
184
+ /** The declarative API a plugin's setup function receives. */
185
+ export interface CmsPluginApi {
186
+ /** The plugin's own id (its package name) — for display/debugging. */
187
+ readonly id: string;
188
+ /**
189
+ * Mint a short-lived credential for a preview service, scoped to one
190
+ * repository.
191
+ *
192
+ * This is how a plugin authenticates to something that trusts the CMS without
193
+ * ever holding the user's access token — that one authenticates everything
194
+ * else the CMS can do, and a plugin is third-party code by design. The token
195
+ * expires in minutes and every CMS endpoint refuses it.
196
+ *
197
+ * Rejects when the host cannot mint them (an older host, or one with no CMS
198
+ * session).
199
+ */
200
+ getPreviewToken(repositoryId: string): Promise<string>;
201
+ /** Register a screen at …/plugins/<pluginId>/<path> (repo-scoped). */
202
+ addRoute(route: PluginRoute): void;
203
+ /** Add a row to the sidebar linking to one of this plugin's routes. */
204
+ addSidebarLink(link: PluginSidebarLink): void;
205
+ /** Add an item to the user (avatar) menu. */
206
+ addUserMenuLink(link: PluginUserMenuLink): void;
207
+ /** Register an editor field component, addressed as `plugin:<name>`. */
208
+ registerFieldComponent(reg: PluginFieldComponent): void;
209
+ /**
210
+ * Add a button to the toolbar of an open document. Its handler can open one
211
+ * of this plugin's routes as a tab beside the editor — which is how a preview
212
+ * pane is built without the CMS knowing anything about previews.
213
+ */
214
+ addDocumentAction(action: PluginDocumentAction): void;
215
+ }
216
+
217
+ /** A plugin's setup function — the package entrypoint's default export. */
218
+ export type PluginSetup = (cms: CmsPluginApi, options?: unknown) => void | Promise<void>;
219
+
220
+ export type PluginStatus = "pending" | "ready" | "failed";
221
+
222
+ /** One entry to load: how `loadPlugins` reaches a plugin's module. */
223
+ export type PluginLoadEntry = {
224
+ /** The plugin id (package name). */
225
+ id: string;
226
+ /** Options forwarded as the setup function's second argument. */
227
+ options?: unknown;
228
+ /** Imports the plugin module (dynamic `import()` in both hosts). */
229
+ load: () => Promise<unknown>;
230
+ };
@@ -0,0 +1,58 @@
1
+ import { colorForId, computeSplice } from "../pure";
2
+
3
+ describe("computeSplice", () => {
4
+ // Applying the returned splice to `cur` must reproduce `next` exactly.
5
+ const apply = (cur: string, next: string) => {
6
+ const { index, deleteCount, insert } = computeSplice(cur, next);
7
+ return cur.slice(0, index) + insert + cur.slice(index + deleteCount);
8
+ };
9
+
10
+ const cases: Array<[string, string]> = [
11
+ ["", ""],
12
+ ["", "hello"],
13
+ ["hello", ""],
14
+ ["hello", "hello"],
15
+ ["hello", "hello world"], // append
16
+ ["hello world", "hello"], // truncate
17
+ ["hello", "jello"], // replace head
18
+ ["hello", "helLo"], // replace middle
19
+ ["cat", "cart"], // insert middle
20
+ ["cart", "cat"], // delete middle
21
+ ["aaaa", "aa"], // ambiguous repeats
22
+ ["The quick brown fox", "The slow brown fox"],
23
+ ];
24
+
25
+ test.each(cases)("apply(computeSplice(%j, %j)) === next", (cur, next) => {
26
+ expect(apply(cur, next)).toBe(next);
27
+ });
28
+
29
+ test("edits only the changed middle (preserves shared prefix/suffix)", () => {
30
+ // "Seeded title" -> "Seeded NEW title": prefix "Seeded ", suffix "title".
31
+ const s = computeSplice("Seeded title", "Seeded NEW title");
32
+ expect(s).toEqual({ index: 7, deleteCount: 0, insert: "NEW " });
33
+ });
34
+
35
+ test("a pure append does not delete anything", () => {
36
+ expect(computeSplice("abc", "abcdef")).toEqual({ index: 3, deleteCount: 0, insert: "def" });
37
+ });
38
+
39
+ test("a pure deletion inserts nothing", () => {
40
+ expect(computeSplice("abcdef", "abc")).toEqual({ index: 3, deleteCount: 3, insert: "" });
41
+ });
42
+ });
43
+
44
+ describe("colorForId", () => {
45
+ test("is deterministic for the same id", () => {
46
+ expect(colorForId("user-123")).toBe(colorForId("user-123"));
47
+ });
48
+
49
+ test("produces a valid hsl string", () => {
50
+ expect(colorForId("abc")).toMatch(/^hsl\(\d{1,3} 70% 45%\)$/);
51
+ });
52
+
53
+ test("varies across different ids", () => {
54
+ const colors = new Set(["a", "b", "c", "d", "e", "f", "g", "h"].map(colorForId));
55
+ // Not all identical — the hash spreads ids across hues.
56
+ expect(colors.size).toBeGreaterThan(1);
57
+ });
58
+ });
@@ -0,0 +1 @@
1
+ AQjYhJqjDwAEAQ9mOnNlby5tZXRhVGl0bGUJU0VPIHRpdGxlBAEHZjp0aXRsZQVBbHBoYSgBA3JlZwhmZWF0dXJlZAF4KAEDcmVnBWNvdW50AXxAQAAAKAEDcmVnC3Nlby5ub2luZGV4AXkoAQNyZWcGYmxvY2tzAXUCdgIIX3ZhcmlhbnR3BGhlcm8HaGVhZGluZ3cHV2VsY29tZXYCCF92YXJpYW50dwdtZXRyaWNzBWl0ZW1zdQF2AgVsYWJlbHcHRGVwbG95cwV2YWx1ZXcCMTQoAQNyZWcEdGFncwF1AncCZ293A2NtcygBBG1ldGEEYmFzZQF35AF7ImJsb2NrcyI6W3siX3ZhcmlhbnQiOiJoZXJvIiwiaGVhZGluZyI6IldlbGNvbWUifSx7Il92YXJpYW50IjoibWV0cmljcyIsIml0ZW1zIjpbeyJsYWJlbCI6IkRlcGxveXMiLCJ2YWx1ZSI6IjE0In1dfV0sImNvdW50IjozLCJmZWF0dXJlZCI6dHJ1ZSwic2VvIjp7Im1ldGFUaXRsZSI6IlNFTyB0aXRsZSIsIm5vaW5kZXgiOmZhbHNlfSwidGFncyI6WyJnbyIsImNtcyJdLCJ0aXRsZSI6IkFscGhhIn0A
@@ -0,0 +1,70 @@
1
+ // What the server seeds has to be what the editor can render.
2
+ //
3
+ // The Go tests prove the collab server converges with itself. They cannot prove
4
+ // the part the browser depends on: that Yjs decodes a seeded list into a real JS
5
+ // array of plain objects. A control that renders a list checks `Array.isArray`
6
+ // and shows nothing when that is false, which is indistinguishable — on screen —
7
+ // from a document with no items. Mixed lists sat in exactly that state, so the
8
+ // encoding gets a test rather than an assumption.
9
+ //
10
+ // The fixture is the literal update a seeded room sends a client, generated by
11
+ // packages/collab's TestFixtureForYjsDecode (WRITE_FIXTURE=1). Regenerate it
12
+ // there if the seeded shapes change.
13
+
14
+ import { readFileSync } from "fs";
15
+ import { join } from "path";
16
+ import * as Y from "yjs";
17
+
18
+ const FIELD_PREFIX = "f:";
19
+
20
+ function seededDoc(): Y.Doc {
21
+ const base64 = readFileSync(join(__dirname, "seeded-room.base64"), "utf8").trim();
22
+ const doc = new Y.Doc();
23
+ Y.applyUpdate(doc, new Uint8Array(Buffer.from(base64, "base64")));
24
+ return doc;
25
+ }
26
+
27
+ describe("a room seeded by the Go server, read through Yjs", () => {
28
+ it("gives a mixed list back as an array the editor can render", () => {
29
+ const blocks = seededDoc().getMap("reg").get("blocks");
30
+
31
+ // The check every list control makes before it renders anything.
32
+ expect(Array.isArray(blocks)).toBe(true);
33
+ expect(blocks).toEqual([
34
+ { _variant: "hero", heading: "Welcome" },
35
+ { _variant: "metrics", items: [{ label: "Deploys", value: "14" }] },
36
+ ]);
37
+ });
38
+
39
+ it("keeps a variant's own nested list nested", () => {
40
+ const blocks = seededDoc().getMap("reg").get("blocks") as Record<string, unknown>[];
41
+ const metrics = blocks[1];
42
+ // An item whose fields arrived as something other than a plain object
43
+ // renders as an empty row inside a list that looks populated.
44
+ expect(Object.getPrototypeOf(metrics)).toBe(Object.prototype);
45
+ expect(Array.isArray(metrics.items)).toBe(true);
46
+ });
47
+
48
+ it("gives a list of primitives back as an array too", () => {
49
+ expect(seededDoc().getMap("reg").get("tags")).toEqual(["go", "cms"]);
50
+ });
51
+
52
+ it("puts scalars in the register and strings in their own text", () => {
53
+ const doc = seededDoc();
54
+ const reg = doc.getMap("reg");
55
+ expect(reg.get("featured")).toBe(true);
56
+ expect(reg.get("count")).toBe(3);
57
+ expect(doc.getText(`${FIELD_PREFIX}title`).toString()).toBe("Alpha");
58
+ // Nested group leaves land at their dotted path, which is what the field
59
+ // controls bind to.
60
+ expect(doc.getText(`${FIELD_PREFIX}seo.metaTitle`).toString()).toBe("SEO title");
61
+ expect(reg.get("seo.noindex")).toBe(false);
62
+ });
63
+
64
+ it("hands out a stable reference for an unchanged value", () => {
65
+ // useSyncExternalStore re-renders until getSnapshot returns the same value
66
+ // twice; a decoder that rebuilt the array per read would spin the editor.
67
+ const reg = seededDoc().getMap("reg");
68
+ expect(reg.get("blocks")).toBe(reg.get("blocks"));
69
+ });
70
+ });
@@ -0,0 +1,308 @@
1
+ import * as Y from "yjs";
2
+ import { WebsocketProvider } from "y-websocket";
3
+ import { colorForId, computeSplice } from "./pure";
4
+
5
+ export { colorForId } from "./pure";
6
+
7
+ // A collaborator's identity, surfaced to presence UI.
8
+ export type CollabPeer = { id: string; name: string; color: string };
9
+
10
+ // A connected collaborator plus the field they are currently focused on (null
11
+ // when not editing any field). Drives the document's stacked presence avatars.
12
+ export type CollabParticipant = { id: string; name: string; color: string; focus: string | null };
13
+
14
+ // A live binding to one text field's Y.Text. Values converge across clients.
15
+ export type CollabText = {
16
+ get(): string;
17
+ set(next: string): void;
18
+ // Peers currently focused on this field (excludes the local user).
19
+ peers(): CollabPeer[];
20
+ // Fires on value change OR presence change for this field. Returns unsubscribe.
21
+ subscribe(cb: () => void): () => void;
22
+ };
23
+
24
+ // A live binding to one scalar field (select/boolean/number/date) held as a
25
+ // last-writer-wins register in the shared "reg" Y.Map.
26
+ export type CollabRegister = {
27
+ get(): unknown;
28
+ set(next: unknown): void;
29
+ subscribe(cb: () => void): () => void;
30
+ };
31
+
32
+ export type CollabStatus = "connecting" | "connected" | "disconnected";
33
+
34
+ export type CollabUser = { id: string; name: string; color?: string };
35
+
36
+ // The per-document collaboration handle. Structurally matches design-system's
37
+ // CollabApi so it can be handed to <ContentBrowser> without either package
38
+ // importing the other.
39
+ export type DocCollab = {
40
+ text(path: string): CollabText;
41
+ register(path: string): CollabRegister;
42
+ // Announce/clear which field this user is editing (null = not focused).
43
+ setFocus(path: string | null): void;
44
+ // Presence for any field path (text or not), excluding the local user.
45
+ peersAt(path: string): CollabPeer[];
46
+ // Presence anywhere under a group path ("seo" matches focus "seo.title"),
47
+ // used to highlight a collapsed group whose child a peer is editing.
48
+ peersUnder(prefix: string): CollabPeer[];
49
+ // Every connected collaborator (excluding self), for the document's stacked
50
+ // presence avatars, each with the field they are currently editing.
51
+ participants(): CollabParticipant[];
52
+ subscribeAwareness(cb: () => void): () => void;
53
+ // Monotonic counter bumped on every awareness change, for tick-based presence
54
+ // subscriptions (useSyncExternalStore) that then read peersAt/peersUnder.
55
+ awarenessVersion(): number;
56
+ status(): CollabStatus;
57
+ subscribeStatus(cb: () => void): () => void;
58
+ // Whether the provider has completed its initial sync with the server room.
59
+ // Distinct from status(): a socket is "connected" before the first sync-step
60
+ // exchange finishes, and anything that reads the shared doc to decide
61
+ // something (e.g. "is the body fragment empty?") must wait for this.
62
+ synced(): boolean;
63
+ subscribeSynced(cb: () => void): () => void;
64
+ // Opaque handles the body (ProseMirror) editor binds to in a later phase.
65
+ readonly ydoc: Y.Doc;
66
+ readonly awareness: WebsocketProvider["awareness"];
67
+ destroy(): void;
68
+ };
69
+
70
+ export type CreateDocCollabOptions = {
71
+ // Base WebSocket URL of the collab endpoint, e.g. "wss://host/collab/ws".
72
+ wsBase: string;
73
+ // Room = "<repositoryId>_<documentId>".
74
+ room: string;
75
+ user: CollabUser;
76
+ // Returns the current access token; called on every (re)connect so a refreshed
77
+ // token is used. The token rides the WebSocket subprotocol, never the URL.
78
+ getToken: () => string;
79
+ };
80
+
81
+ const FIELD_PREFIX = "f:";
82
+
83
+ // Shallow equality of two peer lists (by id set, order-insensitive) so cached
84
+ // snapshots keep a stable reference across awareness events with no real change.
85
+ function samePeers(a: CollabPeer[], b: CollabPeer[]): boolean {
86
+ if (a.length !== b.length) return false;
87
+ const ids = new Set(a.map((p) => p.id));
88
+ return b.every((p) => ids.has(p.id));
89
+ }
90
+
91
+ // createDocCollab wires a Yjs doc to the collab server for one document.
92
+ export function createDocCollab(opts: CreateDocCollabOptions): DocCollab {
93
+ const color = opts.user.color ?? colorForId(opts.user.id);
94
+ const ydoc = new Y.Doc();
95
+
96
+ // Authenticate via a query-string token, the standard y-websocket pattern.
97
+ // (A subprotocol token was tried first, but Chrome resets a connection when
98
+ // the client offers a subprotocol the server does not echo, and ygo's
99
+ // upgrader cannot be made to echo one.) The token is short-lived and the
100
+ // server's request logger records only the path, never the query string, so
101
+ // it is not leaked to logs. `params` is a live reference: refreshing it before
102
+ // a reconnect lets the socket pick up a newer token.
103
+ const params: Record<string, string> = { token: opts.getToken() };
104
+ if (typeof console !== "undefined") {
105
+ console.log(`[collab ${opts.room}] connecting to ${opts.wsBase}/${opts.room}`, {
106
+ hasToken: !!params.token,
107
+ });
108
+ }
109
+ const provider = new WebsocketProvider(opts.wsBase, opts.room, ydoc, { params });
110
+ const awareness = provider.awareness;
111
+ awareness.setLocalStateField("user", { id: opts.user.id, name: opts.user.name, color });
112
+ awareness.setLocalStateField("focus", null);
113
+
114
+ // ---- presence snapshots (stable refs for useSyncExternalStore) ----
115
+ let peerBuckets = new Map<string, CollabPeer[]>();
116
+ const awarenessListeners = new Set<() => void>();
117
+ const recomputePeers = () => {
118
+ const next = new Map<string, CollabPeer[]>();
119
+ awareness.getStates().forEach((state, clientId) => {
120
+ if (clientId === awareness.clientID) return; // exclude self
121
+ const focus = state?.focus as string | null | undefined;
122
+ const user = state?.user as CollabPeer | undefined;
123
+ if (!focus || !user) return;
124
+ const list = next.get(focus) ?? [];
125
+ list.push(user);
126
+ next.set(focus, list);
127
+ });
128
+ // Preserve array references where the peer set is unchanged.
129
+ const merged = new Map<string, CollabPeer[]>();
130
+ const paths = new Set([...peerBuckets.keys(), ...next.keys()]);
131
+ paths.forEach((path) => {
132
+ const prev = peerBuckets.get(path) ?? [];
133
+ const cur = next.get(path) ?? [];
134
+ merged.set(path, samePeers(prev, cur) ? prev : cur);
135
+ });
136
+ peerBuckets = merged;
137
+ };
138
+ let awarenessVer = 0;
139
+ recomputePeers();
140
+ awareness.on("change", () => {
141
+ recomputePeers();
142
+ awarenessVer++;
143
+ awarenessListeners.forEach((cb) => cb());
144
+ });
145
+ const emptyPeers: CollabPeer[] = [];
146
+
147
+ // peersUnder returns every peer whose focus is the prefix itself or nested
148
+ // beneath it ("seo" matches "seo.title"), deduped by user id.
149
+ const peersUnder = (prefix: string): CollabPeer[] => {
150
+ const seen = new Set<string>();
151
+ const out: CollabPeer[] = [];
152
+ peerBuckets.forEach((peers, focus) => {
153
+ if (focus === prefix || focus.startsWith(prefix + ".")) {
154
+ for (const p of peers) {
155
+ if (!seen.has(p.id)) {
156
+ seen.add(p.id);
157
+ out.push(p);
158
+ }
159
+ }
160
+ }
161
+ });
162
+ return out;
163
+ };
164
+
165
+ // ---- connection status ----
166
+ let status: CollabStatus = "connecting";
167
+ const statusListeners = new Set<() => void>();
168
+ const tag = `[collab ${opts.room}]`;
169
+ provider.on("status", (e: { status: "connecting" | "connected" | "disconnected" }) => {
170
+ status = e.status;
171
+ // Refresh the query token before the next connect so a reconnect after a
172
+ // token rotation uses the current one.
173
+ if (e.status !== "connected") params.token = opts.getToken();
174
+ if (typeof console !== "undefined") console.debug(`${tag} status: ${e.status}`);
175
+ statusListeners.forEach((cb) => cb());
176
+ });
177
+ const syncListeners = new Set<() => void>();
178
+ provider.on("sync", (isSynced: boolean) => {
179
+ if (typeof console !== "undefined") console.debug(`${tag} synced: ${isSynced}`);
180
+ syncListeners.forEach((cb) => cb());
181
+ });
182
+ // Surface handshake rejections (e.g. a blocked cross-origin upgrade or an
183
+ // invalid token) that otherwise look like a silent "connected but no sync".
184
+ provider.on("connection-close", (event: CloseEvent | null) => {
185
+ if (event && (event.code !== 1000 || event.reason) && typeof console !== "undefined") {
186
+ console.warn(`${tag} connection closed: code=${event.code} reason=${event.reason || "(none)"}`);
187
+ }
188
+ });
189
+ provider.on("connection-error", (event: Event) => {
190
+ if (typeof console !== "undefined") console.warn(`${tag} connection error`, event);
191
+ });
192
+ // One-shot probe: report the raw socket state a few seconds in, so a hung
193
+ // upgrade (readyState stays 0 = CONNECTING) is distinguishable from a silent
194
+ // close (3 = CLOSED) or a working connection (1 = OPEN).
195
+ setTimeout(() => {
196
+ if (typeof console === "undefined") return;
197
+ const p = provider as unknown as { ws?: WebSocket | null; wsconnected?: boolean };
198
+ const rs = p.ws ? p.ws.readyState : "no-socket";
199
+ const names: Record<number, string> = { 0: "CONNECTING", 1: "OPEN", 2: "CLOSING", 3: "CLOSED" };
200
+ console.log(`${tag} probe`, {
201
+ readyState: typeof rs === "number" ? `${rs} (${names[rs]})` : rs,
202
+ wsconnected: p.wsconnected,
203
+ synced: provider.synced,
204
+ });
205
+ }, 3000);
206
+
207
+ // ---- text bindings (memoized per path) ----
208
+ const textCache = new Map<string, CollabText>();
209
+ const textBinding = (path: string): CollabText => {
210
+ const existing = textCache.get(path);
211
+ if (existing) return existing;
212
+ const ytext = ydoc.getText(FIELD_PREFIX + path);
213
+ const binding: CollabText = {
214
+ get: () => ytext.toString(),
215
+ set: (next: string) => {
216
+ const cur = ytext.toString();
217
+ if (cur === next) return;
218
+ // Minimal splice: replace only the changed middle so unaffected text
219
+ // (and other clients' concurrent edits there) survive.
220
+ const { index, deleteCount, insert } = computeSplice(cur, next);
221
+ ydoc.transact(() => {
222
+ if (deleteCount > 0) ytext.delete(index, deleteCount);
223
+ if (insert) ytext.insert(index, insert);
224
+ });
225
+ },
226
+ peers: () => peerBuckets.get(path) ?? emptyPeers,
227
+ subscribe: (cb: () => void) => {
228
+ const onText = () => cb();
229
+ ytext.observe(onText);
230
+ awarenessListeners.add(cb);
231
+ return () => {
232
+ ytext.unobserve(onText);
233
+ awarenessListeners.delete(cb);
234
+ };
235
+ },
236
+ };
237
+ textCache.set(path, binding);
238
+ return binding;
239
+ };
240
+
241
+ // ---- register bindings (memoized per path) ----
242
+ const reg = ydoc.getMap("reg");
243
+ const regCache = new Map<string, CollabRegister>();
244
+ const registerBinding = (path: string): CollabRegister => {
245
+ const existing = regCache.get(path);
246
+ if (existing) return existing;
247
+ const binding: CollabRegister = {
248
+ get: () => reg.get(path),
249
+ set: (next: unknown) => {
250
+ if (reg.get(path) === next) return;
251
+ reg.set(path, next);
252
+ },
253
+ subscribe: (cb: () => void) => {
254
+ // Y.Map observe fires for any key; the component re-reads its own key.
255
+ const onChange = () => cb();
256
+ reg.observe(onChange);
257
+ return () => reg.unobserve(onChange);
258
+ },
259
+ };
260
+ regCache.set(path, binding);
261
+ return binding;
262
+ };
263
+
264
+ return {
265
+ text: textBinding,
266
+ register: registerBinding,
267
+ setFocus: (path: string | null) => awareness.setLocalStateField("focus", path),
268
+ peersAt: (path: string) => peerBuckets.get(path) ?? emptyPeers,
269
+ peersUnder,
270
+ participants: () => {
271
+ const byId = new Map<string, CollabParticipant>();
272
+ awareness.getStates().forEach((state, clientId) => {
273
+ if (clientId === awareness.clientID) return; // exclude self
274
+ const user = state?.user as CollabPeer | undefined;
275
+ if (!user) return;
276
+ const focus = (state?.focus as string | null | undefined) ?? null;
277
+ const existing = byId.get(user.id);
278
+ // Dedup by user id (multiple tabs); prefer an entry that has a focus.
279
+ if (!existing || (!existing.focus && focus)) {
280
+ byId.set(user.id, { id: user.id, name: user.name, color: user.color, focus });
281
+ }
282
+ });
283
+ return [...byId.values()];
284
+ },
285
+ awarenessVersion: () => awarenessVer,
286
+ subscribeAwareness: (cb: () => void) => {
287
+ awarenessListeners.add(cb);
288
+ return () => awarenessListeners.delete(cb);
289
+ },
290
+ status: () => status,
291
+ subscribeStatus: (cb: () => void) => {
292
+ statusListeners.add(cb);
293
+ return () => statusListeners.delete(cb);
294
+ },
295
+ synced: () => provider.synced,
296
+ subscribeSynced: (cb: () => void) => {
297
+ syncListeners.add(cb);
298
+ return () => syncListeners.delete(cb);
299
+ },
300
+ ydoc,
301
+ awareness,
302
+ destroy: () => {
303
+ awareness.setLocalState(null);
304
+ provider.destroy();
305
+ ydoc.destroy();
306
+ },
307
+ };
308
+ }