@jant/core 0.6.10 → 0.6.12

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 (92) hide show
  1. package/bin/commands/import-site.js +45 -20
  2. package/dist/app-B5P1b4TF.js +6 -0
  3. package/dist/{app-CGHkOdme.js → app-D-zAhayc.js} +847 -244
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/client-BUxR-E8O.css +2 -0
  6. package/dist/client/_assets/{client-DYrWuaIk.js → client-S1cdvulk.js} +1 -1
  7. package/dist/client/_assets/{client-auth-B5Re0uCd.js → client-auth-YW92ZH7u.js} +158 -97
  8. package/dist/{export-DY1v5Iqu.js → export-Ckwh4BiE.js} +96 -96
  9. package/dist/{github-sync-LefaslGJ.js → github-sync-BMmK3JZ0.js} +2 -2
  10. package/dist/{github-sync-2_T7nbOv.js → github-sync-Dl9wQMar.js} +1 -1
  11. package/dist/index.js +3 -3
  12. package/dist/node.js +4 -4
  13. package/package.json +1 -1
  14. package/src/__tests__/import-site-command.test.ts +75 -0
  15. package/src/__tests__/vite-dev-plugins.test.ts +34 -0
  16. package/src/client/__tests__/compose-launch.test.ts +50 -1
  17. package/src/client/__tests__/compose-shortcuts.test.ts +74 -18
  18. package/src/client/components/__tests__/jant-command-palette.test.ts +63 -0
  19. package/src/client/components/__tests__/jant-compose-dialog.test.ts +33 -0
  20. package/src/client/components/__tests__/jant-compose-editor-rehost-notice.test.ts +81 -0
  21. package/src/client/components/__tests__/jant-nav-manager.test.ts +77 -1
  22. package/src/client/components/__tests__/jant-settings-avatar.test.ts +6 -0
  23. package/src/client/components/__tests__/jant-settings-general.test.ts +71 -0
  24. package/src/client/components/compose-types.ts +4 -0
  25. package/src/client/components/jant-command-palette.ts +67 -2
  26. package/src/client/components/jant-compose-dialog.ts +8 -2
  27. package/src/client/components/jant-compose-editor.ts +111 -0
  28. package/src/client/components/jant-nav-manager.ts +142 -2
  29. package/src/client/components/jant-settings-general.ts +56 -0
  30. package/src/client/components/nav-manager-types.ts +14 -0
  31. package/src/client/components/settings-types.ts +30 -0
  32. package/src/client/compose-bridge.ts +25 -8
  33. package/src/client/compose-launch.ts +30 -0
  34. package/src/client/compose-shortcuts.ts +46 -3
  35. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +143 -0
  36. package/src/client/tiptap/__tests__/paste-rehost-e2e.test.ts +65 -0
  37. package/src/client/tiptap/__tests__/rehost-images.test.ts +192 -0
  38. package/src/client/tiptap/create-editor.ts +3 -0
  39. package/src/client/tiptap/extensions.ts +4 -0
  40. package/src/client/tiptap/inline-image-upload.ts +174 -50
  41. package/src/client/tiptap/rehost-images.ts +108 -0
  42. package/src/i18n/__tests__/fallback.test.ts +24 -0
  43. package/src/i18n/locales/public/en.po +10 -0
  44. package/src/i18n/locales/public/en.ts +1 -1
  45. package/src/i18n/locales/public/zh-Hans.po +10 -0
  46. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  47. package/src/i18n/locales/public/zh-Hant.po +10 -0
  48. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  49. package/src/i18n/locales/settings/en.po +54 -0
  50. package/src/i18n/locales/settings/en.ts +1 -1
  51. package/src/i18n/locales/settings/zh-Hans.po +64 -10
  52. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  53. package/src/i18n/locales/settings/zh-Hant.po +56 -2
  54. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  55. package/src/lib/__tests__/feed.test.ts +96 -10
  56. package/src/lib/__tests__/upload-sideload.test.ts +78 -0
  57. package/src/lib/__tests__/url-fetch.test.ts +181 -0
  58. package/src/lib/feed.ts +63 -3
  59. package/src/lib/post-display.ts +14 -8
  60. package/src/lib/upload.ts +111 -0
  61. package/src/lib/url-fetch.ts +263 -0
  62. package/src/preset.css +60 -18
  63. package/src/routes/api/__tests__/uploads.test.ts +63 -1
  64. package/src/routes/api/uploads.ts +52 -0
  65. package/src/routes/dash/settings.tsx +41 -2
  66. package/src/routes/pages/__tests__/draft-about-edit.test.ts +65 -0
  67. package/src/routes/pages/page.tsx +44 -12
  68. package/src/services/__tests__/about-page.test.ts +116 -0
  69. package/src/services/__tests__/media.test.ts +168 -1
  70. package/src/services/__tests__/navigation.test.ts +190 -1
  71. package/src/services/__tests__/post-timeline.test.ts +119 -2
  72. package/src/services/about-page.ts +143 -0
  73. package/src/services/export-theme/layouts/_default/rss.xml +2 -1
  74. package/src/services/export-theme/layouts/partials/feed-post-content.xml +25 -1
  75. package/src/services/export-theme/styles/main.css +46 -14
  76. package/src/services/index.ts +27 -17
  77. package/src/services/media.ts +111 -0
  78. package/src/services/navigation.ts +155 -1
  79. package/src/services/post.ts +24 -7
  80. package/src/styles/components.css +20 -0
  81. package/src/styles/tokens.css +26 -8
  82. package/src/styles/ui.css +149 -7
  83. package/src/types/views.ts +13 -0
  84. package/src/ui/compose/ComposeDialog.tsx +16 -0
  85. package/src/ui/dash/appearance/NavigationContent.tsx +57 -0
  86. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +19 -0
  87. package/src/ui/dash/settings/GeneralContent.tsx +44 -0
  88. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +8 -0
  89. package/src/ui/feed/__tests__/timeline-cards.test.ts +162 -1
  90. package/src/ui/layouts/BaseLayout.tsx +12 -0
  91. package/dist/app-D24n0DoH.js +0 -6
  92. package/dist/client/_assets/client-xWDl78yi.css +0 -2
