@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
@@ -52,6 +52,22 @@ function track(src: string): string {
52
52
 
53
53
  const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
54
54
 
55
+ function dispatchPaste(editor: Editor, html: string, text = "") {
56
+ const event = new Event("paste", {
57
+ bubbles: true,
58
+ cancelable: true,
59
+ }) as Event & { clipboardData: unknown };
60
+ event.clipboardData = {
61
+ getData: (type: string) =>
62
+ type === "text/html" ? html : type === "text/plain" ? text : "",
63
+ files: [],
64
+ items: [],
65
+ types: ["text/html", "text/plain"],
66
+ };
67
+ editor.commands.focus();
68
+ editor.view.dom.dispatchEvent(event);
69
+ }
70
+
55
71
  afterEach(() => {
56
72
  while (editors.length > 0) editors.pop()?.destroy();
57
73
  for (const src of usedSrcs) clearRehostInFlight(src);
@@ -62,12 +78,12 @@ afterEach(() => {
62
78
  });
63
79
 
64
80
  describe("RehostImages extension", () => {
65
- it("fires rehost once for a remote image", async () => {
81
+ it("fires rehost once for a pasted remote image", async () => {
66
82
  const rehost = vi.fn();
67
83
  const editor = createEditor(rehost);
68
84
  const src = track("https://ext.example/a-remote.png");
69
85
 
70
- editor.commands.setImage({ src });
86
+ dispatchPaste(editor, `<p><img src="${src}"></p>`);
71
87
  await flush();
72
88
 
73
89
  expect(rehost).toHaveBeenCalledTimes(1);
@@ -79,7 +95,7 @@ describe("RehostImages extension", () => {
79
95
  const editor = createEditor(rehost);
80
96
  const src = track("data:image/png;base64,AAAAb");
81
97
 
82
- editor.commands.setImage({ src });
98
+ dispatchPaste(editor, `<p><img src="${src}"></p>`);
83
99
  await flush();
84
100
 
85
101
  expect(rehost).toHaveBeenCalledWith(src);
@@ -89,13 +105,15 @@ describe("RehostImages extension", () => {
89
105
  const rehost = vi.fn();
90
106
  document.documentElement.dataset.mediaBase = "https://cdn.mysite.test";
91
107
  const editor = createEditor(rehost);
92
-
93
- editor.commands.setImage({
94
- src: track(`${window.location.origin}/m/x.png`),
95
- });
96
- editor.commands.setImage({ src: track("/relative/y.png") });
97
- editor.commands.setImage({ src: track("blob:abc-123") });
98
- editor.commands.setImage({ src: track("https://cdn.mysite.test/z.png") });
108
+ const sameOrigin = track(`${window.location.origin}/m/x.png`);
109
+ const relative = track("/relative/y.png");
110
+ const blob = track("blob:abc-123");
111
+ const mediaBase = track("https://cdn.mysite.test/z.png");
112
+
113
+ dispatchPaste(
114
+ editor,
115
+ `<p><img src="${sameOrigin}"><img src="${relative}"><img src="${blob}"><img src="${mediaBase}"></p>`,
116
+ );
99
117
  await flush();
100
118
 
101
119
  expect(rehost).not.toHaveBeenCalled();
@@ -106,8 +124,43 @@ describe("RehostImages extension", () => {
106
124
  const editor = createEditor(rehost);
107
125
  const src = track("https://ext.example/dupe.png");
108
126
 
109
- editor.commands.setImage({ src });
110
- editor.commands.setImage({ src });
127
+ dispatchPaste(editor, `<p><img src="${src}"><img src="${src}"></p>`);
128
+ await flush();
129
+
130
+ expect(rehost).toHaveBeenCalledTimes(1);
131
+ });
132
+
133
+ it("does not rehost remote images loaded through setContent", async () => {
134
+ const rehost = vi.fn();
135
+ const editor = createEditor(rehost);
136
+ const src = track("https://ext.example/saved-external.png");
137
+
138
+ editor.commands.setContent({
139
+ type: "doc",
140
+ content: [
141
+ { type: "paragraph" },
142
+ {
143
+ type: "image",
144
+ attrs: { src },
145
+ },
146
+ ],
147
+ });
148
+ await flush();
149
+
150
+ expect(rehost).not.toHaveBeenCalled();
151
+ });
152
+
153
+ it("does not retry a failed paste rehost on later edits", async () => {
154
+ const rehost = vi.fn();
155
+ const editor = createEditor(rehost);
156
+ const src = track("https://ext.example/blocked.png");
157
+
158
+ dispatchPaste(editor, `<p><img src="${src}"></p>`);
159
+ await flush();
160
+ expect(rehost).toHaveBeenCalledTimes(1);
161
+
162
+ clearRehostInFlight(src);
163
+ editor.commands.insertContent("later edit");
111
164
  await flush();
112
165
 
113
166
  expect(rehost).toHaveBeenCalledTimes(1);
@@ -118,7 +171,7 @@ describe("RehostImages extension", () => {
118
171
  const editor = createEditor(rehost);
119
172
  const src = track("https://ext.example/swap.png");
120
173
 
121
- editor.commands.setImage({ src });
174
+ dispatchPaste(editor, `<p><img src="${src}"></p>`);
122
175
  await flush();
123
176
  expect(rehost).toHaveBeenCalledTimes(1);
124
177
 
@@ -12,6 +12,7 @@ import {
12
12
  import type { FormattingToolbarMode } from "./toolbar-mode.js";
13
13
  import type { PasteMediaOptions } from "./paste-media.js";
14
14
  import type { RehostImagesOptions } from "./rehost-images.js";
15
+ import type { ImageNodeLabels } from "./image-node.js";
15
16
  import { normalizeFootnoteArtifacts } from "../../lib/footnotes.js";
16
17
  import { tiptapJsonToMarkdown } from "../../lib/tiptap-to-markdown.js";
17
18
  import { parseMarkdownDocument } from "../../lib/markdown-manager.js";
@@ -22,10 +23,12 @@ export interface CreateEditorOptions {
22
23
  content?: JSONContent | null;
23
24
  onUpdate?: (json: JSONContent) => void;
24
25
  onFocus?: () => void;
26
+ onBlur?: () => void;
25
27
  onSelectionUpdate?: (selection: { from: number; to: number }) => void;
26
28
  toolbarMode?: FormattingToolbarMode;
27
29
  pasteMedia?: PasteMediaOptions;
28
30
  rehostImages?: RehostImagesOptions;
31
+ imageNodeLabels?: Partial<ImageNodeLabels>;
29
32
  }
30
33
 
31
34
  /**
@@ -42,6 +45,7 @@ export function createTiptapEditor(options: CreateEditorOptions): Editor {
42
45
  toolbarMode: options.toolbarMode,
43
46
  pasteMedia: options.pasteMedia,
44
47
  rehostImages: options.rehostImages,
48
+ imageNodeLabels: options.imageNodeLabels,
45
49
  }),
46
50
  content: options.content ?? undefined,
47
51
  editorProps: {
@@ -54,6 +58,9 @@ export function createTiptapEditor(options: CreateEditorOptions): Editor {
54
58
  onFocus: () => {
55
59
  options.onFocus?.();
56
60
  },
61
+ onBlur: () => {
62
+ options.onBlur?.();
63
+ },
57
64
  onSelectionUpdate: ({ editor }) => {
58
65
  options.onSelectionUpdate?.({
59
66
  from: editor.state.selection.from,
@@ -19,7 +19,7 @@ import { WrappingInputRules } from "./wrapping-input-rules.js";
19
19
  import { InsertParagraphAround } from "./insert-paragraph-around.js";
20
20
  import { Footnotes } from "./footnotes.js";
21
21
  import type { FormattingToolbarMode } from "./toolbar-mode.js";
22
- import { ImageNode } from "./image-node.js";
22
+ import { ImageNode, type ImageNodeLabels } from "./image-node.js";
23
23
  import { MoreBreak } from "./more-break.js";
24
24
  import { EmbedNode } from "./embed-node.js";
25
25
  import { HtmlBlockNode } from "./html-block-node.js";
@@ -27,6 +27,7 @@ import { EmbedPaste } from "./embed-paste.js";
27
27
  import { RehostImages } from "./rehost-images.js";
28
28
  import type { RehostImagesOptions } from "./rehost-images.js";
29
29
  import { MarkdownClipboard } from "./markdown-clipboard.js";
30
+ import { ImageInputRules } from "./image-input-rules.js";
30
31
  import {
31
32
  MARKDOWN_MARKED_OPTIONS,
32
33
  createMarkdownContentExtensions,
@@ -37,6 +38,7 @@ export interface EditorExtensionOptions {
37
38
  toolbarMode?: FormattingToolbarMode;
38
39
  pasteMedia?: PasteMediaOptions;
39
40
  rehostImages?: RehostImagesOptions;
41
+ imageNodeLabels?: Partial<ImageNodeLabels>;
40
42
  }
41
43
 
42
44
  /**
@@ -97,7 +99,9 @@ export function createEditorExtensions(
97
99
  ): Extensions {
98
100
  return [
99
101
  ...createMarkdownContentExtensions({
100
- imageExtension: ImageNode,
102
+ imageExtension: ImageNode.configure({
103
+ labels: options.imageNodeLabels,
104
+ }),
101
105
  moreBreakExtension: MoreBreak,
102
106
  embedExtension: EmbedNode,
103
107
  htmlBlockExtension: HtmlBlockNode,
@@ -110,6 +114,7 @@ export function createEditorExtensions(
110
114
  placeholder: options.placeholder ?? "Write something…",
111
115
  }),
112
116
  Footnotes,
117
+ ImageInputRules,
113
118
  LinkInputRules,
114
119
  WrappingInputRules,
115
120
  MarkdownClipboard,
@@ -0,0 +1,48 @@
1
+ import { Extension, InputRule } from "@tiptap/core";
2
+ import { sanitizeUrl } from "../../lib/url.js";
3
+ import { moveSelectionAfterBlockInsertion } from "./block-insertion.js";
4
+
5
+ // Keep the URL group aligned with link-input-rules: single-character
6
+ // alternatives avoid catastrophic backtracking on failed Markdown matches.
7
+ const MARKDOWN_IMAGE_INPUT_REGEX =
8
+ /!\[([^\]\n]*)\]\(((?:[^()\s"]|\([^()\s"]*\))+)(?:\s*"([^"\n]*)")?\)$/;
9
+
10
+ function sanitizeImageSrc(src: string): string {
11
+ const sanitized = sanitizeUrl(src.trim());
12
+ if (!sanitized || /^mailto:/i.test(sanitized)) return "";
13
+ return sanitized;
14
+ }
15
+
16
+ export const ImageInputRules = Extension.create({
17
+ name: "imageInputRules",
18
+
19
+ // Runs before LinkInputRules so `![alt](url)` is handled as an image rather
20
+ // than the inner `[alt](url)` being converted to a link.
21
+ priority: 1000,
22
+
23
+ addInputRules() {
24
+ const imageType = this.editor.schema.nodes.image;
25
+ if (!imageType) return [];
26
+
27
+ return [
28
+ new InputRule({
29
+ find: MARKDOWN_IMAGE_INPUT_REGEX,
30
+ handler: ({ state, range, match }) => {
31
+ const src = sanitizeImageSrc(match[2] ?? "");
32
+ if (!src) return null;
33
+
34
+ state.tr.replaceRangeWith(
35
+ range.from,
36
+ range.to,
37
+ imageType.create({
38
+ src,
39
+ alt: match[1] ?? "",
40
+ title: match[3] ?? "",
41
+ }),
42
+ );
43
+ moveSelectionAfterBlockInsertion(state.tr, state.schema);
44
+ },
45
+ }),
46
+ ];
47
+ },
48
+ });
@@ -20,6 +20,22 @@ import type { EditorView } from "@tiptap/pm/view";
20
20
  import { uploadWithMetadata } from "../upload-with-metadata.js";
21
21
  import { renderMarkdownImage } from "../../lib/rich-image.js";
22
22
 
23
+ export const INLINE_IMAGE_LOAD_STATE_EVENT = "jant:inline-image-load-state";
24
+
25
+ export interface ImageNodeLabels {
26
+ unavailable: string;
27
+ delete: string;
28
+ replace: string;
29
+ open: string;
30
+ }
31
+
32
+ const DEFAULT_IMAGE_NODE_LABELS: ImageNodeLabels = {
33
+ unavailable: "Image unavailable",
34
+ delete: "Delete image",
35
+ replace: "Replace image",
36
+ open: "Open image URL",
37
+ };
38
+
23
39
  declare module "@tiptap/core" {
24
40
  interface Commands<ReturnType> {
25
41
  image: {
@@ -40,6 +56,8 @@ declare module "@tiptap/core" {
40
56
  // ---------------------------------------------------------------------------
41
57
 
42
58
  const ICONS = {
59
+ /** Broken image placeholder */
60
+ imageBroken: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2.5"/><circle cx="8.5" cy="9.5" r="1.5"/><path d="m5 17 4.25-4.25 2.5 2.5 1.75-1.75L19 19"/><path d="m4 4 16 16"/></svg>`,
43
61
  /** Content-width — centered column */
44
62
  regular: `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><rect x="3" y="3" width="10" height="10" rx="1.5"/></svg>`,
45
63
  /** Wide — max 1200 px breakout */
@@ -62,6 +80,9 @@ class ImageNodeView {
62
80
  dom: HTMLElement;
63
81
 
64
82
  private img: HTMLImageElement;
83
+ private missingPanel: HTMLElement;
84
+ private missingSource: HTMLElement;
85
+ private openBtn: HTMLButtonElement;
65
86
  private figcaption: HTMLElement;
66
87
  private captionInput: HTMLInputElement;
67
88
  private altBtn: HTMLButtonElement;
@@ -73,25 +94,31 @@ class ImageNodeView {
73
94
  private view: EditorView;
74
95
  private getPos: () => number | undefined;
75
96
  private editor: Editor;
97
+ private labels: ImageNodeLabels;
76
98
 
77
99
  private editingAlt = false;
100
+ private missing = false;
101
+ private currentSrc = "";
78
102
 
79
103
  constructor(
80
104
  node: ProseMirrorNode,
81
105
  view: EditorView,
82
106
  getPos: () => number | undefined,
83
107
  editor: Editor,
108
+ labels: ImageNodeLabels,
84
109
  ) {
85
110
  this.node = node;
86
111
  this.view = view;
87
112
  this.getPos = getPos;
88
113
  this.editor = editor;
114
+ this.labels = labels;
89
115
 
90
116
  // --- Build DOM tree ---
91
117
  const figure = document.createElement("figure");
92
118
  figure.className = "tiptap-image-figure";
93
119
  figure.dataset.selected = "false";
94
120
  figure.dataset.layout = String(node.attrs.layout || "regular");
121
+ figure.dataset.loadState = "loading";
95
122
  this.dom = figure;
96
123
 
97
124
  // Image container
@@ -101,13 +128,78 @@ class ImageNodeView {
101
128
 
102
129
  // <img>
103
130
  const img = document.createElement("img");
104
- img.src = String(node.attrs.src ?? "");
105
- img.alt = String(node.attrs.alt ?? "");
106
- if (node.attrs.title) img.title = String(node.attrs.title);
107
131
  img.draggable = false;
132
+ img.addEventListener("load", () => this.setMissing(false));
133
+ img.addEventListener("error", () => this.setMissing(true));
108
134
  container.appendChild(img);
109
135
  this.img = img;
110
136
 
137
+ // --- Missing image panel ---
138
+ const missingPanel = document.createElement("div");
139
+ missingPanel.className = "tiptap-image-missing";
140
+ missingPanel.role = "group";
141
+ missingPanel.tabIndex = 0;
142
+ missingPanel.setAttribute("aria-label", labels.unavailable);
143
+ missingPanel.addEventListener("keydown", (e) => {
144
+ if (e.key !== "Delete" && e.key !== "Backspace") return;
145
+ e.preventDefault();
146
+ this.deleteNode();
147
+ });
148
+ container.appendChild(missingPanel);
149
+ this.missingPanel = missingPanel;
150
+
151
+ const missingIcon = document.createElement("span");
152
+ missingIcon.className = "tiptap-image-missing-icon";
153
+ missingIcon.setAttribute("aria-hidden", "true");
154
+ missingIcon.innerHTML = ICONS.imageBroken;
155
+ missingPanel.appendChild(missingIcon);
156
+
157
+ const missingCopy = document.createElement("span");
158
+ missingCopy.className = "tiptap-image-missing-copy";
159
+ missingPanel.appendChild(missingCopy);
160
+
161
+ const missingTitle = document.createElement("strong");
162
+ missingTitle.textContent = labels.unavailable;
163
+ missingCopy.appendChild(missingTitle);
164
+
165
+ const missingSource = document.createElement("span");
166
+ missingSource.className = "tiptap-image-missing-source";
167
+ missingCopy.appendChild(missingSource);
168
+ this.missingSource = missingSource;
169
+
170
+ const missingActions = document.createElement("span");
171
+ missingActions.className = "tiptap-image-missing-actions";
172
+ missingPanel.appendChild(missingActions);
173
+
174
+ const deleteBtn = document.createElement("button");
175
+ deleteBtn.type = "button";
176
+ deleteBtn.className = "tiptap-image-missing-delete";
177
+ deleteBtn.textContent = labels.delete;
178
+ deleteBtn.addEventListener("mousedown", (e) => {
179
+ e.preventDefault();
180
+ this.deleteNode();
181
+ });
182
+ missingActions.appendChild(deleteBtn);
183
+
184
+ const replaceMissingBtn = document.createElement("button");
185
+ replaceMissingBtn.type = "button";
186
+ replaceMissingBtn.textContent = labels.replace;
187
+ replaceMissingBtn.addEventListener("mousedown", (e) => {
188
+ e.preventDefault();
189
+ this.handleReplace();
190
+ });
191
+ missingActions.appendChild(replaceMissingBtn);
192
+
193
+ const openBtn = document.createElement("button");
194
+ openBtn.type = "button";
195
+ openBtn.textContent = labels.open;
196
+ openBtn.addEventListener("mousedown", (e) => {
197
+ e.preventDefault();
198
+ this.handleOpenOriginal();
199
+ });
200
+ missingActions.appendChild(openBtn);
201
+ this.openBtn = openBtn;
202
+
111
203
  // --- Toolbar (shown when selected) ---
112
204
  const toolbar = document.createElement("div");
113
205
  toolbar.className = "tiptap-image-toolbar";
@@ -206,6 +298,8 @@ class ImageNodeView {
206
298
  figcaption.textContent = String(node.attrs.caption ?? "");
207
299
  figure.appendChild(figcaption);
208
300
  this.figcaption = figcaption;
301
+
302
+ this.syncImageAttrs(node);
209
303
  }
210
304
 
211
305
  // --- ProseMirror NodeView interface ---
@@ -215,9 +309,7 @@ class ImageNodeView {
215
309
  this.node = node;
216
310
 
217
311
  // Sync DOM with new attrs
218
- this.img.src = String(node.attrs.src ?? "");
219
- this.img.alt = String(node.attrs.alt ?? "");
220
- this.img.title = String(node.attrs.title ?? "");
312
+ this.syncImageAttrs(node);
221
313
 
222
314
  this.dom.dataset.layout = String(node.attrs.layout || "regular");
223
315
  this.layoutBtns.forEach((btn, value) => {
@@ -259,6 +351,7 @@ class ImageNodeView {
259
351
  // Let the NodeView handle events on toolbar, caption bar, and their children
260
352
  if (target.closest(".tiptap-image-toolbar")) return true;
261
353
  if (target.closest(".tiptap-image-caption-bar")) return true;
354
+ if (target.closest(".tiptap-image-missing")) return true;
262
355
  return false;
263
356
  }
264
357
 
@@ -296,6 +389,65 @@ class ImageNodeView {
296
389
  this.view.dispatch(tr);
297
390
  }
298
391
 
392
+ private syncImageAttrs(node: ProseMirrorNode) {
393
+ const src = String(node.attrs.src ?? "");
394
+ this.img.alt = String(node.attrs.alt ?? "");
395
+ this.img.title = String(node.attrs.title ?? "");
396
+ this.missingSource.textContent = imageSourceHint(src);
397
+ this.openBtn.disabled = !canOpenImageSource(src);
398
+
399
+ if (src !== this.currentSrc) {
400
+ this.currentSrc = src;
401
+ this.setMissing(false);
402
+ this.dom.dataset.loadState = "loading";
403
+ this.img.setAttribute("src", src);
404
+ this.checkCachedImageState();
405
+ return;
406
+ }
407
+
408
+ this.checkCachedImageState();
409
+ }
410
+
411
+ private checkCachedImageState() {
412
+ queueMicrotask(() => {
413
+ if (
414
+ !this.img.complete ||
415
+ this.img.getAttribute("src") !== this.currentSrc
416
+ ) {
417
+ return;
418
+ }
419
+ this.setMissing(this.img.naturalWidth === 0);
420
+ });
421
+ }
422
+
423
+ private setMissing(missing: boolean) {
424
+ if (this.missing === missing) {
425
+ this.dom.dataset.loadState = missing ? "missing" : "loaded";
426
+ return;
427
+ }
428
+
429
+ this.missing = missing;
430
+ this.dom.dataset.loadState = missing ? "missing" : "loaded";
431
+ this.dom.dispatchEvent(
432
+ new CustomEvent(INLINE_IMAGE_LOAD_STATE_EVENT, {
433
+ bubbles: true,
434
+ composed: true,
435
+ detail: {
436
+ src: this.currentSrc,
437
+ missing,
438
+ },
439
+ }),
440
+ );
441
+ }
442
+
443
+ private deleteNode() {
444
+ const pos = this.getPos();
445
+ if (pos === undefined) return;
446
+ const tr = this.view.state.tr.delete(pos, pos + this.node.nodeSize);
447
+ this.view.dispatch(tr);
448
+ this.view.focus();
449
+ }
450
+
299
451
  private handleLink() {
300
452
  const current = String(this.node.attrs.href ?? "");
301
453
  if (current) {
@@ -324,6 +476,12 @@ class ImageNodeView {
324
476
  input.click();
325
477
  }
326
478
 
479
+ private handleOpenOriginal() {
480
+ const src = String(this.node.attrs.src ?? "");
481
+ if (!canOpenImageSource(src)) return;
482
+ window.open(src, "_blank", "noopener,noreferrer");
483
+ }
484
+
327
485
  private handleExpand() {
328
486
  const lightbox = document.querySelector("jant-media-lightbox") as {
329
487
  open: (
@@ -358,17 +516,36 @@ class ImageNodeView {
358
516
  }
359
517
  }
360
518
 
519
+ function imageSourceHint(src: string): string {
520
+ const trimmed = src.trim();
521
+ if (!trimmed) return "";
522
+ if (trimmed.length > 140) return `${trimmed.slice(0, 137)}...`;
523
+ return trimmed;
524
+ }
525
+
526
+ function canOpenImageSource(src: string): boolean {
527
+ const trimmed = src.trim();
528
+ if (!trimmed) return false;
529
+ return /^(https?:|\/|\.\/|\.\.\/|blob:)/i.test(trimmed);
530
+ }
531
+
361
532
  // ---------------------------------------------------------------------------
362
533
  // Node Extension
363
534
  // ---------------------------------------------------------------------------
364
535
 
365
- export const ImageNode = Node.create({
536
+ export const ImageNode = Node.create<{ labels?: Partial<ImageNodeLabels> }>({
366
537
  name: "image",
367
538
  group: "block",
368
539
  atom: true,
369
540
  selectable: true,
370
541
  draggable: true,
371
542
 
543
+ addOptions() {
544
+ return {
545
+ labels: DEFAULT_IMAGE_NODE_LABELS,
546
+ };
547
+ },
548
+
372
549
  addAttributes() {
373
550
  return {
374
551
  src: { default: "" },
@@ -514,11 +691,16 @@ export const ImageNode = Node.create({
514
691
 
515
692
  addNodeView() {
516
693
  return ({ node, view, getPos, editor }) => {
694
+ const labels = {
695
+ ...DEFAULT_IMAGE_NODE_LABELS,
696
+ ...(this.options.labels ?? {}),
697
+ };
517
698
  return new ImageNodeView(
518
699
  node,
519
700
  view,
520
701
  getPos as () => number | undefined,
521
702
  editor,
703
+ labels,
522
704
  );
523
705
  };
524
706
  },
@@ -62,7 +62,11 @@ export const RehostImages = Extension.create<RehostImagesOptions>({
62
62
  new Plugin({
63
63
  key: rehostImagesKey,
64
64
  appendTransaction(transactions, _oldState, newState) {
65
- if (!transactions.some((tr) => tr.docChanged)) return null;
65
+ if (
66
+ !transactions.some((tr) => tr.docChanged && tr.getMeta("paste"))
67
+ ) {
68
+ return null;
69
+ }
66
70
  const { shouldRehost, rehost } = options;
67
71
  if (!shouldRehost || !rehost) return null;
68
72
 
@@ -26,7 +26,6 @@ import "./client/collection-form-bridge.js";
26
26
  import "./client/components/jant-post-form.js";
27
27
  import "./client/post-form-bridge.js";
28
28
  import "./client/components/jant-nav-manager.js";
29
- import "./client/nav-manager-bridge.js";
30
29
  import "./client/components/jant-post-menu.js";
31
30
  import "./client/collection-page-actions.js";
32
31
  import "./client/custom-url-menu.js";
@@ -1,7 +1,15 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
1
3
  import { describe, expect, it } from "vitest";
2
4
  import { msg } from "@lingui/core/macro";
3
5
  import { createI18n } from "../i18n.js";
4
6
 
7
+ const CORE_ROOT = resolve(import.meta.dirname, "../../..");
8
+
9
+ function readLocaleFile(path: string): string {
10
+ return readFileSync(resolve(CORE_ROOT, path), "utf8");
11
+ }
12
+
5
13
  /**
6
14
  * Guards the "public surface stays English under non-English locales" contract.
7
15
  *
@@ -37,4 +45,20 @@ describe("i18n locale fallback", () => {
37
45
  });
38
46
  expect(i18n._(unique)).toBe("Jant fallback canary string");
39
47
  });
48
+
49
+ it("keeps Chinese settings format labels concise", () => {
50
+ const zhHansSettings = readLocaleFile(
51
+ "src/i18n/locales/settings/zh-Hans.po",
52
+ );
53
+ const zhHantSettings = readLocaleFile(
54
+ "src/i18n/locales/settings/zh-Hant.po",
55
+ );
56
+
57
+ expect(zhHansSettings).toContain('msgid "Quote"\nmsgstr "引用"');
58
+ expect(zhHantSettings).toContain('msgid "Link"\nmsgstr "連結"');
59
+ expect(zhHantSettings).toContain('msgid "Quote"\nmsgstr "引用"');
60
+
61
+ expect(zhHansSettings).not.toContain("Jant 的帖子格式之一");
62
+ expect(zhHantSettings).not.toContain("Jant 的貼文格式之一");
63
+ });
40
64
  });