@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
@@ -1320,6 +1320,97 @@ function buildJantBrandPackReadme() {
1320
1320
  return null;
1321
1321
  }
1322
1322
  //#endregion
1323
+ //#region src/lib/image.ts
1324
+ /**
1325
+ * Generates an image URL with optional transformations.
1326
+ *
1327
+ * If `transformUrl` is provided and options are specified, returns a transformed image URL.
1328
+ * Otherwise, returns the original URL unchanged.
1329
+ *
1330
+ * Compatible with:
1331
+ * - Cloudflare Image Transformations (`/cdn-cgi/image/...`)
1332
+ * - imgproxy
1333
+ * - Cloudinary
1334
+ * - Any service with similar URL-based transformation API
1335
+ *
1336
+ * @param originalUrl - The original image URL
1337
+ * @param transformUrl - The base URL for transformations (e.g., `https://example.com/cdn-cgi/image`)
1338
+ * @param options - Transformation options (width, height, quality, format, fit)
1339
+ * @returns The transformed URL or original URL if transformations are not configured
1340
+ *
1341
+ * @example
1342
+ * ```ts
1343
+ * // Without transform URL - returns original
1344
+ * getImageUrl("/media/abc123", undefined, { width: 200 });
1345
+ * // Returns: "/media/abc123"
1346
+ *
1347
+ * // With transform URL - returns transformed
1348
+ * getImageUrl("/media/abc123", "https://example.com/cdn-cgi/image", { width: 200, quality: 80 });
1349
+ * // Returns: "https://example.com/cdn-cgi/image/width=200,quality=80/https://example.com/media/abc123"
1350
+ * ```
1351
+ */ function getImageUrl(originalUrl, transformUrl, options) {
1352
+ if (!transformUrl || !options || Object.keys(options).length === 0) return originalUrl;
1353
+ const params = [];
1354
+ if (options.width) params.push(`width=${options.width}`);
1355
+ if (options.height) params.push(`height=${options.height}`);
1356
+ if (options.quality) params.push(`quality=${options.quality}`);
1357
+ if (options.format) params.push(`format=${options.format}`);
1358
+ if (options.fit) params.push(`fit=${options.fit}`);
1359
+ if (params.length === 0) return originalUrl;
1360
+ return `${transformUrl}/${params.join(",")}/${originalUrl}`;
1361
+ }
1362
+ /**
1363
+ * Returns the appropriate public URL base for a given storage provider.
1364
+ *
1365
+ * For `"s3"` provider, returns `s3PublicUrl`. For all other providers
1366
+ * (including `"r2"`), returns `r2PublicUrl`. Falls back to `undefined`
1367
+ * if the matching URL is not configured.
1368
+ *
1369
+ * @param provider - The storage provider identifier (e.g., `"r2"`, `"s3"`)
1370
+ * @param r2PublicUrl - Optional R2 public URL
1371
+ * @param s3PublicUrl - Optional S3 public URL
1372
+ * @returns The public URL base for the provider, or undefined
1373
+ *
1374
+ * @example
1375
+ * ```ts
1376
+ * getPublicUrlForProvider("r2", "https://r2.example.com", "https://s3.example.com");
1377
+ * // Returns: "https://r2.example.com"
1378
+ *
1379
+ * getPublicUrlForProvider("s3", "https://r2.example.com", "https://s3.example.com");
1380
+ * // Returns: "https://s3.example.com"
1381
+ * ```
1382
+ */ function getPublicUrlForProvider(provider, r2PublicUrl, s3PublicUrl, localPublicUrl) {
1383
+ if (provider === "s3") return s3PublicUrl;
1384
+ if (provider === "local") return localPublicUrl;
1385
+ return r2PublicUrl;
1386
+ }
1387
+ /**
1388
+ * Generates a media URL from a storage key.
1389
+ *
1390
+ * Both proxy and CDN paths use the same structure — only the domain differs.
1391
+ * Without a public URL, returns a root-relative path for the local proxy.
1392
+ * With a public URL, prefixes that domain.
1393
+ *
1394
+ * @param storageKey - The storage object key (e.g. `"media/med_01jpx7gb2w4rcg9w82g5r8kkx3.webp"`)
1395
+ * @param publicUrl - Optional public URL base for direct CDN access
1396
+ * @returns The public URL for the media file
1397
+ *
1398
+ * @example
1399
+ * ```ts
1400
+ * // Without public URL - local proxy
1401
+ * getMediaUrl("media/med_01jpx7gb2w4rcg9w82g5r8kkx3.webp");
1402
+ * // Returns: "/media/med_01jpx7gb2w4rcg9w82g5r8kkx3.webp"
1403
+ *
1404
+ * // With public URL - CDN
1405
+ * getMediaUrl("media/med_01jpx7gb2w4rcg9w82g5r8kkx3.webp", "https://cdn.example.com");
1406
+ * // Returns: "https://cdn.example.com/media/med_01jpx7gb2w4rcg9w82g5r8kkx3.webp"
1407
+ * ```
1408
+ */ function getMediaUrl(storageKey, publicUrl, sitePathPrefix = "") {
1409
+ const base = publicUrl ? publicUrl.replace(/\/+$/, "") : "";
1410
+ if (base) return `${base}/${storageKey}`;
1411
+ return toPublicPath(`/${storageKey}`, sitePathPrefix);
1412
+ }
1413
+ //#endregion
1323
1414
  //#region src/lib/time.ts
