@tangle-network/ui 11.5.0 → 11.7.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 (66) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +70 -2
  3. package/dist/chat.d.ts +18 -8
  4. package/dist/chat.js +6 -5
  5. package/dist/{chunk-XROQW5J7.js → chunk-4ENFLPWQ.js} +1 -1
  6. package/dist/{chunk-XPYPZMYT.js → chunk-6RIX7M27.js} +1 -1
  7. package/dist/{chunk-R5GVQDL3.js → chunk-CVX53NCC.js} +7 -5
  8. package/dist/{chunk-ULDNFLIM.js → chunk-EIUE5GV2.js} +10 -14
  9. package/dist/chunk-L57WRIND.js +59 -0
  10. package/dist/chunk-LFVF2HFB.js +19 -0
  11. package/dist/{chunk-7O22XQ4D.js → chunk-PLWRAS5Z.js} +1 -1
  12. package/dist/{chunk-NZLLDHEG.js → chunk-QGTIBEPX.js} +608 -420
  13. package/dist/{chunk-AZWDI2JG.js → chunk-UYTRNR7Z.js} +1 -1
  14. package/dist/{chunk-RKC3GXHE.js → chunk-VDCZHOOO.js} +99 -104
  15. package/dist/{chunk-J2G7KCYH.js → chunk-XT4JMYVB.js} +109 -67
  16. package/dist/{document-editor-pane-MX3FIZSE.js → document-editor-pane-A3JURPW5.js} +1 -1
  17. package/dist/{document-editor-pane-C3cYEDWZ.d.ts → document-editor-pane-IAEQn_Dl.d.ts} +8 -4
  18. package/dist/editor.d.ts +5 -4
  19. package/dist/editor.js +1 -1
  20. package/dist/files.d.ts +1 -1
  21. package/dist/files.js +1 -1
  22. package/dist/hooks.js +6 -5
  23. package/dist/index.d.ts +2 -2
  24. package/dist/index.js +16 -11
  25. package/dist/primitives.d.ts +15 -1
  26. package/dist/primitives.js +5 -1
  27. package/dist/run.d.ts +22 -17
  28. package/dist/run.js +8 -6
  29. package/dist/sdk-hooks.js +6 -5
  30. package/dist/utils.d.ts +6 -0
  31. package/dist/utils.js +1 -1
  32. package/package.json +1 -1
  33. package/src/chat/agent-timeline.stories.tsx +163 -10
  34. package/src/chat/agent-timeline.test.tsx +114 -7
  35. package/src/chat/agent-timeline.tsx +120 -158
  36. package/src/chat/chat-container.test.tsx +59 -0
  37. package/src/chat/user-message.test.tsx +33 -0
  38. package/src/chat/user-message.tsx +13 -8
  39. package/src/editor/document-editor-pane.test.tsx +58 -0
  40. package/src/editor/editor-lazy.test.tsx +102 -0
  41. package/src/editor/editor-lazy.ts +40 -0
  42. package/src/editor/editor-loading.tsx +25 -0
  43. package/src/editor/editor-peers.test.ts +250 -0
  44. package/src/editor/editor-peers.ts +272 -0
  45. package/src/editor/editor-provider.test.tsx +226 -0
  46. package/src/editor/editor-provider.tsx +301 -265
  47. package/src/editor/editor-toolbar.tsx +1 -1
  48. package/src/editor/markdown-document-editor.test.tsx +121 -0
  49. package/src/editor/markdown-document-editor.tsx +114 -82
  50. package/src/editor/tiptap-editor.test.tsx +228 -0
  51. package/src/editor/tiptap-editor.tsx +162 -130
  52. package/src/primitives/index.ts +3 -0
  53. package/src/primitives/text-shimmer.test.tsx +34 -0
  54. package/src/primitives/text-shimmer.tsx +82 -0
  55. package/src/run/inline-thinking-item.stories.tsx +2 -2
  56. package/src/run/inline-thinking-item.tsx +2 -2
  57. package/src/run/inline-tool-item.test.tsx +76 -0
  58. package/src/run/inline-tool-item.tsx +9 -7
  59. package/src/run/run-group.tsx +4 -3
  60. package/src/run/run-row-shell.test.tsx +74 -4
  61. package/src/run/run-row-shell.tsx +81 -53
  62. package/src/run/tool-call-step.tsx +9 -7
  63. package/src/utils/tool-display.test.ts +53 -0
  64. package/src/utils/tool-display.ts +16 -15
  65. package/dist/chunk-LQS34IGP.js +0 -0
  66. /package/dist/{chunk-EJ5NBW7V.js → chunk-LDLOJKPC.js} +0 -0
