@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
@@ -33,6 +33,7 @@ import type {
33
33
  NavManagerLabels,
34
34
  NavManagerUpdateDetail,
35
35
  NavManagerDeleteDetail,
36
+ NavManagerSuggestedLink,
36
37
  SystemNavConfig,
37
38
  } from "./nav-manager-types.js";
38
39
 
@@ -42,6 +43,7 @@ export class JantNavManager extends LitElement {
42
43
  labels: { type: Object },
43
44
  systemNavItems: { type: Array, attribute: "system-nav-items" },
44
45
  collections: { type: Array },
46
+ suggestedLinks: { type: Array, attribute: "suggested-links" },
45
47
  siteName: { type: String, attribute: "site-name" },
46
48
 
47
49
  _items: { state: true },
@@ -56,12 +58,14 @@ export class JantNavManager extends LitElement {
56
58
  _showPreviewMore: { state: true },
57
59
  _addingCollectionId: { state: true },
58
60
  _showCollectionPicker: { state: true },
61
+ _addingSuggestedKey: { state: true },
59
62
  };
60
63
 
61
64
  declare items: NavManagerItem[];
62
65
  declare labels: NavManagerLabels;
63
66
  declare systemNavItems: SystemNavConfig[];
64
67
  declare collections: NavManagerCollection[];
68
+ declare suggestedLinks: NavManagerSuggestedLink[];
65
69
  declare siteName: string;
66
70
 
67
71
  declare _items: NavManagerItem[];
@@ -78,6 +82,8 @@ export class JantNavManager extends LitElement {
78
82
  /** ID of the collection currently being added (for loading state) */
79
83
  declare _addingCollectionId: string | null;
80
84
  declare _showCollectionPicker: boolean;
85
+ /** Key of the suggested link currently being added */
86
+ declare _addingSuggestedKey: string | null;
81
87
 
82
88
  #sortableHeader: { destroy(): void } | null = null;
83
89
  #sortableMore: { destroy(): void } | null = null;
@@ -124,6 +130,7 @@ export class JantNavManager extends LitElement {
124
130
  this.labels = {} as NavManagerLabels;
125
131
  this.systemNavItems = [];
126
132
  this.collections = [];
133
+ this.suggestedLinks = [];
127
134
  this.siteName = "";
128
135
 
129
136
  this._items = [];
@@ -138,6 +145,7 @@ export class JantNavManager extends LitElement {
138
145
  this._showPreviewMore = false;
139
146
  this._addingCollectionId = null;
140
147
  this._showCollectionPicker = false;
148
+ this._addingSuggestedKey = null;
141
149
  }
142
150
 
143
151
  protected update(changedProperties: PropertyValueMap<JantNavManager>): void {
@@ -448,6 +456,99 @@ export class JantNavManager extends LitElement {
448
456
  }
449
457
  }
450
458
 
459
+ // ===========================================================================
460
+ // Suggested link handlers
461
+ // ===========================================================================
462
+
463
+ #getComparableInternalPath(url: string): string | null {
464
+ if (
465
+ url.startsWith("http://") ||
466
+ url.startsWith("https://") ||
467
+ url.startsWith("//") ||
468
+ url.startsWith("mailto:") ||
469
+ url.startsWith("tel:") ||
470
+ url.startsWith("#")
471
+ ) {
472
+ return null;
473
+ }
474
+
475
+ try {
476
+ const pathname = new URL(url, "https://jant.invalid").pathname;
477
+ const normalized = pathname
478
+ .trim()
479
+ .toLowerCase()
480
+ .replace(/^\/+|\/+$/g, "")
481
+ .replace(/\/+/g, "/");
482
+ return normalized ? `/${normalized}` : "/";
483
+ } catch {
484
+ return null;
485
+ }
486
+ }
487
+
488
+ #isSuggestedLinkAdded(link: NavManagerSuggestedLink): boolean {
489
+ const path = this.#getComparableInternalPath(link.url);
490
+ const hasMatchingPath =
491
+ path !== null &&
492
+ this._items.some(
493
+ (item) => this.#getComparableInternalPath(item.url) === path,
494
+ );
495
+ if (hasMatchingPath) return true;
496
+
497
+ return Boolean(
498
+ link.collectionId &&
499
+ this._items.some(
500
+ (item) =>
501
+ item.type === "collection" && item.collectionId === link.collectionId,
502
+ ),
503
+ );
504
+ }
505
+
506
+ get #availableSuggestedLinks(): NavManagerSuggestedLink[] {
507
+ return (this.suggestedLinks ?? []).filter(
508
+ (link) => !this.#isSuggestedLinkAdded(link),
509
+ );
510
+ }
511
+
512
+ async #handleAddSuggestedLink(link: NavManagerSuggestedLink) {
513
+ if (this._addingSuggestedKey) return;
514
+
515
+ this._addingSuggestedKey = link.key;
516
+ try {
517
+ const body =
518
+ link.navItemType === "collection" && link.collectionId
519
+ ? {
520
+ type: "collection",
521
+ collectionId: link.collectionId,
522
+ placement: "header",
523
+ }
524
+ : {
525
+ type: "link",
526
+ label: link.label,
527
+ url: link.url,
528
+ placement: "header",
529
+ };
530
+
531
+ const res = await fetch("/api/nav-items", {
532
+ method: "POST",
533
+ headers: {
534
+ "Content-Type": "application/json",
535
+ Accept: "application/json",
536
+ },
537
+ body: JSON.stringify(body),
538
+ });
539
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
540
+
541
+ const created: NavManagerItem = await res.json();
542
+ this.#destroySortables();
543
+ this._items = [...this._items, created];
544
+ showToast(this.labels.suggestedLinkAdded);
545
+ } catch {
546
+ showToast(this.labels.saveFailed, "error");
547
+ } finally {
548
+ this._addingSuggestedKey = null;
549
+ }
550
+ }
551
+
451
552
  // ===========================================================================