@@ -0,0 +1,181 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { assertPublicHttpUrl, fetchImageBytes } from "../url-fetch.js";
3
+ import { ValidationError } from "../errors.js";
4
+
5
+ describe("assertPublicHttpUrl", () => {
6
+ it("accepts public http(s) URLs", () => {
7
+ expect(assertPublicHttpUrl("https://example.com/a.png").href).toBe(
8
+ "https://example.com/a.png",
9
+ );
10
+ expect(assertPublicHttpUrl("http://cdn.example.com/b.jpg").hostname).toBe(
11
+ "cdn.example.com",
12
+ );
13
+ });
14
+
15
+ it.each([
16
+ ["ftp scheme", "ftp://example.com/a.png"],
17
+ ["data scheme", "data:image/png;base64,AAAA"],
18
+ ["embedded credentials", "https://user:pass@example.com/a.png"],
19
+ ["localhost", "http://localhost/a.png"],
20
+ ["sub.localhost", "http://api.localhost/a.png"],
21
+ ["0.0.0.0", "http://0.0.0.0/a.png"],
22
+ ["loopback v4", "http://127.0.0.1/a.png"],
23
+ ["private 10/8", "http://10.0.0.5/a.png"],
24
+ ["private 172.16/12", "http://172.16.5.5/a.png"],
25
+ ["private 192.168/16", "http://192.168.1.1/a.png"],
26
+ ["CGNAT 100.64/10", "http://100.64.0.1/a.png"],
27
+ ["link-local / metadata", "http://169.254.169.254/latest/meta-data"],
28
+ ["loopback v6", "http://[::1]/a.png"],
29
+ ["link-local v6", "http://[fe80::1]/a.png"],
30
+ ["ULA v6", "http://[fd00::1]/a.png"],
31
+ ["v4-mapped private v6", "http://[::ffff:10.0.0.1]/a.png"],
32
+ ])("rejects %s", (_label, url) => {
33
+ expect(() => assertPublicHttpUrl(url)).toThrow(ValidationError);
34
+ });
35
+
36
+ it("rejects a non-URL string", () => {
37
+ expect(() => assertPublicHttpUrl("not a url")).toThrow(ValidationError);
38
+ });
39
+ });
40
+
41
+ describe("fetchImageBytes", () => {
42
+ afterEach(() => {
43
+ vi.restoreAllMocks();
44
+ });
45
+
46
+ function pngBytes(length = 64): Uint8Array {
47
+ const bytes = new Uint8Array(length);
48
+ bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
49
+ return bytes;
50
+ }
51
+
52
+ it("returns the bytes and content-type on success", async () => {
53
+ const bytes = pngBytes();
54
+ vi.stubGlobal(
55
+ "fetch",
56
+ vi.fn(
57
+ async () =>
58
+ new Response(bytes, {
59
+ headers: { "content-type": "image/png" },
60
+ }),
61
+ ),
62
+ );
63
+
64
+ const result = await fetchImageBytes(new URL("https://example.com/a.png"), {
65
+ maxBytes: 1024,
66
+ timeoutMs: 5000,
67
+ });
68
+
69
+ expect(result.contentType).toBe("image/png");
70
+ expect(result.bytes.byteLength).toBe(bytes.byteLength);
71
+ });
72
+
73
+ it("sends a Referer matching the image origin (hotlink protection)", async () => {
74
+ const fetchMock = vi.fn(
75
+ async () =>
76
+ new Response(pngBytes(), { headers: { "content-type": "image/png" } }),
77
+ );
78
+ vi.stubGlobal("fetch", fetchMock);
79
+
80
+ await fetchImageBytes(
81
+ new URL("https://img9.doubanio.com/view/photo/p1.webp"),
82
+ { maxBytes: 1024, timeoutMs: 5000 },
83
+ );
84
+
85
+ const headers = fetchMock.mock.calls[0]?.[1]?.headers as
86
+ | Record<string, string>
87
+ | undefined;
88
+ expect(headers?.Referer).toBe("https://img9.doubanio.com/");
89
+ });
90
+
91
+ it("rejects via the Content-Length precheck", async () => {
92
+ vi.stubGlobal(
93
+ "fetch",
94
+ vi.fn(
95
+ async () =>
96
+ new Response(pngBytes(16), {
97
+ headers: {
98
+ "content-type": "image/png",
99
+ "content-length": "999999",
100
+ },
101
+ }),
102
+ ),
103
+ );
104
+
105
+ await expect(
106
+ fetchImageBytes(new URL("https://example.com/a.png"), {
107
+ maxBytes: 1024,
108
+ timeoutMs: 5000,
109
+ }),
110
+ ).rejects.toThrow(/too large/i);
111
+ });
112
+
113
+ it("aborts when the streamed body exceeds maxBytes (no Content-Length)", async () => {
114
+ const stream = new ReadableStream<Uint8Array>({
115
+ start(controller) {
116
+ controller.enqueue(new Uint8Array(2048));
117
+ controller.close();
118
+ },
119
+ });
120
+ vi.stubGlobal(
121
+ "fetch",
122
+ vi.fn(
123
+ async () =>
124
+ new Response(stream, {
125
+ headers: { "content-type": "image/png" },
126
+ }),
127
+ ),
128
+ );
129
+
130
+ await expect(
131
+ fetchImageBytes(new URL("https://example.com/a.png"), {
132
+ maxBytes: 1024,
133
+ timeoutMs: 5000,
134
+ }),
135
+ ).rejects.toThrow(/too large/i);
136
+ });
137
+
138
+ it("re-validates redirect targets and rejects a hop to a private address", async () => {
139
+ vi.stubGlobal(
140
+ "fetch",
141
+ vi.fn(
142
+ async () =>
143
+ new Response(null, {
144
+ status: 302,
145
+ headers: { location: "http://169.254.169.254/" },
146
+ }),
147
+ ),
148
+ );
149
+
150
+ await expect(
151
+ fetchImageBytes(new URL("https://example.com/a.png"), {
152
+ maxBytes: 1024,
153
+ timeoutMs: 5000,
154
+ }),
155
+ ).rejects.toThrow(ValidationError);
156
+ });
157
+
158
+ it("follows a redirect to a public target", async () => {
159
+ const bytes = pngBytes();
160
+ const fetchMock = vi
161
+ .fn()
162
+ .mockResolvedValueOnce(
163
+ new Response(null, {
164
+ status: 302,
165
+ headers: { location: "https://cdn.example.com/final.png" },
166
+ }),
167
+ )
168
+ .mockResolvedValueOnce(
169
+ new Response(bytes, { headers: { "content-type": "image/png" } }),
170
+ );
171
+ vi.stubGlobal("fetch", fetchMock);
172
+
173
+ const result = await fetchImageBytes(new URL("https://example.com/a.png"), {
174
+ maxBytes: 1024,
175
+ timeoutMs: 5000,
176
+ });
177
+
178
+ expect(fetchMock).toHaveBeenCalledTimes(2);
179
+ expect(result.bytes.byteLength).toBe(bytes.byteLength);
180
+ });
181
+ });
package/src/lib/feed.ts CHANGED
@@ -92,6 +92,49 @@ function getAtomTitle(post: PostView): string {
92
92
  return post.title || "";
93
93
  }
