@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
@@ -15,7 +15,11 @@ vi.mock("sortablejs", () => ({
15
15
  },
16
16
  }));
17
17
 
18
- import type { NavManagerItem, NavManagerLabels } from "../nav-manager-types.js";
18
+ import type {
19
+ NavManagerItem,
20
+ NavManagerLabels,
21
+ NavManagerSuggestedLink,
22
+ } from "../nav-manager-types.js";
19
23
  import "../jant-nav-manager.js";
20
24
  import type { JantNavManager } from "../jant-nav-manager.js";
21
25
 
@@ -43,6 +47,10 @@ const labels: NavManagerLabels = {
43
47
  addLinkDescription: "Add a custom nav link.",
44
48
  urlPlaceholder: "/about",
45
49
  labelAndUrlRequired: "Add a label and URL.",
50
+ suggestedLinks: "Suggested links",
51
+ suggestedLinksDescription: "Add common destinations.",
52
+ addSuggestedLink: "Add",
53
+ suggestedLinkAdded: "Link added to navigation.",
46
54
  headerSection: "Header",
47
55
  moreSection: "More",
48
56
  moreEmptyHint: "Move links here to hide them under More.",
@@ -84,6 +92,18 @@ const items: NavManagerItem[] = [
84
92
  },
85
93
  ];
86
94
 