1324
1415
  var time_exports = /* @__PURE__ */ __exportAll({
1325
1416
  formatDate: () => formatDate,
@@ -1524,97 +1615,6 @@ function formatYearMonth(timestamp, timeZone = "UTC") {
1524
1615
  return `${parts.find((part) => part.type === "year")?.value ?? "1970"}-${parts.find((part) => part.type === "month")?.value ?? "01"}`;
1525
1616
  }
1526
1617
  //#endregion
1527
- //#region src/lib/image.ts
1528
- /**
1529
- * Generates an image URL with optional transformations.
1530
- *
1531
- * If `transformUrl` is provided and options are specified, returns a transformed image URL.
1532
- * Otherwise, returns the original URL unchanged.
1533
- *
1534
- * Compatible with:
1535
- * - Cloudflare Image Transformations (`/cdn-cgi/image/...`)
1536
- * - imgproxy
1537
- * - Cloudinary
1538
- * - Any service with similar URL-based transformation API
1539
- *
1540
- * @param originalUrl - The original image URL
1541
- * @param transformUrl - The base URL for transformations (e.g., `https://example.com/cdn-cgi/image`)
1542
- * @param options - Transformation options (width, height, quality, format, fit)
1543
- * @returns The transformed URL or original URL if transformations are not configured
1544
- *
1545
- * @example
1546
- * ```ts
1547
- * // Without transform URL - returns original
1548
- * getImageUrl("/media/abc123", undefined, { width: 200 });
1549
- * // Returns: "/media/abc123"
1550
- *
1551
- * // With transform URL - returns transformed
1552
- * getImageUrl("/media/abc123", "https://example.com/cdn-cgi/image", { width: 200, quality: 80 });
1553
- * // Returns: "https://example.com/cdn-cgi/image/width=200,quality=80/https://example.com/media/abc123"
1554
- * ```
1555
- */ function getImageUrl(originalUrl, transformUrl, options) {
1556
- if (!transformUrl || !options || Object.keys(options).length === 0) return originalUrl;
1557
- const params = [];
1558
- if (options.width) params.push(`width=${options.width}`);
1559
- if (options.height) params.push(`height=${options.height}`);
1560
- if (options.quality) params.push(`quality=${options.quality}`);
1561
- if (options.format) params.push(`format=${options.format}`);
1562
- if (options.fit) params.push(`fit=${options.fit}`);
1563
- if (params.length === 0) return originalUrl;
1564
- return `${transformUrl}/${params.join(",")}/${originalUrl}`;
1565
- }
1566
- /**
1567
- * Returns the appropriate public URL base for a given storage provider.
1568
- *
1569
- * For `"s3"` provider, returns `s3PublicUrl`. For all other providers
1570
- * (including `"r2"`), returns `r2PublicUrl`. Falls back to `undefined`
1571
- * if the matching URL is not configured.
1572
- *
1573
- * @param provider - The storage provider identifier (e.g., `"r2"`, `"s3"`)
1574
- * @param r2PublicUrl - Optional R2 public URL
1575
- * @param s3PublicUrl - Optional S3 public URL
1576
- * @returns The public URL base for the provider, or undefined
1577
- *
1578
- * @example
1579
- * ```ts
1580
- * getPublicUrlForProvider("r2", "https://r2.example.com", "https://s3.example.com");
1581
- * // Returns: "https://r2.example.com"
1582
- *
1583
- * getPublicUrlForProvider("s3", "https://r2.example.com", "https://s3.example.com");
1584
- * // Returns: "https://s3.example.com"
1585
- * ```
1586
- */ function getPublicUrlForProvider(provider, r2PublicUrl, s3PublicUrl, localPublicUrl) {
1587
- if (provider === "s3") return s3PublicUrl;
1588
- if (provider === "local") return localPublicUrl;
1589
- return r2PublicUrl;
1590
- }
1591
- /**
1592
- * Generates a media URL from a storage key.
1593
- *
1594
- * Both proxy and CDN paths use the same structure — only the domain differs.
1595
- * Without a public URL, returns a root-relative path for the local proxy.
1596
- * With a public URL, prefixes that domain.
1597
- *
1598
- * @param storageKey - The storage object key (e.g. `"media/med_01jpx7gb2w4rcg9w82g5r8kkx3.webp"`)
1599
- * @param publicUrl - Optional public URL base for direct CDN access
1600
- * @returns The public URL for the media file
1601
- *
1602
- * @example
1603
- * ```ts
1604
- * // Without public URL - local proxy
1605
- * getMediaUrl("media/med_01jpx7gb2w4rcg9w82g5r8kkx3.webp");
1606
- * // Returns: "/media/med_01jpx7gb2w4rcg9w82g5r8kkx3.webp"
1607
- *
1608
- * // With public URL - CDN
1609
- * getMediaUrl("media/med_01jpx7gb2w4rcg9w82g5r8kkx3.webp", "https://cdn.example.com");
1610
- * // Returns: "https://cdn.example.com/media/med_01jpx7gb2w4rcg9w82g5r8kkx3.webp"
1611
- * ```
1612
- */ function getMediaUrl(storageKey, publicUrl, sitePathPrefix = "") {
1613
- const base = publicUrl ? publicUrl.replace(/\/+$/, "") : "";
1614
- if (base) return `${base}/${storageKey}`;
1615
- return toPublicPath(`/${storageKey}`, sitePathPrefix);
1616
- }
1617
- //#endregion
1618
1618
  //#region src/lib/footnotes.ts
1619
1619
  /**
1620
1620
  * Shared Footnote Helpers
@@ -4334,4 +4334,4 @@ Safe to re-run; files already on disk are reused. Anything that fails to downloa
4334
4334
  `;
4335
4335
  }
4336
4336
  //#endregion
4337
- export { JANT_POSITIVE_LOGO_PNG_FILENAME as A, getJantLogoHref as B, formatYearMonth as C, HOME_BRANDING_LINK_LABEL as D, toISOString as E, getJantBundledAsset as F, base64ToUint8Array as G, JANT_LOGO_PATH_DATA as H, getJantIconFilename as I, getJantIconHref as L, getDefaultJantAppleTouchIconBytes as M, getDefaultJantFaviconIcoBytes as N, HOME_BRANDING_PREFIX as O, getJantBrandPackHref as P, getJantLogoFilename as R, formatTime as S, time_exports as T, JANT_LOGO_VIEW_BOX as U, getJantPositiveLogoPngHref as V, arrayBufferToBase64 as W, getMediaUrl as _, markdown_exports as a, formatRelativeAge as b, parseMarkdownDocument as c, extractSummaryHtml as d, renderTiptapDocument as f, getImageUrl as g, escapeHtml as h, tiptapJsonToMarkdown as i, JANT_REPO_URL as j, JANT_BRAND_PACK_FILENAME as k, extractBodyText as l, trimTiptapBody as m, export_exports as n, render as o, renderTiptapJson as p, parseFrontMatter as r, toPlainText as s, createExportService as t, extractSummary as u, getPublicUrlForProvider as v, now as w, formatRelativeTime as x, formatDate as y, getJantLogoFills as z };
4337
+ export { JANT_POSITIVE_LOGO_PNG_FILENAME as A, getJantLogoHref as B, toISOString as C, HOME_BRANDING_LINK_LABEL as D, getPublicUrlForProvider as E, getJantBundledAsset as F, base64ToUint8Array as G, JANT_LOGO_PATH_DATA as H, getJantIconFilename as I, getJantIconHref as L, getDefaultJantAppleTouchIconBytes as M, getDefaultJantFaviconIcoBytes as N, HOME_BRANDING_PREFIX as O, getJantBrandPackHref as P, getJantLogoFilename as R, time_exports as S, getMediaUrl as T, JANT_LOGO_VIEW_BOX as U, getJantPositiveLogoPngHref as V, arrayBufferToBase64 as W, formatRelativeAge as _, markdown_exports as a, formatYearMonth as b, parseMarkdownDocument as c, extractSummaryHtml as d, renderTiptapDocument as f, formatDate as g, escapeHtml as h, tiptapJsonToMarkdown as i, JANT_REPO_URL as j, JANT_BRAND_PACK_FILENAME as k, extractBodyText as l, trimTiptapBody as m, export_exports as n, render as o, renderTiptapJson as p, parseFrontMatter as r, toPlainText as s, createExportService as t, extractSummary as u, formatRelativeTime as v, getImageUrl as w, now as x, formatTime as y, getJantLogoFills as z };
@@ -1,4 +1,4 @@
1
1
  import "./url-BMYO-Zlt.js";
2
- import "./export-DY1v5Iqu.js";
3
- import { i as classifyRepoForSync, o as createGitHubSyncService } from "./github-sync-2_T7nbOv.js";
2
+ import "./export-Ba7NJImL.js";
3
+ import { i as classifyRepoForSync, o as createGitHubSyncService } from "./github-sync-Cb4_6_i7.js";
4
4
  export { classifyRepoForSync, createGitHubSyncService };
@@ -1,4 +1,4 @@
1
- import { c as parseMarkdownDocument, r as parseFrontMatter, t as createExportService } from "./export-DY1v5Iqu.js";
1
+ import { c as parseMarkdownDocument, r as parseFrontMatter, t as createExportService } from "./export-Ba7NJImL.js";
2
2
  import { r as getInstallationToken } from "./github-app-BbklkFmU.js";
3
3
  import { r as parseRepoSlug, t as createGitHubClient } from "./github-api-BgSiE71w.js";
4
4
  //#region src/lib/markdown-to-tiptap.ts
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { y as url_exports } from "./url-BMYO-Zlt.js";
2
- import { A as MAX_MEDIA_ATTACHMENTS, C as toMediaView, D as toPostViews, E as toPostView, F as STATUSES, I as TEXT_ATTACHMENT_CONTENT_FORMATS, M as MEDIA_KINDS, N as NAV_ITEM_TYPES, O as toSearchResultView, P as SORT_ORDERS, S as toArchiveGroupsWithMedia, T as toNavItemViews, b as createMediaContext, j as MAX_PINNED_POSTS, k as FORMATS, m as defaultFeedRenderer, t as createApp, w as toNavItemView, x as toArchiveGroups } from "./app-CGHkOdme.js";
3
- import { T as time_exports, a as markdown_exports } from "./export-DY1v5Iqu.js";
2
+ import { A as MAX_MEDIA_ATTACHMENTS, C as toMediaView, D as toPostViews, E as toPostView, F as STATUSES, I as TEXT_ATTACHMENT_CONTENT_FORMATS, M as MEDIA_KINDS, N as NAV_ITEM_TYPES, O as toSearchResultView, P as SORT_ORDERS, S as toArchiveGroupsWithMedia, T as toNavItemViews, b as createMediaContext, j as MAX_PINNED_POSTS, k as FORMATS, m as defaultFeedRenderer, t as createApp, w as toNavItemView, x as toArchiveGroups } from "./app-CpmficmQ.js";
3
+ import { S as time_exports, a as markdown_exports } from "./export-Ba7NJImL.js";
4
4
  import "./env-OHRKGcMj.js";
5
- import "./github-sync-2_T7nbOv.js";
5
+ import "./github-sync-Cb4_6_i7.js";
6
6
  export { FORMATS, MAX_MEDIA_ATTACHMENTS, MAX_PINNED_POSTS, MEDIA_KINDS, NAV_ITEM_TYPES, SORT_ORDERS, STATUSES, TEXT_ATTACHMENT_CONTENT_FORMATS, createApp, createMediaContext, defaultFeedRenderer, markdown_exports as markdown, time_exports as time, toArchiveGroups, toArchiveGroupsWithMedia, toMediaView, toNavItemView, toNavItemViews, toPostView, toPostViews, toSearchResultView, url_exports as url };
package/dist/node.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import "./url-BMYO-Zlt.js";
2
- import { B as isAssetPath, L as buildThemeStyle, R as BUILTIN_COLOR_THEMES, _ as sqliteSchemaBundle, a as resolveDatabaseDialect, c as getWebhookUrl, d as BUILTIN_FONT_THEMES, f as getCjkSerifCssVariables, g as pgSchemaBundle, h as createStorageDriver, i as createSiteService, l as setMyCommands, n as createNodeCliRuntime, o as getHostBasedStartupConfigurationIssues, p as getFontThemeCssVariables, r as createNodeRequestRuntime, s as resolveConfig, t as createApp, u as setWebhook, v as createNodeDatabase, y as schema_exports, z as getPublicAssetBasePath } from "./app-CGHkOdme.js";
3
- import { t as createExportService } from "./export-DY1v5Iqu.js";
2
+ import { B as isAssetPath, L as buildThemeStyle, R as BUILTIN_COLOR_THEMES, _ as sqliteSchemaBundle, a as resolveDatabaseDialect, c as getWebhookUrl, d as BUILTIN_FONT_THEMES, f as getCjkSerifCssVariables, g as pgSchemaBundle, h as createStorageDriver, i as createSiteService, l as setMyCommands, n as createNodeCliRuntime, o as getHostBasedStartupConfigurationIssues, p as getFontThemeCssVariables, r as createNodeRequestRuntime, s as resolveConfig, t as createApp, u as setWebhook, v as createNodeDatabase, y as schema_exports, z as getPublicAssetBasePath } from "./app-CpmficmQ.js";
3
+ import { t as createExportService } from "./export-Ba7NJImL.js";
4
4
  import { C as shouldTrustProxy, S as getTelegramWebhookSecret, b as getSiteResolutionMode, d as getHostedControlPlaneBaseUrl, i as getConfiguredSingleSitePathPrefix, l as getEnvString, r as getConfiguredSingleSiteOrigin, x as getTelegramBotPool, y as getPort } from "./env-OHRKGcMj.js";
5
- import "./github-sync-2_T7nbOv.js";
5
+ import "./github-sync-Cb4_6_i7.js";
6
6
  import { drizzle } from "drizzle-orm/better-sqlite3";
7
7
  import { serve } from "@hono/node-server";
8
8
  import Database from "better-sqlite3";
@@ -529,7 +529,7 @@ async function createNodeRequestHandler(options) {
529
529
  async function start(env = process.env, app) {
530
530
  const handler = await createNodeRequestHandler({
531
531
  env,
532
- app: async () => app ?? (await import("./app-D24n0DoH.js")).createApp()
532
+ app: async () => app ?? (await import("./app-DqKkZenB.js")).createApp()
533
533
  });
534
534
  const hostname = resolveHost(env);
535
535
  const port = resolvePort(env);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jant/core",
3
- "version": "0.6.10",
3
+ "version": "0.6.11",
4
4
  "description": "A modern, open-source microblogging platform built on Cloudflare Workers",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,62 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import type { Editor } from "@tiptap/core";
5
+ import type { ComposeLabels } from "../compose-types.js";
6
+ import "../jant-compose-editor.js";
7
+ import type { JantComposeEditor } from "../jant-compose-editor.js";
8
+
9
+ const labels = {
10
+ bodyPlaceholder: "What's on your mind...",
11
+ imageNotRehosted: "An image couldn't be saved — its original link was kept.",
12
+ imagesNotRehosted:
13
+ "{count} images couldn't be saved — their original links were kept.",
14
+ } as unknown as ComposeLabels;
15
+
16
+ function editorOf(el: JantComposeEditor): Editor {
17
+ const editor = (el as unknown as { _editor?: Editor | null })._editor;
18
+ if (!editor) throw new Error("expected compose editor instance");
19
+ return editor;
20
+ }
21
+
22
+ const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
23
+
24
+ beforeEach(() => {
25
+ const container = document.createElement("div");
26
+ container.id = "toast-container";
27
+ document.body.appendChild(container);
28
+ });
29
+
30
+ afterEach(() => {
31
+ document.body.innerHTML = "";
32
+ vi.restoreAllMocks();
33
+ });
34
+
35
+ describe("compose editor: rehost failure notice", () => {
36
+ it("shows an error toast when a pasted remote image can't be rehosted", async () => {
37
+ // Server rejects the sideload (e.g. host hotlink protection).
38
+ vi.stubGlobal(
39
+ "fetch",
40
+ vi.fn(async () => new Response("blocked", { status: 403 })),
41
+ );
42
+
43
+ const el = document.createElement(
44
+ "jant-compose-editor",
45
+ ) as JantComposeEditor;
46
+ el.format = "note";
47
+ el.labels = labels;
48
+ document.body.appendChild(el);
49
+ await el.updateComplete;
50
+
51
+ editorOf(el).commands.setImage({ src: "https://ext.example/blocked.png" });
52
+
53
+ // Let the rehost fire, the sideload fail, then the debounce window elapse.
54
+ await wait(50);
55
+ await wait(900);
56
+
57
+ const toast = document.querySelector("#toast-container .toast");
58
+ expect(toast).not.toBeNull();
59
+ expect(toast?.className).toContain("toast-error");
60
+ expect(toast?.textContent ?? "").toContain("couldn't be saved");
61
+ });
62
+ });
@@ -145,6 +145,10 @@ export interface ComposeLabels {
145
145
  addMore: string;
146
146
  removeAttachment: string;
147
147
  uploading: string;
148
+ /** Toast when exactly one pasted remote image couldn't be rehosted. */
149
+ imageNotRehosted?: string;
150
+ /** Toast when several pasted remote images couldn't be rehosted (uses a {count} placeholder). */
151
+ imagesNotRehosted?: string;
148
152
  loadingPost: string;
149
153
  loadPostFailed: string;
150
154
  published: string;
@@ -46,7 +46,11 @@ import { createTiptapEditor } from "../tiptap/create-editor.js";
46
46
  import {
47
47
  uploadAndInsertInlineImage,
48
48
  adoptPendingInlineImageUploads,
49
+ rehostInlineImage,
50
+ sideloadImage,
49
51
  } from "../tiptap/inline-image-upload.js";
52
+ import { clearRehostInFlight } from "../tiptap/rehost-images.js";
53
+ import { uploadWithMetadata } from "../upload-with-metadata.js";
50
54
  import { getClipboardFiles } from "../tiptap/paste-media.js";
51
55
  import { isSafeAbsoluteUrl } from "../../lib/url.js";
52
56
  import { randomUUID } from "../random-uuid.js";
@@ -247,6 +251,8 @@ export class JantComposeEditor extends LitElement {
247
251
  private _suppressContentChangedOnce = false;
248
252
  #inlineImageUploadGeneration = 0;
249
253
  #inlineImageUploadPromises = new Set<Promise<void>>();
254
+ #rehostFailureCount = 0;
255
+ #rehostFailureNoticeTimer: ReturnType<typeof setTimeout> | undefined;
250
256
  #sortable: { destroy(): void } | null = null;
251
257
  #revertNextSibling: globalThis.Node | null = null;
252
258
 
@@ -429,6 +435,102 @@ export class JantComposeEditor extends LitElement {
429
435
  return uploadPromise;
430
436
  }
431
437
 
438
+ /**
439
+ * Decide whether a pasted image node's `src` should be rehosted into our own
440
+ * storage. Skips images that are already ours (same-origin or on the
441
+ * configured media CDN), relative paths, and `blob:` placeholders (handled by
442
+ * the insert flow). Remote `http(s)` and inline `data:` srcs are rehosted.
443
+ */
444
+ #shouldRehostSrc(src: string): boolean {
445
+ if (src.startsWith("data:")) return true;
446
+ if (!/^https?:\/\//i.test(src)) return false;
447
+ let origin: string;
448
+ try {
449
+ origin = new URL(src).origin;
450
+ } catch {
451
+ return false;
452
+ }
453
+ if (origin === window.location.origin) return false;
454
+ const mediaBase = document.documentElement.dataset.mediaBase;
455
+ if (mediaBase && src.startsWith(mediaBase)) return false;
456
+ return true;
457
+ }
458
+
459
+ /**
460
+ * Rehost a pasted inline image into our storage and swap its `src`. Remote
461
+ * URLs go through the server sideload endpoint (bypasses CORS); `data:` URLs
462
+ * are decoded locally and run through the normal client upload pipeline
463
+ * (gaining WebP/resize/blurhash). Tracked like other inline uploads so submit
464
+ * waits for it; failures leave the node with its original src.
465
+ */
466
+ #rehostInlineImage(src: string) {
467
+ const editor = this._editor;
468
+ if (!editor) {
469
+ clearRehostInFlight(src);
470
+ return;
471
+ }
472
+
473
+ const resolver = src.startsWith("data:")
474
+ ? async () => {
475
+ const blob = await (await fetch(src)).blob();
476
+ const file = new File([blob], "pasted-image", {
477
+ type: blob.type || "image/png",
478
+ });
479
+ return (await uploadWithMetadata(file)).url;
480
+ }
481
+ : async () => (await sideloadImage(src)).url;
482
+
483
+ // Surface failures so a blocked rehost (e.g. a host's hotlink protection)
484
+ // isn't silent — the node keeps its original link, but the author is told.
485
+ const trackedResolver = async () => {
486
+ try {
487
+ return await resolver();
488
+ } catch (error) {
489
+ this.#noteRehostFailure();
490
+ throw error;
491
+ }
492
+ };
493
+
494
+ const generation = this.#inlineImageUploadGeneration;
495
+ const rehostPromise = rehostInlineImage(
496
+ editor,
497
+ src,
498
+ trackedResolver,
499
+ ).finally(() => {
500
+ clearRehostInFlight(src);
501
+ if (generation !== this.#inlineImageUploadGeneration) return;
502
+ this.#inlineImageUploadPromises.delete(rehostPromise);
503
+ });
504
+ this.#inlineImageUploadPromises.add(rehostPromise);
505
+ }
506
+
507
+ /** Count a failed rehost and debounce a single batched notice. */
508
+ #noteRehostFailure() {
509
+ this.#rehostFailureCount += 1;
510
+ if (this.#rehostFailureNoticeTimer !== undefined) return;
511
+ this.#rehostFailureNoticeTimer = setTimeout(() => {
512
+ this.#flushRehostFailureNotice();
513
+ }, 800);
514
+ }
515
+
516
+ /** Show one toast summarizing all rehost failures collected in the window. */
517
+ #flushRehostFailureNotice() {
518
+ this.#rehostFailureNoticeTimer = undefined;
519
+ const count = this.#rehostFailureCount;
520
+ this.#rehostFailureCount = 0;
521
+ if (count <= 0 || !this.isConnected) return;
522
+
523
+ const message =
524
+ count === 1
525
+ ? (this.labels.imageNotRehosted ??
526
+ "An image couldn't be saved to your library — its original link was kept.")
527
+ : (
528
+ this.labels.imagesNotRehosted ??
529
+ "{count} images couldn't be saved to your library — their original links were kept."
530
+ ).replace("{count}", String(count));
531
+ showToast(message, "error");
532
+ }
533
+
432
534
  hasPendingInlineImageUploads(): boolean {
433
535
  return this.#inlineImageUploadPromises.size > 0;
434
536
  }
