@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.
- package/dist/{app-CGHkOdme.js → app-CpmficmQ.js} +531 -204
- package/dist/app-DqKkZenB.js +6 -0
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-BhHHVvSY.css +2 -0
- package/dist/client/_assets/{client-DYrWuaIk.js → client-Dd9U383b.js} +1 -1
- package/dist/client/_assets/{client-auth-B5Re0uCd.js → client-auth-DkpSdIDz.js} +80 -80
- package/dist/{export-DY1v5Iqu.js → export-Ba7NJImL.js} +92 -92
- package/dist/{github-sync-LefaslGJ.js → github-sync-BD4w2m8-.js} +2 -2
- package/dist/{github-sync-2_T7nbOv.js → github-sync-Cb4_6_i7.js} +1 -1
- package/dist/index.js +3 -3
- package/dist/node.js +4 -4
- package/package.json +1 -1
- package/src/client/components/__tests__/jant-compose-editor-rehost-notice.test.ts +62 -0
- package/src/client/components/compose-types.ts +4 -0
- package/src/client/components/jant-compose-editor.ts +111 -0
- package/src/client/compose-bridge.ts +25 -8
- package/src/client/tiptap/__tests__/inline-image-upload.test.ts +143 -0
- package/src/client/tiptap/__tests__/paste-rehost-e2e.test.ts +65 -0
- package/src/client/tiptap/__tests__/rehost-images.test.ts +139 -0
- package/src/client/tiptap/create-editor.ts +3 -0
- package/src/client/tiptap/extensions.ts +4 -0
- package/src/client/tiptap/inline-image-upload.ts +174 -50
- package/src/client/tiptap/rehost-images.ts +104 -0
- package/src/i18n/locales/public/en.po +10 -0
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +10 -0
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +10 -0
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/lib/__tests__/upload-sideload.test.ts +78 -0
- package/src/lib/__tests__/url-fetch.test.ts +181 -0
- package/src/lib/upload.ts +111 -0
- package/src/lib/url-fetch.ts +263 -0
- package/src/routes/api/__tests__/uploads.test.ts +63 -1
- package/src/routes/api/uploads.ts +52 -0
- package/src/services/__tests__/media.test.ts +168 -1
- package/src/services/media.ts +111 -0
- package/src/styles/ui.css +1 -1
- package/src/ui/compose/ComposeDialog.tsx +16 -0
- package/src/ui/layouts/BaseLayout.tsx +12 -0
- package/dist/app-D24n0DoH.js +0 -6
- 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,
|
|
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-
|
|
3
|
-
import { i as classifyRepoForSync, o as createGitHubSyncService } from "./github-sync-
|
|
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-
|
|
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-
|
|
3
|
-
import {
|
|
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-
|
|
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-
|
|
3
|
-
import { t as createExportService } from "./export-
|
|
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-
|
|
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-
|
|
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
|
@@ -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 {
|
|
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
|
|
683
|
-
? detail.threadPosts.some((p) => p.body
|
|
684
|
-
: detail.body
|
|
685
|
-
const hasPending = detail.pendingAttachments.length > 0 ||
|
|
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
|
|
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
|
|
944
|
-
|
|
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);
|