452
553
  // System toggle handlers
453
554
  // ===========================================================================
@@ -834,6 +935,45 @@ export class JantNavManager extends LitElement {
834
935
  `;
835
936
  }
836
937
 
938
+ #renderSuggestedLinksSection() {
939
+ const available = this.#availableSuggestedLinks;
940
+ if (available.length === 0) return nothing;
941
+
942
+ return html`
943
+ <section class="mt-8">
944
+ <h2 class="text-lg font-semibold mb-1">
945
+ ${this.labels.suggestedLinks}
946
+ </h2>
947
+ <p class="text-sm text-muted-foreground mb-3">
948
+ ${this.labels.suggestedLinksDescription}
949
+ </p>
950
+ <div class="nav-suggestions-list">
951
+ ${available.map((link) => {
952
+ const adding = this._addingSuggestedKey === link.key;
953
+ return html`
954
+ <div class="nav-suggestion-item">
955
+ <div class="nav-suggestion-info">
956
+ <span class="nav-suggestion-title">${link.label}</span>
957
+ <span class="nav-suggestion-meta">
958
+ ${link.url} · ${link.targetLabel}
959
+ </span>
960
+ </div>
961
+ <button
962
+ type="button"
963
+ class="btn-sm"
964
+ ?disabled=${adding || this._addingSuggestedKey !== null}
965
+ @click=${() => this.#handleAddSuggestedLink(link)}
966
+ >
967
+ ${this.labels.addSuggestedLink}
968
+ </button>
969
+ </div>
970
+ `;
971
+ })}
972
+ </div>
973
+ </section>
974
+ `;
975
+ }
976
+
837
977
  #renderAddLinkSection() {
838
978
  return html`
839
979
  <section class="mt-8">
@@ -1175,8 +1315,8 @@ export class JantNavManager extends LitElement {
1175
1315
  </div>
1176
1316
  </section>
1177
1317
 
1178
- ${this.#renderAddLinkSection()} ${this.#renderAddCollectionSection()}
1179
- ${this.#renderSystemToggles()}
1318
+ ${this.#renderSuggestedLinksSection()} ${this.#renderAddLinkSection()}
1319
+ ${this.#renderAddCollectionSection()} ${this.#renderSystemToggles()}
1180
1320
  `;
1181
1321
  }
1182
1322
  }
@@ -21,6 +21,7 @@ import type {
21
21
  SettingsTimezone,
22
22
  SettingsCjkFont,
23
23
  SettingsDashboardLanguage,
24
+ SettingsAboutPageStatus,
24
25
  } from "./settings-types.js";
25
26
  import { showToast } from "../toast.js";
26
27
  import {
@@ -44,6 +45,12 @@ export class JantSettingsGeneral extends LitElement {
44
45
  latestFeedUrl: { type: String, attribute: "latest-feed-url" },
45
46
  featuredFeedUrl: { type: String, attribute: "featured-feed-url" },
46
47
  archiveFeedUrl: { type: String, attribute: "archive-feed-url" },
48
+ aboutPage: { type: Object, attribute: "about-page" },
49
+ aboutEditUrl: { type: String, attribute: "about-edit-url" },
50
+ aboutCreateUrl: {
51
+ type: String,
52
+ attribute: "about-create-url",
53
+ },
47
54
 
48
55
  // Site group
49
56
  _siteName: { state: true },
@@ -92,6 +99,9 @@ export class JantSettingsGeneral extends LitElement {
92
99
  declare latestFeedUrl: string;
93
100
  declare featuredFeedUrl: string;
94
101
  declare archiveFeedUrl: string;
102
+ declare aboutPage: SettingsAboutPageStatus;
103
+ declare aboutEditUrl: string;
104
+ declare aboutCreateUrl: string;
95
105
 
96
106
  // Site
97
107
  declare _siteName: string;
@@ -162,6 +172,12 @@ export class JantSettingsGeneral extends LitElement {
162
172
  this.latestFeedUrl = "/latest/feed";
163
173
  this.featuredFeedUrl = "/featured/feed";
164
174
  this.archiveFeedUrl = "/archive/feed";
175
+ this.aboutPage = {
176
+ state: "missing",
177
+ path: "/about",
178
+ };
179
+ this.aboutEditUrl = "/about?edit=1";
180
+ this.aboutCreateUrl = "/settings/general/about-page";
165
181
 
166
182
  this._siteName = "";
167
183
  this._siteDescription = "";
@@ -757,6 +773,45 @@ export class JantSettingsGeneral extends LitElement {
757
773
  `;
758
774
  }
759
775
 
776
+ private _renderAboutPageRow() {
777
+ const status = this.aboutPage;
778
+
779
+ return html`
780
+ <div class="mt-2 text-sm text-muted-foreground" data-about-page-row>
781
+ ${this.labels.aboutPagePrompt}
782
+ ${status.state === "ready"
783
+ ? html`
784
+ <a
785
+ class="font-medium text-foreground underline-offset-4 hover:underline"
786
+ href=${this.aboutEditUrl}
787
+ >
788
+ ${this.labels.editAboutPage}
789
+ </a>
790
+ `
791
+ : status.state === "missing"
792
+ ? html`
793
+ <form
794
+ class="inline"
795
+ method="post"
796
+ action=${this.aboutCreateUrl}
797
+ >
798
+ <button
799
+ type="submit"
800
+ class="inline cursor-pointer border-0 bg-transparent p-0 font-medium text-foreground underline-offset-4 hover:underline"
801
+ >
802
+ ${this.labels.createAboutPage}
803
+ </button>
804
+ </form>
805
+ `
806
+ : html`
807
+ <span class="text-destructive"
808
+ >${this.labels.aboutPageConflict}</span
809
+ >
810
+ `}
811
+ </div>
812
+ `;
813
+ }
814
+
760
815
  private _renderGeneralForm() {
761
816
  return html`
762
817
  <div class="flex flex-col gap-8">
@@ -796,6 +851,7 @@ export class JantSettingsGeneral extends LitElement {
796
851
  <p class="text-sm text-muted-foreground mt-1">
797
852
  ${this.labels.aboutBlogHelp}
798
853
  </p>
854
+ ${this._renderAboutPageRow()}
799
855
  </div>
800
856
 
801
857
  <div class="field">
@@ -30,6 +30,16 @@ export interface NavManagerCollection {
30
30
  group?: string | null;
31
31
  }
32
32
 
33
+ export interface NavManagerSuggestedLink {
34
+ key: string;
35
+ label: string;
36
+ url: string;
37
+ targetType: "page" | "collection" | "archive";
38
+ targetLabel: string;
39
+ navItemType: "link" | "collection";
40
+ collectionId?: string;
41
+ }
42
+
33
43
  export interface NavManagerLabels {
34
44
  preview: string;
35
45
  navigationItems: string;
@@ -54,6 +64,10 @@ export interface NavManagerLabels {
54
64
  addLinkDescription: string;
55
65
  urlPlaceholder: string;
56
66
  labelAndUrlRequired: string;
67
+ suggestedLinks: string;
68
+ suggestedLinksDescription: string;
69
+ addSuggestedLink: string;
70
+ suggestedLinkAdded: string;
57
71
  collection: string;
58
72
  addCollection: string;
59
73
  addCollectionToNavigation: string;
@@ -18,6 +18,11 @@ export interface SettingsLabels {
18
18
  // General
19
19
  general: string;
20
20
  site: string;
21
+ aboutPage: string;
22
+ aboutPagePrompt: string;
23
+ aboutPageConflict: string;
24
+ createAboutPage: string;
25
+ editAboutPage: string;
21
26
  languageAndTime: string;
22
27
  home: string;
23
28
  search: string;
@@ -100,6 +105,31 @@ export interface SettingsInitialData {
100
105
  noindex: boolean;
101
106
  }
102
107
 
108
+ export type SettingsAboutPageStatus =
109
+ | {
110
+ state: "missing";
111
+ path: "/about";
112
+ }
113
+ | {
114
+ state: "ready";
115
+ path: "/about";
116
+ post: {
117
+ id: string;
118
+ title: string | null;
119
+ status: "draft" | "published";
120
+ visibility: "public" | "latest_hidden" | "private";
121
+ };
122
+ }
123
+ | {
124
+ state: "conflict";
125
+ path: "/about";
126
+ conflict: {
127
+ targetType: "collection" | "redirect" | "archive" | "post";
128
+ id: string | null;
129
+ title: string | null;
130
+ };
131
+ };
132
+
103
133
  /** Event detail dispatched when a settings form is saved */
104
134
  export interface SettingsSaveDetail {
105
135
  endpoint: string;
@@ -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);
@@ -57,6 +57,36 @@ export function getCurrentDetailPostArticle(
57
57
  return null;
58
58
  }
59
59
 
60
+ function getLastThreadDetailArticle(
61
+ root: globalThis.Document | globalThis.Element,
62
+ ): HTMLElement | null {
63
+ const threadGroup = root.querySelector<HTMLElement>(
64
+ ".thread-group-detail[data-page='post']",
65
+ );
66
+ if (!threadGroup) return null;
67
+
68
+ const articles = threadGroup.querySelectorAll<HTMLElement>(
69
+ ".thread-detail-item article[data-post]",
70
+ );
71
+ return articles.length > 0 ? articles.item(articles.length - 1) : null;
72
+ }
73
+
74
+ export function getReplyTargetArticle(
75
+ root: globalThis.Document | globalThis.Element = document,
76
+ ): HTMLElement | null {
77
+ const lastThreadPost = getLastThreadDetailArticle(root);
78
+ if (lastThreadPost) return lastThreadPost;
79
+
80
+ if (root === document) {
81
+ const hoveredPost = document.querySelector<HTMLElement>(
82
+ "[data-page='post'] article[data-post]:hover",
83
+ );
84
+ if (hoveredPost) return hoveredPost;
85
+ }
86
+
87
+ return getCurrentDetailPostArticle(root);
88
+ }
89
+
60
90
  export function getReplyRefreshTarget(
61
91
  article: HTMLElement,
62
92
  ): ReplyRefreshTarget | null {
@@ -2,6 +2,7 @@ import {
2
2
  getActiveCollectionId,
3
3
  getComposeDialog,
4
4
  getCurrentDetailPostArticle,
5
+ getReplyTargetArticle,
5
6
  openNewCompose,
6
7
  openReplyForArticle,
7
8
  } from "./compose-launch.js";
@@ -42,6 +43,34 @@ function shouldIgnoreShortcut(event: globalThis.KeyboardEvent): boolean {
42
43
  );
43
44
  }
44
45
 
46
+ function hasEditQueryParam(): boolean {
47
+ return new URL(window.location.href).searchParams.get("edit") === "1";
48
+ }
49
+
50
+ function consumeEditQueryParam() {
51
+ const url = new URL(window.location.href);
52
+ url.searchParams.delete("edit");
53
+ const nextSearch = url.searchParams.toString();
54
+ globalThis.history.replaceState(
55
+ globalThis.history.state,
56
+ "",
57
+ `${url.pathname}${nextSearch ? `?${nextSearch}` : ""}${url.hash}`,
58
+ );
59
+ }
60
+
61
+ function openEditFromQueryParam() {
62
+ if (!document.body?.hasAttribute("data-authenticated")) return;
63
+ if (!hasEditQueryParam()) return;
64
+
65
+ const article = getCurrentDetailPostArticle();
66
+ const postId = article?.dataset.postId;
67
+ const composeEl = getComposeDialog();
68
+ if (!postId || !composeEl) return;
69
+
70
+ consumeEditQueryParam();
71
+ void composeEl.openEdit(postId);
72
+ }
73
+
45
74
  async function toggleFeatured(
46
75
  postId: string,
47
76
  featured: boolean,
@@ -93,15 +122,17 @@ document.addEventListener("keydown", (event: globalThis.KeyboardEvent) => {
93
122
  return;
94
123
  }
95
124
 
96
- const article = getCurrentDetailPostArticle();
97
- if (!article) return;
98
-
99
125
  if (key === "r") {
126
+ const article = getReplyTargetArticle();
127
+ if (!article) return;
100
128
  event.preventDefault();
101
129
  void openReplyForArticle(article);
102
130
  return;
103
131
  }
104
132
 
133
+ const article = getCurrentDetailPostArticle();
134
+ if (!article) return;
135
+
105
136
  if (key === "e") {
106
137
  const postId = article.dataset.postId;
107
138
  if (!postId) return;
@@ -131,3 +162,15 @@ document.addEventListener("keydown", (event: globalThis.KeyboardEvent) => {
131
162
  return;
132
163
  }
133
164
  });
165
+
166
+ if (document.readyState === "loading") {
167
+ document.addEventListener("DOMContentLoaded", openEditFromQueryParam, {
168
+ once: true,
169
+ });
170
+ } else {
171
+ openEditFromQueryParam();
172
+ }
173
+
174
+ export const __testOnly = {
175
+ openEditFromQueryParam,
176
+ };
@@ -0,0 +1,143 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { Editor, type JSONContent } from "@tiptap/core";
5
+ import StarterKit from "@tiptap/starter-kit";
6
+ import { ImageNode } from "../image-node.js";
7
+ import {
8
+ rehostInlineImage,
9
+ resolveInlineImageUrls,
10
+ hasPendingInlineImagePlaceholders,
11
+ } from "../inline-image-upload.js";
12
+
13
+ const editors: Editor[] = [];
14
+
15
+ function createEditor(): Editor {
16
+ const element = document.createElement("div");
17
+ document.body.appendChild(element);
18
+ const editor = new Editor({
19
+ element,
20
+ extensions: [
21
+ StarterKit.configure({
22
+ heading: { levels: [1, 2, 3] },
23
+ link: { openOnClick: false, autolink: false },
24
+ }),
25
+ ImageNode,
26
+ ],
27
+ content: "<p></p>",
28
+ });
29
+ editors.push(editor);
30
+ return editor;
31
+ }
32
+
33
+ function imageSrcs(editor: Editor): string[] {
34
+ const out: string[] = [];
35
+ editor.state.doc.descendants((node) => {
36
+ if (node.type.name === "image") out.push(node.attrs.src as string);
37
+ });
38
+ return out;
39
+ }
40
+
41
+ function jsonImageSrcs(node: JSONContent | null): string[] {
42
+ if (!node) return [];
43
+ const out: string[] = [];
44
+ const walk = (n: JSONContent) => {
45
+ if (n.type === "image" && typeof n.attrs?.src === "string") {
46
+ out.push(n.attrs.src);
47
+ }
48
+ for (const child of n.content ?? []) walk(child);
49
+ };
50
+ walk(node);
51
+ return out;
52
+ }
53
+
54
+ afterEach(() => {
55
+ while (editors.length > 0) editors.pop()?.destroy();
56
+ document.body.innerHTML = "";
57
+ });
58
+
59
+ describe("rehostInlineImage", () => {
60
+ it("swaps the node src on a successful rehost", async () => {
61
+ const editor = createEditor();
62
+ const src = "https://ext.example/keep1.png";
63
+ editor.commands.setImage({ src });
64
+
65
+ await rehostInlineImage(
66
+ editor,
67
+ src,
68
+ async () => "https://cdn.local/keep1.webp",
69
+ );
70
+
71
+ expect(imageSrcs(editor)).toEqual(["https://cdn.local/keep1.webp"]);
72
+ });
73
+
74
+ it("keeps a remote node's original src when the rehost fails", async () => {
75
+ const editor = createEditor();
76
+ const src = "https://ext.example/fail1.png";
77
+ editor.commands.setImage({ src });
78
+
79
+ await rehostInlineImage(editor, src, async () => {
80
+ throw new Error("boom");
81
+ });
82
+
83
+ expect(imageSrcs(editor)).toEqual([src]);
84
+ });
85
+
86
+ it("replaces every node sharing a deduped remote src", async () => {
87
+ const editor = createEditor();
88
+ const src = "https://ext.example/dupe.png";
89
+ editor.commands.setImage({ src });
90
+ editor.commands.setImage({ src });
91
+
92
+ await rehostInlineImage(
93
+ editor,
94
+ src,
95
+ async () => "https://cdn.local/dupe.webp",
96
+ );
97
+
98
+ expect(imageSrcs(editor)).toEqual([
99
+ "https://cdn.local/dupe.webp",
100
+ "https://cdn.local/dupe.webp",
101
+ ]);
102
+ });
103
+ });
104
+
105
+ describe("resolveInlineImageUrls + hasPendingInlineImagePlaceholders", () => {
106
+ it("tracks an in-flight rehost and resolves its placeholder to the stored URL", async () => {
107
+ const editor = createEditor();
108
+ const src = "https://ext.example/pending.png";
109
+ editor.commands.setImage({ src });
110
+
111
+ let release!: (url: string) => void;
112
+ const deferred = new Promise<string>((resolve) => {
113
+ release = resolve;
114
+ });
115
+ const rehostDone = rehostInlineImage(editor, src, () => deferred);
116
+
117
+ const json = editor.getJSON();
118
+ expect(hasPendingInlineImagePlaceholders(json)).toBe(true);
119
+
120
+ const resolvePromise = resolveInlineImageUrls(json);
121
+ release("https://cdn.local/pending.webp");
122
+ const resolved = await resolvePromise;
123
+ await rehostDone;
124
+
125
+ expect(jsonImageSrcs(resolved)).toEqual(["https://cdn.local/pending.webp"]);
126
+ });
127
+
128
+ it("is a no-op for content with no pending placeholders", async () => {
129
+ const json: JSONContent = {
130
+ type: "doc",
131
+ content: [
132
+ {
133
+ type: "image",
134
+ attrs: { src: "https://cdn.local/already-stored.webp" },
135
+ },
136
+ ],
137
+ };
138
+
139
+ expect(hasPendingInlineImagePlaceholders(json)).toBe(false);
140
+ const resolved = await resolveInlineImageUrls(json);
141
+ expect(resolved).toBe(json);
142
+ });
143
+ });