@@ -446,6 +548,11 @@ export class JantComposeEditor extends LitElement {
446
548
  #clearPendingInlineImageUploads() {
447
549
  this.#inlineImageUploadGeneration += 1;
448
550
  this.#inlineImageUploadPromises.clear();
551
+ if (this.#rehostFailureNoticeTimer !== undefined) {
552
+ clearTimeout(this.#rehostFailureNoticeTimer);
553
+ this.#rehostFailureNoticeTimer = undefined;
554
+ }
555
+ this.#rehostFailureCount = 0;
449
556
  }
450
557
 
451
558
  /** Adopt in-flight inline image uploads from another editor (e.g. fullscreen). */
@@ -824,6 +931,10 @@ export class JantComposeEditor extends LitElement {
824
931
  this.addFiles(files);
825
932
  },
826
933
  },
934
+ rehostImages: {
935
+ shouldRehost: (src) => this.#shouldRehostSrc(src),
936
+ rehost: (src) => this.#rehostInlineImage(src),
937
+ },
827
938
  });
828
939
  this._lastEditorSelection = this._readEditorSelection();
829
940
 
@@ -32,7 +32,23 @@ import { uploadViaSession } from "./upload-session.js";
32
32
  import { publicPath } from "./runtime-paths.js";
33
33
  import { tiptapJsonToMarkdown } from "../lib/tiptap-to-markdown.js";
