@jant/core 0.6.11 → 0.6.13

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 (139) hide show
  1. package/bin/commands/import-site.js +47 -30
  2. package/bin/commands/migrate-text-attachments.js +1 -3
  3. package/bin/commands/site/export.js +1 -3
  4. package/bin/commands/telegram/register-webhooks.js +9 -3
  5. package/bin/lib/site-snapshot.js +1 -5
  6. package/dist/app-CWJFPjuR.js +6 -0
  7. package/dist/{app-CpmficmQ.js → app-HnzmsaqU.js} +721 -473
  8. package/dist/client/.vite/manifest.json +3 -3
  9. package/dist/client/_assets/{client-Dd9U383b.js → client-3_OaxhTs.js} +1 -1
  10. package/dist/client/_assets/client-DCrcHVJl.css +2 -0
  11. package/dist/client/_assets/{client-auth-DkpSdIDz.js → client-auth-TsYq90xm.js} +625 -561
  12. package/dist/{export-Ba7NJImL.js → export-Dfbq9aot.js} +12 -15
  13. package/dist/{github-sync-Cb4_6_i7.js → github-sync--PSoE-Ne.js} +1 -1
  14. package/dist/{github-sync-BD4w2m8-.js → github-sync-BGTLx8Mf.js} +2 -2
  15. package/dist/index.js +3 -3
  16. package/dist/node.js +4 -4
  17. package/package.json +1 -1
  18. package/src/__tests__/export-service.test.ts +1 -2
  19. package/src/__tests__/helpers/app.ts +3 -0
  20. package/src/__tests__/helpers/export-fixtures.ts +0 -1
  21. package/src/__tests__/import-site-command.test.ts +76 -2
  22. package/src/__tests__/vite-dev-plugins.test.ts +34 -0
  23. package/src/client/__tests__/compose-launch.test.ts +50 -1
  24. package/src/client/__tests__/compose-shortcuts.test.ts +74 -18
  25. package/src/client/__tests__/site-header-nav.test.ts +36 -2
  26. package/src/client/components/__tests__/compose-title-from-heading.test.ts +57 -0
  27. package/src/client/components/__tests__/jant-command-palette.test.ts +63 -0
  28. package/src/client/components/__tests__/jant-compose-dialog.test.ts +33 -0
  29. package/src/client/components/__tests__/jant-compose-editor-rehost-notice.test.ts +20 -1
  30. package/src/client/components/__tests__/jant-compose-editor.test.ts +94 -2
  31. package/src/client/components/__tests__/jant-nav-manager.test.ts +181 -20
  32. package/src/client/components/__tests__/jant-settings-avatar.test.ts +6 -0
  33. package/src/client/components/__tests__/jant-settings-general.test.ts +72 -0
  34. package/src/client/components/compose-title-from-heading.ts +66 -0
  35. package/src/client/components/compose-types.ts +8 -0
  36. package/src/client/components/inline-image-issues.ts +17 -0
  37. package/src/client/components/jant-command-palette.ts +67 -2
  38. package/src/client/components/jant-compose-dialog.ts +10 -2
  39. package/src/client/components/jant-compose-editor.ts +78 -1
  40. package/src/client/components/jant-compose-fullscreen.ts +2 -0
  41. package/src/client/components/jant-nav-manager.ts +382 -152
  42. package/src/client/components/jant-settings-general.ts +56 -0
  43. package/src/client/components/nav-manager-types.ts +14 -10
  44. package/src/client/components/settings-types.ts +30 -0
  45. package/src/client/compose-launch.ts +30 -0
  46. package/src/client/compose-shortcuts.ts +46 -3
  47. package/src/client/site-header-nav.d.ts +1 -0
  48. package/src/client/site-header-nav.js +105 -47
  49. package/src/client/tiptap/__tests__/block-insertion.test.ts +35 -0
  50. package/src/client/tiptap/__tests__/image-input-rules.test.ts +117 -0
  51. package/src/client/tiptap/__tests__/rehost-images.test.ts +66 -13
  52. package/src/client/tiptap/create-editor.ts +7 -0
  53. package/src/client/tiptap/extensions.ts +7 -2
  54. package/src/client/tiptap/image-input-rules.ts +48 -0
  55. package/src/client/tiptap/image-node.ts +189 -7
  56. package/src/client/tiptap/rehost-images.ts +5 -1
  57. package/src/client-auth.ts +0 -1
  58. package/src/i18n/__tests__/fallback.test.ts +24 -0
  59. package/src/i18n/locales/public/en.po +20 -4
  60. package/src/i18n/locales/public/en.ts +1 -1
  61. package/src/i18n/locales/public/zh-Hans.po +22 -6
  62. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  63. package/src/i18n/locales/public/zh-Hant.po +22 -6
  64. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  65. package/src/i18n/locales/settings/en.po +77 -20
  66. package/src/i18n/locales/settings/en.ts +1 -1
  67. package/src/i18n/locales/settings/zh-Hans.po +85 -28
  68. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  69. package/src/i18n/locales/settings/zh-Hant.po +79 -22
  70. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  71. package/src/lib/__tests__/feed.test.ts +96 -10
  72. package/src/lib/__tests__/navigation.test.ts +4 -37
  73. package/src/lib/__tests__/resolve-config.test.ts +6 -1
  74. package/src/lib/__tests__/view.test.ts +6 -30
  75. package/src/lib/feed.ts +63 -3
  76. package/src/lib/github-sync-site-config.ts +0 -2
  77. package/src/lib/hugo-markdown.ts +1 -1
  78. package/src/lib/navigation.ts +5 -22
  79. package/src/lib/post-display.ts +14 -8
  80. package/src/lib/resolve-config.ts +9 -2
  81. package/src/lib/site-header-fragment.tsx +37 -0
  82. package/src/lib/view.ts +2 -7
  83. package/src/node/__tests__/cli-site-snapshot.test.ts +45 -45
  84. package/src/preset.css +60 -18
  85. package/src/routes/api/__tests__/nav-items.test.ts +26 -0
  86. package/src/routes/api/export.ts +0 -2
  87. package/src/routes/api/internal/__tests__/uploads.test.ts +9 -13
  88. package/src/routes/api/internal/sites.ts +5 -5
  89. package/src/routes/api/nav-items.ts +25 -4
  90. package/src/routes/dash/settings.tsx +48 -3
  91. package/src/routes/feed/__tests__/feed.test.ts +42 -0
  92. package/src/routes/feed/__tests__/sitemap.test.ts +2 -13
  93. package/src/routes/feed/feed.ts +1 -0
  94. package/src/routes/feed/sitemap.ts +2 -6
  95. package/src/routes/pages/__tests__/draft-about-edit.test.ts +65 -0
  96. package/src/routes/pages/archive.tsx +1 -0
  97. package/src/routes/pages/collection.tsx +1 -0
  98. package/src/routes/pages/featured.tsx +0 -4
  99. package/src/routes/pages/home.tsx +4 -51
  100. package/src/routes/pages/latest.tsx +2 -51
  101. package/src/routes/pages/page.tsx +44 -12
  102. package/src/services/__tests__/about-page.test.ts +116 -0
  103. package/src/services/__tests__/github-app-installations.test.ts +10 -10
  104. package/src/services/__tests__/navigation.test.ts +231 -1
  105. package/src/services/__tests__/post-timeline.test.ts +175 -2
  106. package/src/services/__tests__/post.test.ts +33 -0
  107. package/src/services/__tests__/settings.test.ts +5 -20
  108. package/src/services/about-page.ts +143 -0
  109. package/src/services/export-theme/assets/client-site.js +10 -10
  110. package/src/services/export-theme/layouts/_default/rss.xml +7 -16
  111. package/src/services/export-theme/layouts/index.html +26 -39
  112. package/src/services/export-theme/layouts/partials/feed-post-content.xml +25 -1
  113. package/src/services/export-theme/styles/main.css +46 -14
  114. package/src/services/export.ts +5 -10
  115. package/src/services/github-app-installations.ts +1 -3
  116. package/src/services/index.ts +27 -17
  117. package/src/services/navigation.ts +186 -1
  118. package/src/services/post.ts +103 -63
  119. package/src/services/settings.ts +0 -10
  120. package/src/services/site-admin.ts +0 -2
  121. package/src/services/upload-session.ts +4 -18
  122. package/src/styles/components.css +20 -0
  123. package/src/styles/tokens.css +28 -10
  124. package/src/styles/ui.css +349 -13
  125. package/src/types/bindings.ts +0 -1
  126. package/src/types/config.ts +3 -7
  127. package/src/types/views.ts +13 -0
  128. package/src/ui/compose/ComposeDialog.tsx +28 -0
  129. package/src/ui/dash/appearance/NavigationContent.tsx +58 -1
  130. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +19 -0
  131. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  132. package/src/ui/dash/settings/GeneralContent.tsx +44 -0
  133. package/src/ui/dash/settings/SettingsRootContent.tsx +3 -3
  134. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +8 -0
  135. package/src/ui/feed/__tests__/timeline-cards.test.ts +162 -1
  136. package/src/ui/layouts/SiteLayout.tsx +249 -216
  137. package/dist/app-DqKkZenB.js +0 -6
  138. package/dist/client/_assets/client-BhHHVvSY.css +0 -2
  139. package/src/client/nav-manager-bridge.ts +0 -54
