@jant/core 0.6.10 → 0.6.11

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 (42) hide show
  1. package/dist/{app-CGHkOdme.js → app-CpmficmQ.js} +531 -204
  2. package/dist/app-DqKkZenB.js +6 -0
  3. package/dist/client/.vite/manifest.json +3 -3
  4. package/dist/client/_assets/client-BhHHVvSY.css +2 -0
  5. package/dist/client/_assets/{client-DYrWuaIk.js → client-Dd9U383b.js} +1 -1
  6. package/dist/client/_assets/{client-auth-B5Re0uCd.js → client-auth-DkpSdIDz.js} +80 -80
  7. package/dist/{export-DY1v5Iqu.js → export-Ba7NJImL.js} +92 -92
  8. package/dist/{github-sync-LefaslGJ.js → github-sync-BD4w2m8-.js} +2 -2
  9. package/dist/{github-sync-2_T7nbOv.js → github-sync-Cb4_6_i7.js} +1 -1
  10. package/dist/index.js +3 -3
  11. package/dist/node.js +4 -4
  12. package/package.json +1 -1
  13. package/src/client/components/__tests__/jant-compose-editor-rehost-notice.test.ts +62 -0
  14. package/src/client/components/compose-types.ts +4 -0
  15. package/src/client/components/jant-compose-editor.ts +111 -0
  16. package/src/client/compose-bridge.ts +25 -8
  17. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +143 -0
  18. package/src/client/tiptap/__tests__/paste-rehost-e2e.test.ts +65 -0
  19. package/src/client/tiptap/__tests__/rehost-images.test.ts +139 -0
  20. package/src/client/tiptap/create-editor.ts +3 -0
  21. package/src/client/tiptap/extensions.ts +4 -0
  22. package/src/client/tiptap/inline-image-upload.ts +174 -50
  23. package/src/client/tiptap/rehost-images.ts +104 -0
  24. package/src/i18n/locales/public/en.po +10 -0
  25. package/src/i18n/locales/public/en.ts +1 -1
  26. package/src/i18n/locales/public/zh-Hans.po +10 -0
  27. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  28. package/src/i18n/locales/public/zh-Hant.po +10 -0
  29. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  30. package/src/lib/__tests__/upload-sideload.test.ts +78 -0
  31. package/src/lib/__tests__/url-fetch.test.ts +181 -0
  32. package/src/lib/upload.ts +111 -0
  33. package/src/lib/url-fetch.ts +263 -0
  34. package/src/routes/api/__tests__/uploads.test.ts +63 -1
  35. package/src/routes/api/uploads.ts +52 -0
  36. package/src/services/__tests__/media.test.ts +168 -1
  37. package/src/services/media.ts +111 -0
  38. package/src/styles/ui.css +1 -1
  39. package/src/ui/compose/ComposeDialog.tsx +16 -0
  40. package/src/ui/layouts/BaseLayout.tsx +12 -0
  41. package/dist/app-D24n0DoH.js +0 -6
  42. package/dist/client/_assets/client-xWDl78yi.css +0 -2