34
34
  import { getMediaCategory } from "../lib/upload.js";
35
- import { resolveInlineImageUrls } from "./tiptap/inline-image-upload.js";
35
+ import {
36
+ resolveInlineImageUrls,
37
+ hasPendingInlineImagePlaceholders,
38
+ } from "./tiptap/inline-image-upload.js";
39
+
40
+ /**
41
+ * Whether a serialized post body still references inline image placeholders
42
+ * pending upload or paste-rehost. Drives the "uploading" toast and whether to
43
+ * resolve placeholders before submit.
44
+ */
45
+ function bodyHasPendingInline(body: string): boolean {
46
+ try {
47
+ return hasPendingInlineImagePlaceholders(JSON.parse(body));
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
36
52
 
37
53
  function getComposeEditorFromEventTarget(
38
54
  target: globalThis.EventTarget | null,
@@ -679,10 +695,10 @@ document.addEventListener("jant:compose-submit-deferred", async (e: Event) => {
679
695
  // Get labels for toast messages
680
696
  const labels = composeEl?.labels;
681
697
  const uploadingMsg = labels?.uploading ?? "Uploading...";
682
- const hasInlineBlobs = detail.threadPosts
683
- ? detail.threadPosts.some((p) => p.body.includes('"blob:'))
684
- : detail.body.includes('"blob:');
685
- const hasPending = detail.pendingAttachments.length > 0 || hasInlineBlobs;
698
+ const hasInlinePending = detail.threadPosts
699
+ ? detail.threadPosts.some((p) => bodyHasPendingInline(p.body))
700
+ : bodyHasPendingInline(detail.body);
701
+ const hasPending = detail.pendingAttachments.length > 0 || hasInlinePending;
686
702
  const publishedMsg = labels?.published ?? "Published!";
687
703
  const viewLabel = labels?.view ?? "View";
688
704
 
@@ -831,7 +847,7 @@ document.addEventListener("jant:compose-submit-deferred", async (e: Event) => {
831
847
  const resolvedPosts = await Promise.all(
832
848
  threadPosts.map(async (post) => {
833
849
  let body = post.body;
834
- if (body.includes('"blob:')) {
850
+ if (bodyHasPendingInline(body)) {
835
851
  try {
836
852
  const bodyJson = JSON.parse(body);
837
853
  const resolved = await resolveInlineImageUrls(bodyJson);
@@ -940,8 +956,9 @@ document.addEventListener("jant:compose-submit-deferred", async (e: Event) => {
940
956
  mediaClientIdMap,
941
957
  );
942
958
 
943
- // Resolve any blob: inline image URLs to real URLs before submitting
944
- if (hasInlineBlobs) {
959
+ // Resolve any pending inline image placeholders (blob upload + paste rehost)
960
+ // to their stored URLs before submitting.
961
+ if (hasInlinePending) {
945
962
  try {
946
963
  const bodyJson = JSON.parse(detail.body);
947
964
  const resolved = await resolveInlineImageUrls(bodyJson);