@@ -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;
@@ -67,13 +81,3 @@ export interface NavManagerLabels {
67
81
  placementSaved: string;
68
82
  cancel: string;
69
83
  }
70
-
71
- export interface NavManagerUpdateDetail {
72
- id: string;
73
- label: string;
74
- url?: string;
75
- }
76
-
77
- export interface NavManagerDeleteDetail {
78
- id: string;
79
- }
@@ -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;
@@ -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
+ };
@@ -1 +1,2 @@
1
1
  export function initSiteHeaderNav(root?: object): void;
2
+ export function destroySiteHeaderNav(root?: object): void;
@@ -5,6 +5,9 @@
5
5
  * Manages the "More" dropdown popover.
6
6
  */
7
7
 
8
+ const moreControllers = new WeakMap();
9
+ const drawerControllers = new WeakMap();
10
+
8
11
  function initMoreDropdown(root) {
9
12
  const trigger = root.querySelector(".site-header-more-btn");
10
13
  const popover = root.querySelector(".site-header-more-popover");
@@ -12,6 +15,9 @@ function initMoreDropdown(root) {
12
15
  if (!trigger || !popover) return;
13
16
  if (trigger.dataset.moreInitialized === "true") return;
14
17
  trigger.dataset.moreInitialized = "true";
18
+ const controller = new AbortController();
19
+ const signal = controller.signal;
20
+ moreControllers.set(trigger, controller);
15
21
 
16
22
  function open() {
17
23
  popover.setAttribute("aria-hidden", "false");
@@ -29,30 +35,49 @@ function initMoreDropdown(root) {
29
35
  if (focusTrigger) trigger.focus();
30
36
  }
31
37
 
32
- trigger.addEventListener("click", (e) => {
33
- e.preventDefault();
34
- e.stopPropagation();
35
- if (trigger.getAttribute("aria-expanded") === "true") {
36
- close();
37
- } else {
38
- open();
39
- }
40
- });
41
-
42
- document.addEventListener("click", (e) => {
43
- if (!(e.target instanceof Node)) return;
44
- if (!trigger.parentElement?.contains(e.target)) close();
45
- });
46
-
47
- document.addEventListener("keydown", (e) => {
48
- if (e.key === "Escape" && popover.getAttribute("aria-hidden") === "false") {
49
- close(true);
50
- }
51
- });
52
-
53
- document.addEventListener("basecoat:popover", (e) => {
54
- if (e.detail?.source !== trigger.parentElement) close();
55
- });
38
+ trigger.addEventListener(
39
+ "click",
40
+ (e) => {
41
+ e.preventDefault();
42
+ e.stopPropagation();
43
+ if (trigger.getAttribute("aria-expanded") === "true") {
44
+ close();
45
+ } else {
46
+ open();
47
+ }
48
+ },
49
+ { signal },
50
+ );
51
+
52
+ document.addEventListener(
53
+ "click",
54
+ (e) => {
55
+ if (!(e.target instanceof Node)) return;
56
+ if (!trigger.parentElement?.contains(e.target)) close();
57
+ },
58
+ { signal },
59
+ );
60
+
61
+ document.addEventListener(
62
+ "keydown",
63
+ (e) => {
64
+ if (
65
+ e.key === "Escape" &&
66
+ popover.getAttribute("aria-hidden") === "false"
67
+ ) {
68
+ close(true);
69
+ }
70
+ },
71
+ { signal },
72
+ );
73
+
74
+ document.addEventListener(
75
+ "basecoat:popover",
76
+ (e) => {
77
+ if (e.detail?.source !== trigger.parentElement) close();
78
+ },
79
+ { signal },
80
+ );
56
81
  }
57
82
 
58
83
  const FRESH_VISIT_KEY = "jant:nav-fresh-visits";
@@ -111,6 +136,9 @@ export function initSiteHeaderNav(root = document) {
111
136
  if (!hamburger || !drawer || !backdrop) return;
112
137
  if (hamburger.dataset.drawerInitialized === "true") return;
113
138
  hamburger.dataset.drawerInitialized = "true";
139
+ const controller = new AbortController();
140
+ const signal = controller.signal;
141
+ drawerControllers.set(hamburger, controller);
114
142
 
115
143
  function open() {
116
144
  drawer.setAttribute("aria-hidden", "false");
@@ -138,35 +166,65 @@ export function initSiteHeaderNav(root = document) {
138
166
  drawer.setAttribute("inert", "");
139
167
  }
140
168
  },
141
- { once: true },
169
+ { once: true, signal },
142
170
  );
143
171
 
144
172
  if (returnFocus) hamburger.focus();
145
173
  }
146
174
 
147
- hamburger.addEventListener("click", () => {
148
- if (hamburger.getAttribute("aria-expanded") === "true") {
149
- close();
150
- } else {
151
- open();
152
- }
153
- });
154
-
155
- closeBtn?.addEventListener("click", () => close());
156
- backdrop.addEventListener("click", () => close());
157
- drawer.addEventListener("click", (event) => {
158
- if (!(event.target instanceof Element)) return;
159
- if (event.target.closest("a[href]")) {
160
- close(false);
161
- }
162
- });
175
+ hamburger.addEventListener(
176
+ "click",
177
+ () => {
178
+ if (hamburger.getAttribute("aria-expanded") === "true") {
179
+ close();
180
+ } else {
181
+ open();
182
+ }
183
+ },
184
+ { signal },
185
+ );
186
+
187
+ closeBtn?.addEventListener("click", () => close(), { signal });
188
+ backdrop.addEventListener("click", () => close(), { signal });
189
+ drawer.addEventListener(
190
+ "click",
191
+ (event) => {
192
+ if (!(event.target instanceof Element)) return;
193
+ if (event.target.closest("a[href]")) {
194
+ close(false);
195
+ }
196
+ },
197
+ { signal },
198
+ );
199
+
200
+ drawer.addEventListener(
201
+ "keydown",
202
+ (event) => {
203
+ if (event.key === "Escape") {
204
+ event.preventDefault();
205
+ close();
206
+ }
207
+ },
208
+ { signal },
209
+ );
210
+ }
163
211
 