@@ -0,0 +1,102 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { Component, type ReactNode, Suspense } from "react";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { retryableLazyEditor } from "./editor-lazy";
5
+ import { MissingEditorPeersError } from "./editor-peers";
6
+
7
+ /** Catches the rejection the lazy component throws, and can mount it again. */
8
+ class ResettableBoundary extends Component<
9
+ { children: ReactNode },
10
+ { failure: Error | null }
11
+ > {
12
+ state = { failure: null as Error | null };
13
+
14
+ static getDerivedStateFromError(error: Error) {
15
+ return { failure: error };
16
+ }
17
+
18
+ render() {
19
+ if (this.state.failure) {
20
+ return (
21
+ <button type="button" onClick={() => this.setState({ failure: null })}>
22
+ failed: {this.state.failure.message}
23
+ </button>
24
+ );
25
+ }
26
+ return this.props.children;
27
+ }
28
+ }
29
+
30
+ function Loaded() {
31
+ return <span>editor</span>;
32
+ }
33
+
34
+ /** React reports a caught render error on the console; the assertions read it. */
35
+ let consoleError: ReturnType<typeof vi.spyOn>;
36
+
37
+ beforeEach(() => {
38
+ consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
39
+ });
40
+
41
+ afterEach(() => {
42
+ consoleError.mockRestore();
43
+ });
44
+
45
+ function renderRetryable(load: () => Promise<typeof Loaded>) {
46
+ const lazyEditor = retryableLazyEditor(load);
47
+ function Host() {
48
+ const LazyEditor = lazyEditor();
49
+ return (
50
+ <Suspense fallback={<span>loading</span>}>
51
+ <LazyEditor />
52
+ </Suspense>
53
+ );
54
+ }
55
+ return render(
56
+ <ResettableBoundary>
57
+ <Host />
58
+ </ResettableBoundary>,
59
+ );
60
+ }
61
+
62
+ describe("retryableLazyEditor", () => {
63
+ it("re-attempts the load after a transient failure", async () => {
64
+ // `lazy` caches its first rejection for the life of the component, so one
65
+ // failed chunk fetch would otherwise hold the editor broken until a full
66
+ // page reload.
67
+ const load = vi
68
+ .fn<() => Promise<typeof Loaded>>()
69
+ .mockRejectedValueOnce(new Error("Failed to fetch dynamically imported module"))
70
+ .mockResolvedValue(Loaded);
71
+
72
+ renderRetryable(load);
73
+
74
+ const retry = await screen.findByRole("button");
75
+ expect(retry).toHaveTextContent("Failed to fetch dynamically imported module");
76
+ expect(load).toHaveBeenCalledTimes(1);
77
+
78
+ fireEvent.click(retry);
79
+
80
+ expect(await screen.findByText("editor")).toBeInTheDocument();
81
+ expect(load).toHaveBeenCalledTimes(2);
82
+ });
83
+
84
+ it("keeps a missing peer permanent, so a remount does not import it again", async () => {
85
+ // A package does not install itself mid-session. Retrying would only
86
+ // repeat the same failure and hide the install list behind a loop.
87
+ const load = vi
88
+ .fn<() => Promise<typeof Loaded>>()
89
+ .mockRejectedValue(new MissingEditorPeersError("Install @tiptap/react."));
90
+
91
+ renderRetryable(load);
92
+
93
+ const retry = await screen.findByRole("button");
94
+ expect(retry).toHaveTextContent("Install @tiptap/react.");
95
+ expect(load).toHaveBeenCalledTimes(1);
96
+
97
+ fireEvent.click(retry);
98
+
99
+ expect(await screen.findByRole("button")).toHaveTextContent("Install @tiptap/react.");
100
+ expect(load).toHaveBeenCalledTimes(1);
101
+ });
102
+ });
@@ -0,0 +1,40 @@
1
+ import { type ComponentType, lazy, type LazyExoticComponent } from "react";
2
+ import { isMissingEditorPeersError } from "./editor-peers";
3
+
4
+ /**
5
+ * Builds a `lazy` editor component that a remount can re-attempt.
6
+ *
7
+ * `lazy` caches its first rejection for the life of the component. That is
8
+ * right for a missing peer, which stays missing for the session, and wrong for
9
+ * a transient chunk fetch: one failed network request would otherwise hold the
10
+ * editor broken until a full page reload.
11
+ *
12
+ * The returned function gives the component to render, and a caller must call
13
+ * it on every render: a transient failure replaces the component, and a caller
14
+ * that holds the one it got earlier keeps replaying the cached rejection.
15
+ *
16
+ * A `MissingEditorPeersError` keeps the cached rejection, so the install list
17
+ * reaches the consumer's error boundary once and the loader does not run
18
+ * again. Any other rejection replaces the component, so the next mount — an
19
+ * error boundary that resets, or a remount by the parent — starts a fresh
20
+ * attempt.
21
+ */
22
+ export function retryableLazyEditor<P extends object>(
23
+ loadComponent: () => Promise<ComponentType<P>>,
24
+ ): () => LazyExoticComponent<ComponentType<P>> {
25
+ function build(): LazyExoticComponent<ComponentType<P>> {
26
+ return lazy(async () => {
27
+ try {
28
+ return { default: await loadComponent() };
29
+ } catch (error) {
30
+ if (!isMissingEditorPeersError(error)) {
31
+ current = build();
32
+ }
33
+ throw error;
34
+ }
35
+ });
36
+ }
37
+
38
+ let current = build();
39
+ return () => current;
40
+ }
@@ -0,0 +1,25 @@
1
+ "use client";
2
+
3
+ import { cn } from "../lib/utils";
4
+
5
+ /**
6
+ * Stands in for an editor while its optional peers load. The editors reach
7
+ * tiptap through a dynamic import, so an editor's first frame waits for that
8
+ * chunk. The live region announces the wait, which a screen reader has no
9
+ * other way to notice. Callers put their own minimum height first, so a
10
+ * caller-supplied className still wins.
11
+ */
12
+ export function EditorLoadingPlaceholder({ className }: { className?: string }) {
13
+ return (
14
+ <div
15
+ role="status"
16
+ aria-live="polite"
17
+ className={cn(
18
+ "flex w-full items-center justify-center rounded-lg border border-border border-dashed bg-muted text-muted-foreground text-sm",
19
+ className,
20
+ )}
21
+ >
22
+ Loading editor…
23
+ </div>
24
+ );
25
+ }
@@ -0,0 +1,250 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ asMissingEditorPeersError,
4
+ isMissingEditorPeersError,
5
+ isMissingPeerError,
6
+ } from "./editor-peers";
7
+
8
+ /** The namespace shape each peer has when it is installed. */
9
+ const installedPeers: Record<string, Record<string, unknown>> = {
10
+ "@tiptap/react": { useEditor: () => null, EditorContent: () => null },
11
+ "@tiptap/starter-kit": { default: { configure: () => ({}) } },
12
+ "@tiptap/extension-collaboration": { default: { configure: () => ({}) } },
13
+ "@tiptap/extension-collaboration-caret": { default: { configure: () => ({}) } },
14
+ "@hocuspocus/provider": { HocuspocusProvider: class {} },
15
+ yjs: { Doc: class {} },
16
+ };
17
+
18
+ /**
19
+ * Loads the module under test with the named peers replaced. The loaders read
20
+ * their peers through a dynamic import, so the substitution has to happen
21
+ * before the module graph is built — hence the reset and the dynamic import.
22
+ */
23
+ async function loadPeerLoaders(
24
+ replacements: Record<string, Record<string, unknown>> = {},
25
+ ) {
26
+ vi.resetModules();
27
+ for (const specifier of Object.keys(installedPeers)) {
28
+ const namespace = replacements[specifier] ?? installedPeers[specifier];
29
+ vi.doMock(specifier, () => namespace);
30
+ }
31
+ return await import("./editor-peers");
32
+ }
33
+
34
+ afterEach(() => {
35
+ vi.resetModules();
36
+ });
37
+
38
+ describe("isMissingPeerError", () => {
39
+ it("recognises what each bundler and runtime says for an uninstalled package", () => {
40
+ const messages = [
41
+ // Vite's stub for an optional peer that is not installed.
42
+ 'Could not resolve "@tiptap/react" imported by "@tangle-network/ui". Is it installed?',
43
+ // Node ESM.
44
+ "Cannot find package '@tiptap/react' imported from /app/index.js",
45
+ // CommonJS.
46
+ "Cannot find module '@tiptap/react'",
47
+ // webpack.
48
+ "Module not found: Error: Can't resolve '@tiptap/react' in '/app'",
49
+ // esbuild.
50
+ 'Failed to resolve entry for package "@tiptap/react"',
51
+ ];
52
+
53
+ for (const message of messages) {
54
+ expect(isMissingPeerError(new Error(message)), message).toBe(true);
55
+ }
56
+ });
57
+
58
+ it("leaves a transient chunk failure to report its own cause", () => {
59
+ // Enriching this one with "install the peers" sends the reader to the
60
+ // wrong fix, and the peers are already installed.
61
+ expect(
62
+ isMissingPeerError(new Error("Failed to fetch dynamically imported module")),
63
+ ).toBe(false);
64
+ expect(isMissingPeerError(new Error("Network request failed"))).toBe(false);
65
+ expect(isMissingPeerError("Could not resolve")).toBe(false);
66
+ });
67
+ });
68
+
69
+ describe("editor optional peer loaders", () => {
70
+ it("returns the namespaces the editors call", async () => {
71
+ const { loadCollaborationPeers, loadDocumentEditorPeers } = await loadPeerLoaders();
72
+
73
+ const documentPeers = await loadDocumentEditorPeers();
74
+ expect(documentPeers.react.useEditor).toBe(installedPeers["@tiptap/react"].useEditor);
75
+ expect(documentPeers.starterKit.default).toBe(
76
+ installedPeers["@tiptap/starter-kit"].default,
77
+ );
78
+
79
+ const collaborationPeers = await loadCollaborationPeers();
80
+ expect(collaborationPeers.hocuspocus.HocuspocusProvider).toBe(
81
+ installedPeers["@hocuspocus/provider"].HocuspocusProvider,
82
+ );
83
+ expect(collaborationPeers.yjs.Doc).toBe(installedPeers.yjs.Doc);
84
+ });
85
+
86
+ it("names the install list when a peer resolves to a namespace with no editor in it", async () => {
87
+ // A bundler can stub a missing optional peer as a silent empty namespace
88
+ // rather than a throwing module. That shape must still fail with the peers
89
+ // named, and not as an undefined-property crash in the middle of a render.
90
+ const { loadDocumentEditorPeers } = await loadPeerLoaders({
91
+ "@tiptap/starter-kit": { default: undefined },
92
+ });
93
+
94
+ await expect(loadDocumentEditorPeers()).rejects.toThrow(
95
+ /Install @tiptap\/react and @tiptap\/starter-kit/,
96
+ );
97
+ });
98
+
99
+ it("names the install list when an extension stub carries a default it cannot configure", async () => {
100
+ // The commonest stub shape is a default export that is present but empty.
101
+ // It passes a plain "is the default defined" test, and then the factory
102
+ // calls .configure() on it and throws an opaque TypeError mid-render.
103
+ const { loadDocumentEditorPeers } = await loadPeerLoaders({
104
+ "@tiptap/starter-kit": { default: {} },
105
+ });
106
+
107
+ await expect(loadDocumentEditorPeers()).rejects.toThrow(
108
+ /Install @tiptap\/react and @tiptap\/starter-kit/,
109
+ );
110
+ });
111
+
112
+ it.each([
113
+ "@tiptap/extension-collaboration",
114
+ "@tiptap/extension-collaboration-caret",
115
+ ])("names the collaboration install list when %s is an empty default", async (specifier) => {
116
+ const { loadCollaborationPeers } = await loadPeerLoaders({
117
+ [specifier]: { default: {} },
118
+ });
119
+
120
+ await expect(loadCollaborationPeers()).rejects.toThrow(
121
+ /@hocuspocus\/provider and yjs/,
122
+ );
123
+ });
124
+
125
+ it("marks a missing peer permanent and leaves any other failure retryable", async () => {
126
+ // editor-lazy.ts reads this distinction to decide whether a remount may
127
+ // try the import again.
128
+ const { loadDocumentEditorPeers } = await loadPeerLoaders({
129
+ "@tiptap/starter-kit": { default: {} },
130
+ });
131
+
132
+ await expect(loadDocumentEditorPeers()).rejects.toSatisfy(isMissingEditorPeersError);
133
+ expect(isMissingEditorPeersError(new Error("Failed to fetch dynamically imported module"))).toBe(
134
+ false,
135
+ );
136
+ });
137
+
138
+ it("reports an unresolved import as a permanent missing peer and keeps the cause", async () => {
139
+ // The rejection a throwing stub gives must become the install list, so a
140
+ // consumer reads which packages to add, and must count as permanent so a
141
+ // remount does not retry an import that cannot start to succeed.
142
+ const resolutionFailure = new Error('Could not resolve "@tiptap/starter-kit"');
143
+ const mapped = asMissingEditorPeersError(resolutionFailure, "install the peers");
144
+
145
+ expect(isMissingEditorPeersError(mapped)).toBe(true);
146
+ expect(mapped).toHaveProperty("cause", resolutionFailure);
147
+ // The install list holds every peer the surface needs, so the message also
148
+ // names the one that is actually absent.
149
+ expect((mapped as Error).message).toBe(
150
+ "install the peers @tiptap/starter-kit did not resolve.",
151
+ );
152
+ });
153
+
154
+ it("names the caret package rather than the collaboration package it contains", async () => {
155
+ // "@tiptap/extension-collaboration" is a prefix of the caret specifier, so
156
+ // a naive substring match reports the wrong package.
157
+ const caretFailure = new Error(
158
+ 'Could not resolve "@tiptap/extension-collaboration-caret"',
159
+ );
160
+ const mapped = asMissingEditorPeersError(caretFailure, "install the peers");
161
+
162
+ expect((mapped as Error).message).toBe(
163
+ "install the peers @tiptap/extension-collaboration-caret did not resolve.",
164
+ );
165
+ });
166
+
167
+ it("leaves a resolution failure that names no peer alone, so it stays retryable", async () => {
168
+ // An application chunk, or a dependency of a peer that did load, can fail
169
+ // to resolve too. Installing the editor peers would not fix that, and
170
+ // marking it permanent would stop a remount from trying again.
171
+ const unrelated = new Error("Cannot find module './app-chunk-a91f.js'");
172
+ const mapped = asMissingEditorPeersError(unrelated, "install the peers");
173
+
174
+ expect(mapped).toBe(unrelated);
175
+ expect(isMissingEditorPeersError(mapped)).toBe(false);
176
+ });
177
+
178
+ it("treats a @tiptap/core failure as a missing peer, though no loader imports it", async () => {
179
+ // The tiptap packages need @tiptap/core in turn, so a consumer that
180
+ // resolves it to nothing breaks exactly as one missing @tiptap/react
181
+ // does. Leaving it out of the list left that failure retryable.
182
+ const coreFailure = new Error('Could not resolve "@tiptap/core"');
183
+ const mapped = asMissingEditorPeersError(coreFailure, "install the peers");
184
+
185
+ expect(isMissingEditorPeersError(mapped)).toBe(true);
186
+ expect((mapped as Error).message).toBe(
187
+ "install the peers @tiptap/core did not resolve.",
188
+ );
189
+ });
190
+
191
+ it("keeps the install list alone when the failure names several peers", async () => {
192
+ const both = new Error(
193
+ 'Could not resolve "@tiptap/react" or "@hocuspocus/provider"',
194
+ );
195
+ const mapped = asMissingEditorPeersError(both, "install the peers");
196
+
197
+ expect(isMissingEditorPeersError(mapped)).toBe(true);
198
+ expect((mapped as Error).message).toBe("install the peers");
199
+ });
200
+
201
+ it("leaves a transient chunk failure unchanged, so a remount can retry it", async () => {
202
+ const transient = new Error("Failed to fetch dynamically imported module");
203
+
204
+ expect(asMissingEditorPeersError(transient, "install the peers")).toBe(transient);
205
+ expect(isMissingEditorPeersError(transient)).toBe(false);
206
+ });
207
+
208
+ it("names the install list when a peer carries the wrong member type", async () => {
209
+ const { loadDocumentEditorPeers } = await loadPeerLoaders({
210
+ "@tiptap/react": { useEditor: undefined, EditorContent: undefined },
211
+ });
212
+
213
+ await expect(loadDocumentEditorPeers()).rejects.toThrow(
214
+ /Install @tiptap\/react and @tiptap\/starter-kit/,
215
+ );
216
+ });
217
+
218
+ it("accepts the shapes the installed peers really have", async () => {
219
+ // The other cases in this file describe the peers, so nothing here would
220
+ // notice a tiptap release that moves `configure` off the default export.
221
+ // Loading the real packages is what turns that into a failure here rather
222
+ // than in a consumer's editor.
223
+ vi.resetModules();
224
+ for (const specifier of Object.keys(installedPeers)) {
225
+ vi.doUnmock(specifier);
226
+ }
227
+ const { loadCollaborationPeers } = await import("./editor-peers");
228
+
229
+ const peers = await loadCollaborationPeers();
230
+ // Prove the substitutions are gone; against the stubs this case would
231
+ // pass without reading a real package at all.
232
+ expect(peers.starterKit.default).not.toBe(installedPeers["@tiptap/starter-kit"].default);
233
+ expect(typeof peers.starterKit.default.configure).toBe("function");
234
+ expect(typeof peers.collaboration.default.configure).toBe("function");
235
+ expect(typeof peers.collaborationCaret.default.configure).toBe("function");
236
+ });
237
+
238
+ it("keeps the local editor usable when only a collaboration peer is missing", async () => {
239
+ // sandbox-ui installs the tiptap packages alone, so the local markdown
240
+ // editor must not start to require yjs or Hocuspocus.
241
+ const { loadCollaborationPeers, loadDocumentEditorPeers } = await loadPeerLoaders({
242
+ yjs: { Doc: undefined },
243
+ });
244
+
245
+ await expect(loadDocumentEditorPeers()).resolves.toBeDefined();
246
+ await expect(loadCollaborationPeers()).rejects.toThrow(
247
+ /@hocuspocus\/provider and yjs/,
248
+ );
249
+ });
250
+ });
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Resolves the optional peers behind the `./editor` entry.
3
+ *
4
+ * A bundler resolves an uninstalled optional peer to a stub module that
5
+ * carries a default export only and throws when it evaluates. One static
6
+ * `import { EditorContent } from "@tiptap/react"` therefore fails the build of
7
+ * every consumer that does not install tiptap, even when nothing renders an
8
+ * editor. Every value taken from an optional peer must arrive through the
9
+ * dynamic `import()` calls in this module, and each component is built from
10
+ * the loaded namespaces. Type-only imports are erased, so they stay allowed.
11
+ *
12
+ * A bundler still reads the literal specifier in a dynamic `import()`. Vite
13
+ * and Rollup leave an unresolved one to run time on their own; esbuild does so
14
+ * only when the call carries a `.catch()`. Every import below therefore
15
+ * attaches `rethrow`. Webpack has no such rule and needs consumer
16
+ * configuration, which `packages/ui/README.md` gives.
17
+ *
18
+ * Three loaders keep each surface independent of the peers it does not use. A
19
+ * consumer that installs only tiptap can edit markdown locally. A consumer
20
+ * that installs only yjs and Hocuspocus can drive its own editor from
21
+ * `EditorProvider`'s context. Only the collaborative editor needs all six.
22
+ *
23
+ * A missing peer and a transient chunk fetch fail differently, so they carry
24
+ * different types: a missing peer throws `MissingEditorPeersError`, which
25
+ * `editor-lazy.ts` treats as permanent, and any other rejection keeps its own
26
+ * error and stays retryable.
27
+ */
28
+
29
+ import type * as Hocuspocus from "@hocuspocus/provider";
30
+ import type * as TiptapCollaboration from "@tiptap/extension-collaboration";
31
+ import type * as TiptapCollaborationCaret from "@tiptap/extension-collaboration-caret";
32
+ import type * as TiptapReact from "@tiptap/react";
33
+ import type * as TiptapStarterKit from "@tiptap/starter-kit";
34
+ import type * as Yjs from "yjs";
35
+
36
+ /** Namespaces the local markdown editor needs. */
37
+ export interface DocumentEditorPeers {
38
+ react: typeof TiptapReact;
39
+ starterKit: typeof TiptapStarterKit;
40
+ }
41
+
42
+ /**
43
+ * Namespaces the collaboration transport needs. `EditorProvider` builds the
44
+ * document and the socket from these two alone, so it must not wait on the
45
+ * tiptap stack: a consumer can drive its own editor from the provider's
46
+ * context with nothing else installed.
47
+ */
48
+ export interface EditorProviderPeers {
49
+ hocuspocus: typeof Hocuspocus;
50
+ yjs: typeof Yjs;
51
+ }
52
+
53
+ /** Namespaces the collaborative editor needs, on top of the two sets above. */
54
+ export interface CollaborationPeers extends DocumentEditorPeers, EditorProviderPeers {
55
+ collaboration: typeof TiptapCollaboration;
56
+ collaborationCaret: typeof TiptapCollaborationCaret;
57
+ }
58
+
59
+ const DOCUMENT_PEERS_MISSING =
60
+ "@tangle-network/ui/editor needs its optional editor peers. " +
61
+ "Install @tiptap/react and @tiptap/starter-kit.";
62
+
63
+ const PROVIDER_PEERS_MISSING =
64
+ "@tangle-network/ui/editor needs its optional collaboration transport peers. " +
65
+ "Install @hocuspocus/provider and yjs.";
66
+
67
+ const COLLABORATION_PEERS_MISSING =
68
+ "@tangle-network/ui/editor needs its optional collaboration peers. " +
69
+ "Install @tiptap/react, @tiptap/starter-kit, @tiptap/extension-collaboration, " +
70
+ "@tiptap/extension-collaboration-caret, @hocuspocus/provider and yjs.";
71
+
72
+ /**
73
+ * A peer the editor needs is absent, or resolved to a stub that carries none
74
+ * of the members the editor calls. The condition holds for the rest of the
75
+ * session, because a package does not install itself mid-run.
76
+ */
77
+ export class MissingEditorPeersError extends Error {
78
+ constructor(message: string, options?: { cause?: unknown }) {
79
+ super(message, options);
80
+ this.name = "MissingEditorPeersError";
81
+ }
82
+ }
83
+
84
+ /**
85
+ * True for the error above. The name carries the answer, so a duplicated copy
86
+ * of this module in a consumer's bundle still reports its own error correctly.
87
+ */
88
+ export function isMissingEditorPeersError(error: unknown): boolean {
89
+ return error instanceof Error && error.name === "MissingEditorPeersError";
90
+ }
91
+
92
+ /** The messages a bundler or a runtime gives for a module it cannot resolve. */
93
+ const RESOLUTION_FAILURE =
94
+ /could not resolve|cannot find (?:module|package)|can't resolve|failed to resolve|module not found/i;
95
+
96
+ /**
97
+ * True when the rejection says the package is not installed. Only such an
98
+ * error gets the install list: a transient chunk-fetch failure that reads as
99
+ * "install the peers" sends the reader to the wrong fix. An error this
100
+ * predicate does not match keeps its own message, so it can only
101
+ * under-report.
102
+ */
103
+ export function isMissingPeerError(error: unknown): boolean {
104
+ return error instanceof Error && RESOLUTION_FAILURE.test(error.message);
105
+ }
106
+
107
+ /**
108
+ * Hands an `import()` rejection on unchanged. esbuild reports an unresolvable
109
+ * literal `import()` as a build error and defers it to run time only when the
110
+ * call carries a `.catch()`, so every peer import attaches this handler. It
111
+ * changes nothing at run time.
112
+ */
113
+ function rethrow(error: unknown): never {
114
+ throw error;
115
+ }
116
+
117
+ /**
118
+ * Every optional peer the `./editor` entry needs at run time. The loaders
119
+ * import all but `@tiptap/core`, which the tiptap packages need in turn: a
120
+ * consumer that resolves it to nothing breaks the same way, so a failure that
121
+ * names it is a missing peer too. `scripts/validate-dist.mjs` holds the same
122
+ * list and rejects a build where the two disagree.
123
+ */
124
+ const DEFERRED_PEERS = [
125
+ "@tiptap/core",
126
+ "@tiptap/react",
127
+ "@tiptap/starter-kit",
128
+ "@tiptap/extension-collaboration",
129
+ "@tiptap/extension-collaboration-caret",
130
+ "@hocuspocus/provider",
131
+ "yjs",
132
+ ];
133
+
134
+ /** Every deferred peer a resolution failure names. */
135
+ function unresolvedPeersFrom(error: unknown): string[] {
136
+ if (!(error instanceof Error)) return [];
137
+ const named = DEFERRED_PEERS.filter((name) => error.message.includes(name));
138
+ // "@tiptap/extension-collaboration" is a prefix of the caret package, so a
139
+ // message about the caret names both. Drop a name another match contains.
140
+ return named.filter(
141
+ (name) => !named.some((other) => other !== name && other.includes(name)),
142
+ );
143
+ }
144
+
145
+ /**
146
+ * Turns a rejection that names an unresolved peer into the install-list error,
147
+ * and keeps the original as its cause. Any other rejection passes through, so
148
+ * a transient chunk fetch keeps its own message and stays retryable.
149
+ */
150
+ export function asMissingEditorPeersError(
151
+ error: unknown,
152
+ missingMessage: string,
153
+ ): unknown {
154
+ if (!isMissingPeerError(error)) return error;
155
+ const unresolved = unresolvedPeersFrom(error);
156
+ // A resolution failure that names none of the peers comes from somewhere
157
+ // else: an application chunk, or a dependency of a peer that did load.
158
+ // Installing the list would not fix it, and a later attempt can still
159
+ // succeed, so it keeps its own error and stays retryable.
160
+ if (unresolved.length === 0) return error;
161
+ const detail =
162
+ unresolved.length === 1 ? ` ${unresolved[0]} did not resolve.` : "";
163
+ return new MissingEditorPeersError(missingMessage + detail, { cause: error });
164
+ }
165
+
166
+ async function loadPeers<T>(
167
+ load: () => Promise<T>,
168
+ missingMessage: string,
169
+ ): Promise<T> {
170
+ try {
171
+ return await load();
172
+ } catch (error) {
173
+ throw asMissingEditorPeersError(error, missingMessage);
174
+ }
175
+ }
176
+
177
+ /**
178
+ * True for a tiptap extension the editor can configure. A bundler can stub a
179
+ * missing optional peer as a silent namespace whose default export is an empty
180
+ * object, which is defined but carries no `configure`. Reading the member the
181
+ * factories call separates that shape from a real extension.
182
+ */
183
+ function isConfigurableExtension(value: unknown): boolean {
184
+ return typeof (value as { configure?: unknown } | undefined)?.configure === "function";
185
+ }
186
+
187
+ /**
188
+ * Reads the members the editor calls, so a stub namespace fails with the
189
+ * install list, and not as an undefined-property crash in the middle of a
190
+ * render.
191
+ */
192
+ function assertDocumentEditorPeers(
193
+ peers: DocumentEditorPeers,
194
+ missingMessage: string,
195
+ ): void {
196
+ if (
197
+ typeof peers.react.useEditor !== "function" ||
198
+ peers.react.EditorContent === undefined ||
199
+ !isConfigurableExtension(peers.starterKit.default)
200
+ ) {
201
+ throw new MissingEditorPeersError(missingMessage);
202
+ }
203
+ }
204
+
205
+ function assertEditorProviderPeers(
206
+ peers: EditorProviderPeers,
207
+ missingMessage: string,
208
+ ): void {
209
+ if (
210
+ typeof peers.hocuspocus.HocuspocusProvider !== "function" ||
211
+ typeof peers.yjs.Doc !== "function"
212
+ ) {
213
+ throw new MissingEditorPeersError(missingMessage);
214
+ }
215
+ }
216
+
217
+ function assertCollaborationPeers(peers: CollaborationPeers): void {
218
+ assertDocumentEditorPeers(peers, COLLABORATION_PEERS_MISSING);
219
+ assertEditorProviderPeers(peers, COLLABORATION_PEERS_MISSING);
220
+ if (
221
+ !isConfigurableExtension(peers.collaboration.default) ||
222
+ !isConfigurableExtension(peers.collaborationCaret.default)
223
+ ) {
224
+ throw new MissingEditorPeersError(COLLABORATION_PEERS_MISSING);
225
+ }
226
+ }
227
+
228
+ async function importDocumentEditorPeers(): Promise<DocumentEditorPeers> {
229
+ const [react, starterKit] = await Promise.all([
230
+ import("@tiptap/react").catch(rethrow),
231
+ import("@tiptap/starter-kit").catch(rethrow),
232
+ ]);
233
+ return { react, starterKit };
234
+ }
235
+
236
+ async function importEditorProviderPeers(): Promise<EditorProviderPeers> {
237
+ const [hocuspocus, yjs] = await Promise.all([
238
+ import("@hocuspocus/provider").catch(rethrow),
239
+ import("yjs").catch(rethrow),
240
+ ]);
241
+ return { hocuspocus, yjs };
242
+ }
243
+
244
+ /** Resolve the collaboration transport's peers, or throw and name them. */
245
+ export async function loadEditorProviderPeers(): Promise<EditorProviderPeers> {
246
+ const peers = await loadPeers(importEditorProviderPeers, PROVIDER_PEERS_MISSING);
247
+ assertEditorProviderPeers(peers, PROVIDER_PEERS_MISSING);
248
+ return peers;
249
+ }
250
+
251
+ /** Resolve the local markdown editor's peers, or throw and name them. */
252
+ export async function loadDocumentEditorPeers(): Promise<DocumentEditorPeers> {
253
+ const peers = await loadPeers(importDocumentEditorPeers, DOCUMENT_PEERS_MISSING);
254
+ assertDocumentEditorPeers(peers, DOCUMENT_PEERS_MISSING);
255
+ return peers;
256
+ }
257
+
258
+ /** Resolve the collaborative editor's peers, or throw and name them. */
259
+ export async function loadCollaborationPeers(): Promise<CollaborationPeers> {
260
+ const peers = await loadPeers(async () => {
261
+ const [documentPeers, providerPeers, collaboration, collaborationCaret] =
262
+ await Promise.all([
263
+ importDocumentEditorPeers(),
264
+ importEditorProviderPeers(),
265
+ import("@tiptap/extension-collaboration").catch(rethrow),
266
+ import("@tiptap/extension-collaboration-caret").catch(rethrow),
267
+ ]);
268
+ return { ...documentPeers, ...providerPeers, collaboration, collaborationCaret };
269
+ }, COLLABORATION_PEERS_MISSING);
270
+ assertCollaborationPeers(peers);
271
+ return peers;
272
+ }