94
94
 
95
+ interface SinglePostContentOptions {
96
+ /**
97
+ * Inline posts do not have their own Atom entry title/link, so their visible
98
+ * feed HTML must carry title and link metadata that top-level entries expose
99
+ * through Atom fields.
100
+ */
101
+ inline?: boolean;
102
+ }
103
+
104
+ function renderLinkedText(text: string, href?: string): string {
105
+ const label = escapeXml(text);
106
+ return href ? `<a href="${escapeXml(href)}">${label}</a>` : label;
107
+ }
108
+
109
+ function renderInlinePostHeader(
110
+ post: PostView,
111
+ permalinkUrl?: string,
112
+ ): string[] {
113
+ if (post.format === "quote") return [];
114
+
115
+ const parts: string[] = [];
116
+
117
+ if (post.format === "link") {
118
+ const linkUrl = post.url || "";
119
+ const domain = linkUrl ? extractDisplayDomain(linkUrl) || linkUrl : "";
120
+ if (domain) {
121
+ parts.push(`<p>${renderLinkedText(domain, linkUrl)}</p>`);
122
+ }
123
+ if (post.title) {
124
+ parts.push(
125
+ `<h2>${renderLinkedText(post.title, linkUrl || permalinkUrl)}</h2>`,
126
+ );
127
+ }
128
+ return parts;
129
+ }
130
+
131
+ if (post.title) {
132
+ parts.push(`<h2>${renderLinkedText(post.title, permalinkUrl)}</h2>`);
133
+ }
134
+
135
+ return parts;
136
+ }
137
+
95
138
  /**
96
139
  * Render a star rating as HTML for feed content.
97
140
  */