95
+ const suggestedLinks: NavManagerSuggestedLink[] = [
96
+ {
97
+ key: "now",
98
+ label: "Now",
99
+ url: "/now",
100
+ targetType: "collection",
101
+ targetLabel: "Collection",
102
+ navItemType: "collection",
103
+ collectionId: "col_now",
104
+ },
105
+ ];
106
+
87
107
  function requireElement<T>(value: T | null, message: string): T {
88
108
  if (!value) {
89
109
  throw new Error(message);
@@ -133,6 +153,7 @@ async function createElement(): Promise<JantNavManager> {
133
153
  el.labels = labels;
134
154
  el.items = items;
135
155
  el.systemNavItems = [];
156
+ el.suggestedLinks = [];
136
157
  el.siteName = "Test Site";
137
158
  document.body.appendChild(el);
138
159
  await el.updateComplete;
@@ -251,6 +272,61 @@ describe("JantNavManager", () => {
251
272
  expect(popover.getAttribute("aria-hidden")).toBe("true");
252
273
  });
253
274
 
275
+ it("adds a suggested collection link through the nav items API", async () => {
276
+ const el = await createElement();
277
+ el.suggestedLinks = suggestedLinks;
278
+ el.requestUpdate();
279
+ await el.updateComplete;
280
+
281
+ const addButton = requireElement(
282
+ el.querySelector<HTMLButtonElement>(".nav-suggestion-item button"),
283
+ "expected suggested link add button",
284
+ );
285
+ expect(el.textContent).toContain("Now");
286
+ expect(el.textContent).toContain("/now · Collection");
287
+
288
+ const created: NavManagerItem = {
289
+ id: "nav-now",
290
+ type: "collection",
291
+ collectionId: "col_now",
292
+ label: "Now",
293
+ url: "/now",
294
+ placement: "header",
295
+ };
296
+ const fetchMock = vi.fn().mockResolvedValue({
297
+ ok: true,
298
+ status: 201,
299
+ json: async () => created,
300
+ });
301
+ vi.stubGlobal("fetch", fetchMock);
302
+
303
+ addButton.click();
304
+ await Promise.resolve();
305
+ await Promise.resolve();
306
+ await el.updateComplete;
307
+
308
+ expect(fetchMock).toHaveBeenCalledWith(
309
+ "/api/nav-items",
310
+ expect.objectContaining({
311
+ method: "POST",
312
+ body: JSON.stringify({
313
+ type: "collection",
314
+ collectionId: "col_now",
315
+ placement: "header",
316
+ }),
317
+ }),
318
+ );
319
+ expect(
320
+ getListIds(
321
+ requireElement(
322
+ el.querySelector<HTMLElement>("#nav-items-header"),
323
+ "expected header nav list",
324
+ ),
325
+ ),
326
+ ).toContain("nav-now");
327
+ expect(el.querySelector(".nav-suggestion-item")).toBeNull();
328
+ });
329
+
254
330
  it("confirms before dispatching nav deletion", async () => {
255
331
  const el = await createElement();
256
332
  const deleteHandler = vi.fn();
@@ -32,6 +32,12 @@ const labels: SettingsLabels = {
32
32
  uploadError: "Upload failed.",
33
33
  general: "General",
34
34
  site: "Site",
35
+ aboutPage: "About page",
36
+ aboutPagePrompt: "Want to write a fuller introduction?",
37
+ aboutPageConflict:
38
+ "/about is already used. Rename that item before creating an About page.",
39
+ createAboutPage: "Create About page",
40
+ editAboutPage: "Edit About page",
35
41
  languageAndTime: "Language & Time",
36
42
  home: "Home",
37
43
  search: "Search",
@@ -7,6 +7,7 @@ import type {
7
7
  SettingsCjkFont,
8
8
  SettingsDashboardLanguage,
9
9
  SettingsSaveDetail,
10
+ SettingsAboutPageStatus,
10
11
  } from "../settings-types.js";
11
12
  import { MAX_SITE_NAME_LENGTH } from "../../../types.js";
12
13
  import "../jant-settings-general.js";
@@ -76,6 +77,12 @@ const labels: SettingsLabels = {
76
77
  uploadError: "Upload failed.",
77
78
  general: "General",
78
79
  site: "Site",
80
+ aboutPage: "About page",
81
+ aboutPagePrompt: "Want to write a fuller introduction?",
82
+ aboutPageConflict:
83
+ "/about is already used. Rename that item before creating an About page.",
84
+ createAboutPage: "Create About page",
85
+ editAboutPage: "Edit About page",
79
86
  languageAndTime: "Language & Time",
80
87
  home: "Home",
81
88
  search: "Search",
@@ -167,6 +174,7 @@ function findCheckboxByLabel(
167
174
  async function createElement(
168
175
  opts: {
169
176
  demoMode?: boolean;
177
+ aboutPage?: SettingsAboutPageStatus;
170
178
  } = {},
171
179
  ): Promise<JantSettingsGeneral> {
172
180
  const el = document.createElement(
@@ -182,6 +190,14 @@ async function createElement(
182
190
  el.latestFeedUrl = "/latest/feed";
183
191
  el.featuredFeedUrl = "/featured/feed";
184
192
  el.archiveFeedUrl = "/archive/feed";
193
+ el.aboutPage =
194
+ opts.aboutPage ??
195
+ ({
196
+ state: "missing",
197
+ path: "/about",
198
+ } satisfies SettingsAboutPageStatus);
199
+ el.aboutEditUrl = "/about?edit=1";
200
+ el.aboutCreateUrl = "/settings/general/about-page";
185
201
  el.demoMode = opts.demoMode ?? false;
186
202
  document.body.appendChild(el);
187
203
  await el.updateComplete;
@@ -209,6 +225,61 @@ describe("JantSettingsGeneral", () => {
209
225
  labels.home,
210
226
  labels.search,
211
227
  ]);
228
+
229
+ const siteSection = requireElement(
230
+ findSectionByHeading(el, labels.site),
231
+ "expected site section",
232
+ );
233
+ expect(siteSection.querySelector("[data-about-page-row]")).not.toBeNull();
234
+ });
235
+
236
+ it("renders a create About form from the missing About prompt", async () => {
237
+ const el = await createElement();
238
+ const aboutRow = requireElement(
239
+ el.querySelector<HTMLElement>("[data-about-page-row]"),
240
+ "expected About page row",
241
+ );
242
+ const form = requireElement(
243
+ aboutRow.querySelector<HTMLFormElement>("form"),
244
+ "expected create About page form",
245
+ );
246
+ const button = requireElement(
247
+ form.querySelector<HTMLButtonElement>("button"),
248
+ "expected create About page button",
249
+ );
250
+
251
+ expect(aboutRow.textContent).toContain(labels.aboutPagePrompt);
252
+ expect(form.method).toBe("post");
253
+ expect(form.action).toContain("/settings/general/about-page");
254
+ expect(button.textContent).toContain(labels.createAboutPage);
255
+ });
256
+
257
+ it("renders an edit link when the About page exists", async () => {
258
+ const el = await createElement({
259
+ aboutPage: {
260
+ state: "ready",
261
+ path: "/about",
262
+ post: {
263
+ id: "pst_about000000000000000000000",
264
+ title: "About",
265
+ status: "published",
266
+ visibility: "latest_hidden",
267
+ },
268
+ },
269
+ });
270
+
271
+ const aboutRow = requireElement(
272
+ el.querySelector<HTMLElement>("[data-about-page-row]"),
273
+ "expected About page row",
274
+ );
275
+ const editLink = requireElement(
276
+ aboutRow.querySelector<HTMLAnchorElement>("a"),
277
+ "expected About edit link",
278
+ );
279
+
280
+ expect(aboutRow.textContent).toContain(labels.aboutPagePrompt);
281
+ expect(editLink.textContent).toContain(labels.editAboutPage);
282
+ expect(editLink.href).toContain("/about?edit=1");
212
283
  });
213
284
 
214
285
  it("renders form fields with initial values", async () => {
@@ -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;
@@ -5,6 +5,7 @@
5
5
  * - Navigate (default): filter preloaded posts, collections, system pages
6
6
  * - Command (`>` prefix): run actions like New Post
7
7
  * - Search (`?` prefix): redirects to /search?q=... on Enter
8
+ * - Path (`/` prefix): opens a local path, with search as the second option
8
9
  */
9
10
 
10
11
  import { LitElement, html, nothing } from "lit";
@@ -45,10 +46,24 @@ const ICONS = {
45
46
  system: `<svg ${ICON_ATTRS}><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>`,
46
47
  // zap/lightning for commands
47
48
  command: `<svg ${ICON_ATTRS}><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg>`,
49
+ // corner-down-right for local path navigation
50
+ path: `<svg ${ICON_ATTRS}><polyline points="15 10 20 15 15 20"/><path d="M4 4v7a4 4 0 0 0 4 4h12"/></svg>`,
48
51
  // search
49
52
  search: `<svg ${ICON_ATTRS}><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>`,
50
53
  };
51
54
 
55
+ export function getPathCommandTarget(query: string): string {
56
+ const stripped = query.trim().replace(/^[//]+/, "");
57
+ return stripped ? `/${stripped}` : "/";
58
+ }
59
+
60
+ export function getPathCommandSearchQuery(query: string): string {
61
+ return query
62
+ .trim()
63
+ .replace(/^[//]+/, "")
64
+ .trim();
65
+ }
66
+
52
67
  // ---------------------------------------------------------------------------
53
68
  // History helpers
54
69
  // ---------------------------------------------------------------------------
@@ -208,12 +223,13 @@ export class JantCommandPalette extends LitElement {
208
223
  // Mode detection
209
224
  // -----------------------------------------------------------------------
210
225
 
211
- get #mode(): "navigate" | "command" | "search" {
212
- // Accept both halfwidth (>, ?) and fullwidth (>, ?) prefixes so CJK
226
+ get #mode(): "navigate" | "command" | "search" | "path" {
227
+ // Accept both halfwidth and fullwidth prefixes so CJK
213
228
  // users don't have to toggle their IME to enter command/search mode.
214
229
  const first = this._query.charAt(0);
215
230
  if (first === ">" || first === ">") return "command";
216
231
  if (first === "?" || first === "?") return "search";
232
+ if (first === "/" || first === "/") return "path";
217
233
  return "navigate";
218
234
  }
219
235
 
@@ -270,6 +286,8 @@ export class JantCommandPalette extends LitElement {
270
286
  icon: string;
271
287
  /** For search-mode items: the query to execute */
272
288
  searchQuery?: string;
289
+ /** For path-mode items: the local path to open */
290
+ pathTarget?: string;
273
291
  }> {
274
292
  const mode = this.#mode;
275
293
 
@@ -299,12 +317,41 @@ export class JantCommandPalette extends LitElement {
299
317
  ];
300
318
  }
301
319
 
320
+ if (mode === "path") {
321
+ const target = getPathCommandTarget(this._query);
322
+ const searchQuery = getPathCommandSearchQuery(this._query);
323
+ const items: Array<{
324
+ label: string;
325
+ secondary?: string;
326
+ icon: string;
327
+ searchQuery?: string;
328
+ pathTarget?: string;
329
+ }> = [
330
+ {
331
+ label: `Go to ${target}`,
332
+ icon: ICONS.path,
333
+ pathTarget: target,
334
+ },
335
+ ];
336
+
337
+ if (searchQuery) {
338
+ items.push({
339
+ label: `Search for "${searchQuery}"`,
340
+ icon: ICONS.search,
341
+ searchQuery,
342
+ });
343
+ }
344
+
345
+ return items;
346
+ }
347
+
302
348
  // Navigate mode — show all items when no query (autocomplete)
303
349
  const navItems: Array<{
304
350
  label: string;
305
351
  secondary?: string;
306
352
  icon: string;
307
353
  searchQuery?: string;
354
+ pathTarget?: string;
308
355
  }> = this.#navigateItems.map((item) => ({
309
356
  label: item.title,
310
357
  secondary: item.type === "system" ? item.path : item.path,
@@ -351,6 +398,24 @@ export class JantCommandPalette extends LitElement {
351
398
  return;
352
399
  }
353
400
 
401
+ if (mode === "path") {
402
+ const displayItem = this.#displayItems[index];
403
+ if (displayItem?.pathTarget) {
404
+ saveHistory(NAV_HISTORY_KEY, displayItem.pathTarget);
405
+ this.close();
406
+ window.location.href = displayItem.pathTarget;
407
+ return;
408
+ }
409
+
410
+ if (displayItem?.searchQuery) {
411
+ const q = displayItem.searchQuery;
412
+ saveHistory(SEARCH_HISTORY_KEY, q);
413
+ this.close();
414
+ window.location.href = `/search?q=${encodeURIComponent(q)}`;
415
+ }
416
+ return;
417
+ }
418
+
354
419
  // Navigate mode — check if the selected item is a search fallback
355
420
  const displayItem = this.#displayItems[index];
356
421
  if (displayItem?.searchQuery) {
@@ -5432,7 +5432,11 @@ export class JantComposeDialog extends LitElement {
5432
5432
 
5433
5433
  return html`
5434
5434
  <div
5435
- class="compose-thread-layout compose-thread-compose-layout"
5435
+ class=${classMap({
5436
+ "compose-thread-layout": true,
5437
+ "compose-thread-compose-layout": true,
5438
+ "compose-reply-compose-layout": isReply,
5439
+ })}
5436
5440
  @jant:compose-content-changed=${() => this._scheduleDraftSave()}
5437
5441
  >
5438
5442
  ${isReply ? this._renderReplyContext() : nothing}
@@ -5502,7 +5506,9 @@ export class JantComposeDialog extends LitElement {
5502
5506
  ? this._renderThreadComposeLayout()
5503
5507
  : isReply
5504
5508
  ? html`
5505
- <div class="compose-thread-layout">
5509
+ <div
5510
+ class="compose-thread-layout compose-reply-compose-layout"
5511
+ >
5506
5512
  ${this._renderReplyContext()}
5507
5513
  <div
5508
5514
  class="compose-editor-row"
@@ -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