164
- drawer.addEventListener("keydown", (event) => {
165
- if (event.key === "Escape") {
166
- event.preventDefault();
167
- close();
168
- }
169
- });
212
+ export function destroySiteHeaderNav(root = document) {
213
+ for (const trigger of root.querySelectorAll(".site-header-more-btn")) {
214
+ const controller = moreControllers.get(trigger);
215
+ controller?.abort();
216
+ moreControllers.delete(trigger);
217
+ delete trigger.dataset.moreInitialized;
218
+ }
219
+
220
+ for (const hamburger of root.querySelectorAll(".site-header-hamburger")) {
221
+ const controller = drawerControllers.get(hamburger);
222
+ controller?.abort();
223
+ drawerControllers.delete(hamburger);
224
+ delete hamburger.dataset.drawerInitialized;
225
+ }
226
+
227
+ document.documentElement.classList.remove("drawer-open");
170
228
  }
171
229
 
172
230
  initSiteHeaderNav();
@@ -62,4 +62,39 @@ describe("Block insertion flow", () => {
62
62
  expect(editor.state.selection.$from.parent.type.name).toBe("paragraph");
63
63
  expect(editor.state.selection.$from.parentOffset).toBe(0);
64
64
  });
65
+
66
+ it("renders a removable placeholder when an image fails to load", () => {
67
+ const editor = createEditor();
68
+
69
+ editor.commands.focus("start");
70
+ expect(editor.commands.setImage({ src: "/uploads/missing.webp" })).toBe(
71
+ true,
72
+ );
73
+
74
+ const figure = document.querySelector<HTMLElement>(".tiptap-image-figure");
75
+ const img = figure?.querySelector<HTMLImageElement>("img");
76
+ if (!figure || !img) {
77
+ throw new Error("expected image node view");
78
+ }
79
+
80
+ img.dispatchEvent(new Event("error"));
81
+
82
+ expect(figure.dataset.loadState).toBe("missing");
83
+ expect(figure.textContent).toContain("Image unavailable");
84
+
85
+ const deleteButton = figure.querySelector<HTMLButtonElement>(
86
+ ".tiptap-image-missing-delete",
87
+ );
88
+ if (!deleteButton) {
89
+ throw new Error("expected broken image delete button");
90
+ }
91
+
92
+ deleteButton.dispatchEvent(
93
+ new MouseEvent("mousedown", { bubbles: true, cancelable: true }),
94
+ );
95
+
96
+ expect(
97
+ editor.getJSON().content?.some((node) => node.type === "image"),
98
+ ).toBe(false);
99
+ });
65
100
  });