@@ -0,0 +1,143 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { Editor, type JSONContent } from "@tiptap/core";
5
+ import StarterKit from "@tiptap/starter-kit";
6
+ import { ImageNode } from "../image-node.js";
7
+ import {
8
+ rehostInlineImage,
9
+ resolveInlineImageUrls,
10
+ hasPendingInlineImagePlaceholders,
11
+ } from "../inline-image-upload.js";
12
+
13
+ const editors: Editor[] = [];
14
+
15
+ function createEditor(): Editor {
16
+ const element = document.createElement("div");
17
+ document.body.appendChild(element);
18
+ const editor = new Editor({
19
+ element,
20
+ extensions: [
21
+ StarterKit.configure({
22
+ heading: { levels: [1, 2, 3] },
23
+ link: { openOnClick: false, autolink: false },
24
+ }),
25
+ ImageNode,
26
+ ],
27
+ content: "<p></p>",
28
+ });
29
+ editors.push(editor);
30
+ return editor;
31
+ }
32
+
33
+ function imageSrcs(editor: Editor): string[] {
34
+ const out: string[] = [];
35
+ editor.state.doc.descendants((node) => {
36
+ if (node.type.name === "image") out.push(node.attrs.src as string);
37
+ });
38
+ return out;
39
+ }
40
+
41
+ function jsonImageSrcs(node: JSONContent | null): string[] {
42
+ if (!node) return [];
43
+ const out: string[] = [];
44
+ const walk = (n: JSONContent) => {
45
+ if (n.type === "image" && typeof n.attrs?.src === "string") {
46
+ out.push(n.attrs.src);
47
+ }
48
+ for (const child of n.content ?? []) walk(child);
49
+ };
50
+ walk(node);
51
+ return out;
52
+ }
53
+
54
+ afterEach(() => {
55
+ while (editors.length > 0) editors.pop()?.destroy();
56
+ document.body.innerHTML = "";
57
+ });
58
+
59
+ describe("rehostInlineImage", () => {
60
+ it("swaps the node src on a successful rehost", async () => {
61
+ const editor = createEditor();
62
+ const src = "https://ext.example/keep1.png";
63
+ editor.commands.setImage({ src });
64
+
65
+ await rehostInlineImage(
66
+ editor,
67
+ src,
68
+ async () => "https://cdn.local/keep1.webp",
69
+ );
70
+
71
+ expect(imageSrcs(editor)).toEqual(["https://cdn.local/keep1.webp"]);
72
+ });
73
+
74
+ it("keeps a remote node's original src when the rehost fails", async () => {
75
+ const editor = createEditor();
76
+ const src = "https://ext.example/fail1.png";
77
+ editor.commands.setImage({ src });
78
+
79
+ await rehostInlineImage(editor, src, async () => {
80
+ throw new Error("boom");
81
+ });
82
+
83
+ expect(imageSrcs(editor)).toEqual([src]);
84
+ });
85
+
86
+ it("replaces every node sharing a deduped remote src", async () => {
87
+ const editor = createEditor();
88
+ const src = "https://ext.example/dupe.png";
89
+ editor.commands.setImage({ src });
90
+ editor.commands.setImage({ src });
91
+
92
+ await rehostInlineImage(
93
+ editor,
94
+ src,
95
+ async () => "https://cdn.local/dupe.webp",
96
+ );
97
+
98
+ expect(imageSrcs(editor)).toEqual([
99
+ "https://cdn.local/dupe.webp",
100
+ "https://cdn.local/dupe.webp",
101
+ ]);
102
+ });
103
+ });
104
+
105
+ describe("resolveInlineImageUrls + hasPendingInlineImagePlaceholders", () => {
106
+ it("tracks an in-flight rehost and resolves its placeholder to the stored URL", async () => {
107
+ const editor = createEditor();
108
+ const src = "https://ext.example/pending.png";
109
+ editor.commands.setImage({ src });
110
+
111
+ let release!: (url: string) => void;
112
+ const deferred = new Promise<string>((resolve) => {
113
+ release = resolve;
114
+ });
115
+ const rehostDone = rehostInlineImage(editor, src, () => deferred);
116
+
117
+ const json = editor.getJSON();
118
+ expect(hasPendingInlineImagePlaceholders(json)).toBe(true);
119
+
120
+ const resolvePromise = resolveInlineImageUrls(json);
121
+ release("https://cdn.local/pending.webp");
122
+ const resolved = await resolvePromise;
123
+ await rehostDone;
124
+
125
+ expect(jsonImageSrcs(resolved)).toEqual(["https://cdn.local/pending.webp"]);
126
+ });
127
+
128
+ it("is a no-op for content with no pending placeholders", async () => {
129
+ const json: JSONContent = {
130
+ type: "doc",
131
+ content: [
132
+ {
133
+ type: "image",
134
+ attrs: { src: "https://cdn.local/already-stored.webp" },
135
+ },
136
+ ],
137
+ };
138
+
139
+ expect(hasPendingInlineImagePlaceholders(json)).toBe(false);
140
+ const resolved = await resolveInlineImageUrls(json);
141
+ expect(resolved).toBe(json);
142
+ });
143
+ });
@@ -0,0 +1,65 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { createTiptapEditor } from "../create-editor.js";
5
+ import type { Editor } from "@tiptap/core";
6
+
7
+ const editors: Editor[] = [];
8
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
9
+
10
+ afterEach(() => {
11
+ while (editors.length > 0) editors.pop()?.destroy();
12
+ document.body.innerHTML = "";
13
+ vi.restoreAllMocks();
14
+ });
15
+
16
+ function dispatchPaste(editor: Editor, html: string, text = "") {
17
+ const event = new Event("paste", {
18
+ bubbles: true,
19
+ cancelable: true,
20
+ }) as Event & { clipboardData: unknown };
21
+ event.clipboardData = {
22
+ getData: (type: string) =>
23
+ type === "text/html" ? html : type === "text/plain" ? text : "",
24
+ files: [],
25
+ items: [],
26
+ types: ["text/html", "text/plain"],
27
+ };
28
+ editor.view.dom.dispatchEvent(event);
29
+ }
30
+
31
+ function imageSrcs(editor: Editor): string[] {
32
+ const out: string[] = [];
33
+ editor.state.doc.descendants((n) => {
34
+ if (n.type.name === "image") out.push(n.attrs.src as string);
35
+ });
36
+ return out;
37
+ }
38
+
39
+ describe("paste → rehost end to end", () => {
40
+ it("turns pasted article <img> into image nodes and triggers rehost", async () => {
41
+ const rehost = vi.fn();
42
+ const el = document.createElement("div");
43
+ document.body.appendChild(el);
44
+ const editor = createTiptapEditor({
45
+ element: el,
46
+ rehostImages: { shouldRehost: () => true, rehost },
47
+ });
48
+ editors.push(editor);
49
+ editor.commands.focus();
50
+
51
+ const html =
52
+ "<p>正文段落一</p>" +
53
+ '<p><img src="https://img1.doubanio.com/view/photo/l/public/p1.jpg"></p>' +
54
+ "<p>正文段落二</p>";
55
+ dispatchPaste(editor, html);
56
+ await flush();
57
+
58
+ expect(imageSrcs(editor)).toContain(
59
+ "https://img1.doubanio.com/view/photo/l/public/p1.jpg",
60
+ );
61
+ expect(rehost).toHaveBeenCalledWith(
62
+ "https://img1.doubanio.com/view/photo/l/public/p1.jpg",
63
+ );
64
+ });
65
+ });
@@ -0,0 +1,139 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { Editor } from "@tiptap/core";
5
+ import StarterKit from "@tiptap/starter-kit";
6
+ import { ImageNode } from "../image-node.js";
7
+ import { RehostImages, clearRehostInFlight } from "../rehost-images.js";
8
+
9
+ const editors: Editor[] = [];
10
+ const usedSrcs: string[] = [];
11
+
12
+ /** Mirrors the predicate the compose editor passes to the extension. */
13
+ function shouldRehost(src: string): boolean {
14
+ if (src.startsWith("data:")) return true;
15
+ if (!/^https?:\/\//i.test(src)) return false;
16
+ let origin: string;
17
+ try {
18
+ origin = new URL(src).origin;
19
+ } catch {
20
+ return false;
21
+ }
22
+ if (origin === window.location.origin) return false;
23
+ const mediaBase = document.documentElement.dataset.mediaBase;
24
+ if (mediaBase && src.startsWith(mediaBase)) return false;
25
+ return true;
26
+ }
27
+
28
+ function createEditor(rehost: (src: string) => void): Editor {
29
+ const element = document.createElement("div");
30
+ document.body.appendChild(element);
31
+ const editor = new Editor({
32
+ element,
33
+ extensions: [
34
+ StarterKit.configure({
35
+ heading: { levels: [1, 2, 3] },
36
+ link: { openOnClick: false, autolink: false },
37
+ }),
38
+ ImageNode,
39
+ RehostImages.configure({ shouldRehost, rehost }),
40
+ ],
41
+ content: "<p></p>",
42
+ });
43
+ editors.push(editor);
44
+ return editor;
45
+ }
46
+
47
+ /** Track a src so the module-level in-flight set is reset between tests. */
48
+ function track(src: string): string {
49
+ usedSrcs.push(src);
50
+ return src;
51
+ }
52
+
53
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
54
+
55
+ afterEach(() => {
56
+ while (editors.length > 0) editors.pop()?.destroy();
57
+ for (const src of usedSrcs) clearRehostInFlight(src);
58
+ usedSrcs.length = 0;
59
+ document.body.innerHTML = "";
60
+ delete document.documentElement.dataset.mediaBase;
61
+ vi.restoreAllMocks();
62
+ });
63
+
64
+ describe("RehostImages extension", () => {
65
+ it("fires rehost once for a remote image", async () => {
66
+ const rehost = vi.fn();
67
+ const editor = createEditor(rehost);
68
+ const src = track("https://ext.example/a-remote.png");
69
+
70
+ editor.commands.setImage({ src });
71
+ await flush();
72
+
73
+ expect(rehost).toHaveBeenCalledTimes(1);
74
+ expect(rehost).toHaveBeenCalledWith(src);
75
+ });
76
+
77
+ it("fires rehost for a data: URL", async () => {
78
+ const rehost = vi.fn();
79
+ const editor = createEditor(rehost);
80
+ const src = track("data:image/png;base64,AAAAb");
81
+
82
+ editor.commands.setImage({ src });
83
+ await flush();
84
+
85
+ expect(rehost).toHaveBeenCalledWith(src);
86
+ });
87
+
88
+ it("skips same-origin, relative, blob, and media-base images", async () => {
89
+ const rehost = vi.fn();
90
+ document.documentElement.dataset.mediaBase = "https://cdn.mysite.test";
91
+ const editor = createEditor(rehost);
92
+
93
+ editor.commands.setImage({
94
+ src: track(`${window.location.origin}/m/x.png`),
95
+ });
96
+ editor.commands.setImage({ src: track("/relative/y.png") });
97
+ editor.commands.setImage({ src: track("blob:abc-123") });
98
+ editor.commands.setImage({ src: track("https://cdn.mysite.test/z.png") });
99
+ await flush();
100
+
101
+ expect(rehost).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it("dedupes identical remote srcs in one document", async () => {
105
+ const rehost = vi.fn();
106
+ const editor = createEditor(rehost);
107
+ const src = track("https://ext.example/dupe.png");
108
+
109
+ editor.commands.setImage({ src });
110
+ editor.commands.setImage({ src });
111
+ await flush();
112
+
113
+ expect(rehost).toHaveBeenCalledTimes(1);
114
+ });
115
+
116
+ it("does not re-trigger after the src is swapped to a local URL", async () => {
117
+ const rehost = vi.fn();
118
+ const editor = createEditor(rehost);
119
+ const src = track("https://ext.example/swap.png");
120
+
121
+ editor.commands.setImage({ src });
122
+ await flush();
123
+ expect(rehost).toHaveBeenCalledTimes(1);
124
+
125
+ editor.commands.setContent({
126
+ type: "doc",
127
+ content: [
128
+ { type: "paragraph" },
129
+ {
130
+ type: "image",
131
+ attrs: { src: `${window.location.origin}/m/swap.png` },
132
+ },
133
+ ],
134
+ });
135
+ await flush();
136
+
137
+ expect(rehost).toHaveBeenCalledTimes(1);
138
+ });
139
+ });
@@ -11,6 +11,7 @@ import {
11
11
  } from "./extensions.js";
12
12
  import type { FormattingToolbarMode } from "./toolbar-mode.js";
13
13
  import type { PasteMediaOptions } from "./paste-media.js";
14
+ import type { RehostImagesOptions } from "./rehost-images.js";
14
15
  import { normalizeFootnoteArtifacts } from "../../lib/footnotes.js";
15
16
  import { tiptapJsonToMarkdown } from "../../lib/tiptap-to-markdown.js";
16
17
  import { parseMarkdownDocument } from "../../lib/markdown-manager.js";
@@ -24,6 +25,7 @@ export interface CreateEditorOptions {
24
25
  onSelectionUpdate?: (selection: { from: number; to: number }) => void;
25
26
  toolbarMode?: FormattingToolbarMode;
26
27
  pasteMedia?: PasteMediaOptions;
28
+ rehostImages?: RehostImagesOptions;
27
29
  }
28
30
 
29
31
  /**
@@ -39,6 +41,7 @@ export function createTiptapEditor(options: CreateEditorOptions): Editor {
39
41
  placeholder: options.placeholder,
40
42
  toolbarMode: options.toolbarMode,
41
43
  pasteMedia: options.pasteMedia,
44
+ rehostImages: options.rehostImages,
42
45
  }),
43
46
  content: options.content ?? undefined,
44
47
  editorProps: {
@@ -24,6 +24,8 @@ import { MoreBreak } from "./more-break.js";
24
24
  import { EmbedNode } from "./embed-node.js";
25
25
  import { HtmlBlockNode } from "./html-block-node.js";
26
26
  import { EmbedPaste } from "./embed-paste.js";
27
+ import { RehostImages } from "./rehost-images.js";
28
+ import type { RehostImagesOptions } from "./rehost-images.js";
27
29
  import { MarkdownClipboard } from "./markdown-clipboard.js";
28
30
  import {
29
31
  MARKDOWN_MARKED_OPTIONS,
@@ -34,6 +36,7 @@ export interface EditorExtensionOptions {
34
36
  placeholder?: string;
35
37
  toolbarMode?: FormattingToolbarMode;
36
38
  pasteMedia?: PasteMediaOptions;
39
+ rehostImages?: RehostImagesOptions;
37
40
  }
38
41
 
39
42
  /**
@@ -113,6 +116,7 @@ export function createEditorExtensions(
113
116
  SlashCommands,
114
117
  EmbedPaste,
115
118
  PasteMedia.configure(options.pasteMedia ?? {}),
119
+ RehostImages.configure(options.rehostImages ?? {}),
116
120
  BubbleMenu.configure({
117
121
  toolbarMode: options.toolbarMode ?? "default",
118
122
  }),