@@ -242,10 +285,19 @@ function renderMediaForFeed(
242
285
  *
243
286
  * @param post - Post view data
244
287
  * @param permalinkUrl - Absolute permalink URL back to the blog post
288
+ * @param options - Rendering options for top-level versus inline posts
245
289
  */
246
- function buildSinglePostContent(post: PostView, permalinkUrl?: string): string {
290
+ function buildSinglePostContent(
291
+ post: PostView,
292
+ permalinkUrl?: string,
293
+ options: SinglePostContentOptions = {},
294
+ ): string {
247
295
  const parts: string[] = [];
248
296
 
297
+ if (options.inline) {
298
+ parts.push(...renderInlinePostHeader(post, permalinkUrl));
299
+ }
300
+
249
301
  if (post.format === "quote" && post.quoteText) {
250
302
  const sourceName = post.title || "";
251
303
  const sourceUrl = post.url || "";
@@ -316,12 +368,20 @@ function buildFeedContent(
316
368
  parts.push(
317
369
  `<p><small><time datetime="${escapeXml(reply.publishedAt)}">${escapeXml(reply.publishedAtFormatted)}</time></small></p>`,
318
370
  );
319
- parts.push(buildSinglePostContent(reply, replyPermalink));
371
+ parts.push(buildSinglePostContent(reply, replyPermalink, { inline: true }));
320
372
  }
321
373
 
322
374
  return parts.join("\n");
323
375
  }
324
376
 
377
+ function getEntryMedia(post: FeedPostView): MediaView[] {
378
+ const media = [...post.media];
379
+ for (const reply of post.threadReplies ?? []) {
380
+ media.push(...reply.media);
381
+ }
382
+ return media;
383
+ }
384
+
325
385
  /**
326
386
  * Default Atom feed renderer.
327
387
  *
@@ -354,7 +414,7 @@ export function defaultFeedRenderer(data: FeedData): string {
354
414
  // One <link rel="enclosure"> per attachment so podcast/offline readers
355
415
  // can fetch them. Atom omits length when size is unknown; mimeType is
356
416
  // always known from the upload pipeline.
357
- const enclosureLinks = post.media
417
+ const enclosureLinks = getEntryMedia(post)
358
418
  .map((m) => {
359
419
  const lengthAttr =
360
420
  m.size != null && m.size > 0 ? ` length="${m.size}"` : "";
@@ -79,9 +79,17 @@ function resolvePostSocialImage(
79
79
  return undefined;
80
80
  }
81
81
 
82
- function canViewPost(post: Post, isAuthenticated: boolean): boolean {
82
+ interface PostDisplayOptions {
83
+ isAuthenticated?: boolean;
84
+ allowDraft?: boolean;
85
+ }
86
+
87
+ function canViewPost(post: Post, options: PostDisplayOptions = {}): boolean {
88
+ const isAuthenticated = options.isAuthenticated ?? false;
83
89
  if (post.status !== "published") {
84
- return false;
90
+ return Boolean(
91
+ post.status === "draft" && isAuthenticated && options.allowDraft,
92
+ );
85
93
  }
86
94
 
87
95
  if (post.visibility === "private" && !isAuthenticated) {
@@ -102,11 +110,10 @@ function canViewPost(post: Post, isAuthenticated: boolean): boolean {
102
110
  export async function assemblePostCardView(
103
111
  c: Context<Env>,
104
112
  postId: string,
105
- options?: { isAuthenticated?: boolean },
113
+ options?: PostDisplayOptions,
106
114
  ): Promise<PostView | null> {
107
115
  const post = await c.var.services.posts.getById(postId);
108
- const isAuthenticated = options?.isAuthenticated ?? false;
109
- if (!post || !canViewPost(post, isAuthenticated)) {
116
+ if (!post || !canViewPost(post, options)) {
110
117
  return null;
111
118
  }
112
119
 
@@ -149,15 +156,14 @@ export async function assemblePostCardView(
149
156
  export async function assemblePostPageDisplay(
150
157
  c: Context<Env>,
151
158
  postOrId: string | Post,
152
- options?: { isAuthenticated?: boolean },
159
+ options?: PostDisplayOptions,
153
160
  ): Promise<PostPageDisplayData | null> {
154
161
  const post =
155
162
  typeof postOrId === "string"
156
163
  ? await c.var.services.posts.getById(postOrId)
157
164
  : postOrId;
158
- const isAuthenticated = options?.isAuthenticated ?? false;
159
165
 
160
- if (!post || !canViewPost(post, isAuthenticated)) {
166
+ if (!post || !canViewPost(post, options)) {
161
167
  return null;
162
168
  }
163
169
 
package/src/lib/upload.ts CHANGED
@@ -277,6 +277,117 @@ export function isImageMimeType(mimeType: string): boolean {
277
277
  return mimeType.startsWith("image/");
278
278
  }
279
279
 
280
+ /** Image MIME types accepted by the remote-image sideload path. */
281
+ const SIDELOAD_IMAGE_MIME_TYPES = new Set<string>(IMAGE_MIME_TYPES);
282
+
283
+ /** Map of sideload-accepted image MIME types to their file extensions. */
284
+ const IMAGE_MIME_EXTENSIONS: Record<string, string> = {
285
+ "image/jpeg": "jpg",
286
+ "image/png": "png",
287
+ "image/gif": "gif",
288
+ "image/webp": "webp",
289
+ "image/svg+xml": "svg",
290
+ "image/avif": "avif",
291
+ "image/bmp": "bmp",
292
+ "image/x-icon": "ico",
293
+ };
294
+
295
+ /**
296
+ * Whether a MIME type may be rehosted via remote-image sideload.
297
+ *
298
+ * Unlike {@link getStoredUploadPolicy} (which only allows webp/png/jpeg because
299
+ * the normal upload path re-encodes everything to WebP client-side), the
300
+ * sideload path stores the original remote bytes, so it accepts the full set of
301
+ * image formats Jant can display.
302
+ *
303
+ * @param contentType - The MIME type to check
304
+ * @returns Whether the type is an accepted sideload image
305
+ * @example
306
+ * ```ts
307
+ * isAllowedSideloadImageType("image/gif"); // true
308
+ * isAllowedSideloadImageType("text/html"); // false
309
+ * ```
310
+ */
311
+ export function isAllowedSideloadImageType(contentType: string): boolean {
312
+ return SIDELOAD_IMAGE_MIME_TYPES.has(contentType);
313
+ }
314
+
315
+ /**
316
+ * Returns the file extension for a sideload-accepted image MIME type.
317
+ *
318
+ * @param contentType - The image MIME type
319
+ * @returns Extension without a dot, or null if the type isn't sideloadable
320
+ * @example
321
+ * ```ts
322
+ * imageExtensionForMimeType("image/jpeg"); // "jpg"
323
+ * ```
324
+ */
325
+ export function imageExtensionForMimeType(contentType: string): string | null {
326
+ return IMAGE_MIME_EXTENSIONS[contentType] ?? null;
327
+ }
328
+
329
+ /**
330
+ * Identify an image format from its leading bytes (magic numbers), so a remote
331
+ * sideload can trust the actual content rather than the server's content-type
332
+ * header. Recognizes every {@link isAllowedSideloadImageType} format.
333
+ *
334
+ * @param bytes - The leading bytes of the file (≥ ~256 bytes recommended)
335
+ * @returns The detected image MIME type, or null if unrecognized
336
+ * @example
337
+ * ```ts
338
+ * sniffImageMimeType(pngBytes); // "image/png"
339
+ * ```
340
+ */
341
+ export function sniffImageMimeType(bytes: Uint8Array): string | null {
342
+ if (
343
+ bytes.length >= 3 &&
344
+ bytes[0] === 0xff &&
345
+ bytes[1] === 0xd8 &&
346
+ bytes[2] === 0xff
347
+ ) {
348
+ return "image/jpeg";
349
+ }
350
+ if (
351
+ bytes.length >= 8 &&
352
+ bytes[0] === 0x89 &&
353
+ readAscii(bytes, 1, 3) === "PNG"
354
+ ) {
355
+ return "image/png";
356
+ }
357
+ if (bytes.length >= 6 && /^GIF8[79]a$/.test(readAscii(bytes, 0, 6))) {
358
+ return "image/gif";
359
+ }
360
+ if (
361
+ bytes.length >= 12 &&
362
+ readAscii(bytes, 0, 4) === "RIFF" &&
363
+ readAscii(bytes, 8, 4) === "WEBP"
364
+ ) {
365
+ return "image/webp";
366
+ }
367
+ if (bytes.length >= 12 && readAscii(bytes, 4, 4) === "ftyp") {
368
+ const brand = readAscii(bytes, 8, 4);
369
+ if (brand === "avif" || brand === "avis") return "image/avif";
370
+ }
371
+ if (bytes.length >= 2 && bytes[0] === 0x42 && bytes[1] === 0x4d) {
372
+ return "image/bmp";
373
+ }
374
+ if (
375
+ bytes.length >= 4 &&
376
+ bytes[0] === 0x00 &&
377
+ bytes[1] === 0x00 &&
378
+ bytes[2] === 0x01 &&
379
+ bytes[3] === 0x00
380
+ ) {
381
+ return "image/x-icon";
382
+ }
383
+ // SVG is text — look for an <svg> root in the leading bytes.
384
+ const head = new TextDecoder().decode(bytes.subarray(0, 1024)).toLowerCase();
385
+ if (head.includes("<svg")) {
386
+ return "image/svg+xml";
387
+ }
388
+ return null;
389
+ }
390
+
280
391
  export interface ValidateUploadOptions {
281
392
  /** When true, only image MIME types are accepted (e.g. for avatar uploads). */
282
393
  imagesOnly?: boolean;