@@ -0,0 +1,117 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { Editor } from "@tiptap/core";
5
+ import StarterKit from "@tiptap/starter-kit";
6
+ import { ImageInputRules } from "../image-input-rules.js";
7
+ import { ImageNode } from "../image-node.js";
8
+ import { LinkInputRules } from "../link-input-rules.js";
9
+
10
+ const editors: Editor[] = [];
11
+
12
+ function createEditor(): Editor {
13
+ const element = document.createElement("div");
14
+ document.body.appendChild(element);
15
+
16
+ const editor = new Editor({
17
+ element,
18
+ extensions: [
19
+ StarterKit.configure({
20
+ heading: { levels: [1, 2, 3] },
21
+ link: { openOnClick: false, autolink: false },
22
+ }),
23
+ LinkInputRules,
24
+ ImageNode,
25
+ ImageInputRules,
26
+ ],
27
+ content: "<p></p>",
28
+ });
29
+
30
+ editors.push(editor);
31
+ return editor;
32
+ }
33
+
34
+ function type(editor: Editor, text: string): void {
35
+ const view = editor.view;
36
+ for (const ch of text) {
37
+ const { from, to } = view.state.selection;
38
+ const handled = view.someProp("handleTextInput", (handler) =>
39
+ handler(view, from, to, ch, () => view.state.tr.insertText(ch, from, to)),
40
+ );
41
+ if (!handled) view.dispatch(view.state.tr.insertText(ch, from, to));
42
+ }
43
+ }
44
+
45
+ afterEach(() => {
46
+ while (editors.length > 0) {
47
+ editors.pop()?.destroy();
48
+ }
49
+ document.body.innerHTML = "";
50
+ });
51
+
52
+ describe("ImageInputRules", () => {
53
+ it("converts typed empty-alt Markdown image syntax into an image node", () => {
54
+ const editor = createEditor();
55
+
56
+ type(editor, "![](https://example.com/image.png)");
57
+
58
+ expect(editor.getJSON().content).toEqual([
59
+ {
60
+ type: "image",
61
+ attrs: {
62
+ src: "https://example.com/image.png",
63
+ alt: "",
64
+ title: "",
65
+ caption: "",
66
+ href: "",
67
+ layout: "regular",
68
+ },
69
+ },
70
+ { type: "paragraph" },
71
+ ]);
72
+ expect(editor.state.selection.$from.parent.type.name).toBe("paragraph");
73
+ });
74
+
75
+ it("preserves alt text and optional quoted title", () => {
76
+ const editor = createEditor();
77
+
78
+ type(editor, '![A test image](https://example.com/image.png "Title")');
79
+
80
+ expect(editor.getJSON().content?.[0]).toMatchObject({
81
+ type: "image",
82
+ attrs: {
83
+ src: "https://example.com/image.png",
84
+ alt: "A test image",
85
+ title: "Title",
86
+ },
87
+ });
88
+ });
89
+
90
+ it("runs before Markdown link rules", () => {
91
+ const editor = createEditor();
92
+
93
+ type(editor, "![Alt](https://example.com/image.png)");
94
+
95
+ expect(editor.getJSON().content?.[0]).toMatchObject({
96
+ type: "image",
97
+ attrs: {
98
+ src: "https://example.com/image.png",
99
+ alt: "Alt",
100
+ },
101
+ });
102
+ expect(editor.state.doc.textContent).toBe("");
103
+ });
104
+
105
+ it("leaves unsafe image URLs as typed text", () => {
106
+ const editor = createEditor();
107
+
108
+ type(editor, "![](javascript:alert)");
109
+
110
+ expect(editor.getJSON().content).toEqual([
111
+ {
112
+ type: "paragraph",
113
+ content: [{ type: "text", text: "![](javascript:alert)" }],
114
+ },
115
+ ]);
116
+ });
117
+ });