@jant/core 0.6.8 → 0.6.10

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 (77) hide show
  1. package/bin/commands/uploads/cleanup.js +1 -0
  2. package/dist/{app-9P4rVCe2.js → app-CGHkOdme.js} +3450 -3121
  3. package/dist/app-D24n0DoH.js +6 -0
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/{client-CXnEhyyv.js → client-DYrWuaIk.js} +1 -1
  6. package/dist/client/_assets/{client-auth-CSItbyU8.js → client-auth-B5Re0uCd.js} +187 -167
  7. package/dist/client/_assets/client-xWDl78yi.css +2 -0
  8. package/dist/{export-Be082J0n.js → export-DY1v5Iqu.js} +2 -2
  9. package/dist/{github-sync-D1Cw8mOY.js → github-sync-2_T7nbOv.js} +1 -1
  10. package/dist/{github-sync-_kPWM4m9.js → github-sync-LefaslGJ.js} +2 -2
  11. package/dist/index.js +3 -3
  12. package/dist/node.js +4 -4
  13. package/package.json +1 -1
  14. package/src/client/components/__tests__/jant-settings-avatar.test.ts +8 -2
  15. package/src/client/components/__tests__/jant-settings-general.test.ts +64 -12
  16. package/src/client/components/jant-compose-dialog.ts +12 -0
  17. package/src/client/components/jant-settings-general.ts +74 -21
  18. package/src/client/components/settings-types.ts +13 -0
  19. package/src/client/settings-bridge.ts +3 -0
  20. package/src/client/tiptap/__tests__/link-toolbar.test.ts +41 -0
  21. package/src/client/tiptap/__tests__/mark-exit.test.ts +99 -0
  22. package/src/client/tiptap/bubble-menu.ts +37 -4
  23. package/src/client/tiptap/link-toolbar.ts +63 -1
  24. package/src/db/migrations/0026_absent_rhodey.sql +14 -0
  25. package/src/db/migrations/meta/0026_snapshot.json +2511 -0
  26. package/src/db/migrations/meta/_journal.json +7 -0
  27. package/src/db/migrations/pg/0024_high_violations.sql +14 -0
  28. package/src/db/migrations/pg/meta/0024_snapshot.json +3204 -0
  29. package/src/db/migrations/pg/meta/_journal.json +7 -0
  30. package/src/db/pg/schema.ts +36 -0
  31. package/src/db/schema.ts +36 -0
  32. package/src/i18n/__tests__/middleware.test.ts +46 -0
  33. package/src/i18n/locales/settings/en.po +282 -27
  34. package/src/i18n/locales/settings/en.ts +1 -1
  35. package/src/i18n/locales/settings/zh-Hans.po +282 -27
  36. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  37. package/src/i18n/locales/settings/zh-Hant.po +282 -27
  38. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  39. package/src/i18n/middleware.ts +17 -8
  40. package/src/i18n/supported-locales.ts +5 -4
  41. package/src/lib/__tests__/feed.test.ts +5 -1
  42. package/src/lib/feed.ts +6 -3
  43. package/src/lib/ids.ts +1 -0
  44. package/src/lib/resolve-config.ts +1 -0
  45. package/src/lib/upload.ts +14 -0
  46. package/src/routes/api/__tests__/settings.test.ts +1 -4
  47. package/src/routes/api/__tests__/upload.test.ts +2 -0
  48. package/src/routes/api/internal/__tests__/uploads.test.ts +19 -1
  49. package/src/routes/api/settings.ts +2 -1
  50. package/src/routes/auth/__tests__/setup.test.ts +14 -0
  51. package/src/routes/dash/__tests__/settings-avatar.test.ts +35 -17
  52. package/src/routes/dash/settings.tsx +22 -4
  53. package/src/routes/feed/__tests__/feed.test.ts +58 -19
  54. package/src/routes/feed/feed.ts +37 -28
  55. package/src/routes/pages/featured.tsx +17 -0
  56. package/src/routes/pages/latest.tsx +25 -0
  57. package/src/services/__tests__/media.test.ts +191 -30
  58. package/src/services/__tests__/settings.test.ts +55 -0
  59. package/src/services/bootstrap.ts +7 -0
  60. package/src/services/export-theme/layouts/_default/baseof.html +2 -1
  61. package/src/services/media.ts +169 -42
  62. package/src/services/post.ts +1 -1
  63. package/src/services/settings.ts +49 -15
  64. package/src/services/upload-session.ts +13 -3
  65. package/src/styles/tokens.css +21 -4
  66. package/src/styles/ui.css +44 -1
  67. package/src/types/bindings.ts +1 -0
  68. package/src/types/config.ts +13 -0
  69. package/src/ui/__tests__/color-themes.test.ts +2 -2
  70. package/src/ui/color-themes.ts +32 -0
  71. package/src/ui/dash/appearance/ColorThemeContent.tsx +264 -29
  72. package/src/ui/dash/settings/GeneralContent.tsx +54 -4
  73. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +3 -2
  74. package/src/ui/layouts/BaseLayout.tsx +3 -2
  75. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +17 -4
  76. package/dist/app-DaxS_Cz-.js +0 -6
  77. package/dist/client/_assets/client-C6peCkkD.css +0 -2
@@ -35,6 +35,8 @@ export interface SettingsLabels {
35
35
  mainFeedUrl: string;
36
36
  latestFeedUrl: string;
37
37
  featuredFeedUrl: string;
38
+ archiveFeedUrl: string;
39
+ archiveFeedUrlHelp: string;
38
40
  latestFeedOption: string;
39
41
  latestFeedOptionDescription: string;
40
42
  featuredFeedOption: string;
@@ -47,6 +49,10 @@ export interface SettingsLabels {
47
49
  siteLanguageSearchPlaceholder: string;
48
50
  /** Empty-state message when the search filters out every option. */
49
51
  siteLanguageNoMatches: string;
52
+ /** Lead text before the live `<html lang>` preview. */
53
+ contentLanguagePreview: string;
54
+ dashboardLanguage: string;
55
+ dashboardLanguageHelp: string;
50
56
  cjkFont: string;
51
57
  cjkFontHelp: string;
52
58
  timeZone: string;
@@ -75,10 +81,17 @@ export interface SettingsCjkFont {
75
81
  label: string;
76
82
  }
77
83
 
84
+ /** Dashboard UI language option for the select dropdown */
85
+ export interface SettingsDashboardLanguage {
86
+ value: string;
87
+ label: string;
88
+ }
89
+
78
90
  export interface SettingsInitialData {
79
91
  siteName: string;
80
92
  siteDescription: string;
81
93
  siteLanguage: string;
94
+ dashboardLanguage: string;
82
95
  cjkSerifFont: string;
83
96
  mainRssFeed: string;
84
97
  timeZone: string;
@@ -20,6 +20,8 @@ function parseSettingsInitialData(data: unknown): SettingsInitialData | null {
20
20
  const siteName = getJsonString(data, "siteName");
21
21
  const siteDescription = getJsonString(data, "siteDescription");
22
22
  const siteLanguage = getJsonString(data, "siteLanguage");
23
+ // Tolerate older payloads without the key: empty = follow content language.
24
+ const dashboardLanguage = getJsonString(data, "dashboardLanguage") ?? "";
23
25
  const cjkSerifFont = getJsonString(data, "cjkSerifFont");
24
26
  const mainRssFeed = getJsonString(data, "mainRssFeed");
25
27
  const timeZone = getJsonString(data, "timeZone");
@@ -45,6 +47,7 @@ function parseSettingsInitialData(data: unknown): SettingsInitialData | null {
45
47
  siteName,
46
48
  siteDescription,
47
49
  siteLanguage,
50
+ dashboardLanguage,
48
51
  cjkSerifFont,
49
52
  mainRssFeed,
50
53
  timeZone,
@@ -168,6 +168,47 @@ describe("LinkToolbar", () => {
168
168
  expect(linkMark).toBeUndefined();
169
169
  });
170
170
 
171
+ it("opens the popover when a link is clicked (tap on mobile)", async () => {
172
+ const editor = createEditor();
173
+
174
+ // Create a link, then move the caret off it and dismiss the popover.
175
+ editor.commands.setTextSelection({ from: 1, to: 6 });
176
+ editor.view.dom.dispatchEvent(new CustomEvent("tiptap:open-link-input"));
177
+ const urlInput = requireElement(
178
+ document.querySelector<HTMLInputElement>(".tiptap-link-input-field"),
179
+ "expected url field",
180
+ );
181
+ urlInput.value = "https://example.com";
182
+ urlInput.dispatchEvent(
183
+ new globalThis.KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
184
+ );
185
+ await Promise.resolve();
186
+
187
+ editor.commands.setTextSelection(10);
188
+ await Promise.resolve();
189
+ editor.commands.blur();
190
+ const popup = requireElement(
191
+ document.querySelector<HTMLElement>(".tiptap-link-input"),
192
+ "expected link popup",
193
+ );
194
+ popup.style.display = "none";
195
+
196
+ // Clicking the rendered link re-opens the popover without a prior caret
197
+ // move — this is the path that was broken on touch devices.
198
+ const anchor = requireElement(
199
+ editor.view.dom.querySelector<HTMLAnchorElement>("a"),
200
+ "expected rendered link",
201
+ );
202
+ anchor.dispatchEvent(
203
+ new globalThis.MouseEvent("click", { bubbles: true, button: 0 }),
204
+ );
205
+ await Promise.resolve();
206
+
207
+ expect(popup.style.display).toBe("flex");
208
+ expect(urlInput.value).toBe("https://example.com");
209
+ expect(editor.state.selection.empty).toBe(true);
210
+ });
211
+
171
212
  it("replaces the link text when the text field is edited", async () => {
172
213
  const editor = createEditor();
173
214
 
@@ -0,0 +1,99 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { Editor } from "@tiptap/core";
5
+ import { createMarkdownContentExtensions } from "../../../lib/markdown-manager.js";
6
+ import { ExitableMarks } from "../exitable-marks.js";
7
+ import { toggleMarkAndExit } from "../bubble-menu.js";
8
+
9
+ const editors: Editor[] = [];
10
+
11
+ function createEditor(content: string): Editor {
12
+ const element = document.createElement("div");
13
+ document.body.appendChild(element);
14
+ const editor = new Editor({
15
+ element,
16
+ extensions: [...createMarkdownContentExtensions(), ExitableMarks],
17
+ content,
18
+ });
19
+ editor.view.dispatch(editor.state.tr);
20
+ editors.push(editor);
21
+ return editor;
22
+ }
23
+
24
+ // Mimic real ProseMirror typed input: each char is offered to handleTextInput
25
+ // (used by mark input rules); if unhandled, insert it normally.
26
+ function type(editor: Editor, text: string): void {
27
+ const view = editor.view;
28
+ for (const ch of text) {
29
+ const { from, to } = view.state.selection;
30
+ const handled = view.someProp("handleTextInput", (f) =>
31
+ f(view, from, to, ch, () => view.state.tr.insertText(ch, from, to)),
32
+ );
33
+ if (!handled) view.dispatch(view.state.tr.insertText(ch, from, to));
34
+ }
35
+ }
36
+
37
+ /** Marks on the text node containing the last character of the doc. */
38
+ function marksOfLastText(editor: Editor): string[] {
39
+ const json = editor.getJSON();
40
+ const para = json.content?.[0];
41
+ const last = para?.content?.[para.content.length - 1];
42
+ return (last?.marks ?? []).map((m: { type: string }) => m.type);
43
+ }
44
+
45
+ afterEach(() => {
46
+ while (editors.length) editors.pop()?.destroy();
47
+ });
48
+
49
+ describe("toggleMarkAndExit", () => {
50
+ it("formats the selection, then continued typing is plain", () => {
51
+ const editor = createEditor("<p>hello</p>");
52
+ editor.chain().setTextSelection({ from: 1, to: 6 }).run();
53
+
54
+ toggleMarkAndExit(editor, "strike");
55
+ type(editor, "Z");
56
+
57
+ // "hello" struck, "Z" plain — cursor exited the inclusive mark.
58
+ expect(editor.getJSON().content?.[0]).toEqual({
59
+ type: "paragraph",
60
+ content: [
61
+ { type: "text", marks: [{ type: "strike" }], text: "hello" },
62
+ { type: "text", text: "Z" },
63
+ ],
64
+ });
65
+ });
66
+
67
+ it("works for bold the same way", () => {
68
+ const editor = createEditor("<p>word</p>");
69
+ editor.chain().setTextSelection({ from: 1, to: 5 }).run();
70
+
71
+ toggleMarkAndExit(editor, "bold");
72
+ type(editor, "!");
73
+
74
+ expect(marksOfLastText(editor)).toEqual([]);
75
+ });
76
+
77
+ it("toggling a mark off leaves the cursor plain", () => {
78
+ const editor = createEditor("<p><strong>bold</strong></p>");
79
+ editor.chain().setTextSelection({ from: 1, to: 5 }).run();
80
+
81
+ toggleMarkAndExit(editor, "bold");
82
+ type(editor, "x");
83
+
84
+ // Mark removed from the selection and from the trailing cursor.
85
+ expect(editor.isActive("bold")).toBe(false);
86
+ expect(marksOfLastText(editor)).toEqual([]);
87
+ });
88
+
89
+ it("with an empty selection it acts as a plain mode toggle (stays on)", () => {
90
+ const editor = createEditor("<p></p>");
91
+ editor.chain().setTextSelection(1).run();
92
+
93
+ toggleMarkAndExit(editor, "bold");
94
+ type(editor, "ab");
95
+
96
+ // No selection → mode toggle: typed text carries the mark.
97
+ expect(marksOfLastText(editor)).toEqual(["bold"]);
98
+ });
99
+ });
@@ -37,6 +37,39 @@ interface BubbleBtn {
37
37
  isActive: (view: EditorView) => boolean;
38
38
  }
39
39
 
40
+ /**
41
+ * Toggle an inline mark on the current selection, then drop out of it.
42
+ *
43
+ * The toolbar mark buttons (bold, italic) format the *selection* — the same
44
+ * intent as the `**x**` / `~~x~~` markdown shortcuts, which auto-exit once you
45
+ * type the closing delimiter. These marks are inclusive, so a collapsed cursor
46
+ * sitting at the end of the formatted word stays "inside" the mark and keeps
47
+ * extending as the user types, with no obvious way to stop (the bubble menu is
48
+ * hidden once the selection collapses). After toggling, we collapse the cursor
49
+ * to the end of the selection and remove the just-applied mark from the stored
50
+ * set so the next character is plain. Use the keyboard shortcuts (Mod-B /
51
+ * Mod-I) for mode-style "keep typing in this format".
52
+ */
53
+ export function toggleMarkAndExit(editor: Editor, markName: string): void {
54
+ const { to, empty } = editor.state.selection;
55
+ if (empty) {
56
+ // No selection (e.g. shortcut-driven): behave as a plain mode toggle.
57
+ editor.chain().focus().toggleMark(markName).run();
58
+ return;
59
+ }
60
+ const markType = editor.schema.marks[markName];
61
+ editor
62
+ .chain()
63
+ .focus()
64
+ .toggleMark(markName)
65
+ .setTextSelection(to)
66
+ .command(({ tr }) => {
67
+ if (markType) tr.removeStoredMark(markType);
68
+ return true;
69
+ })
70
+ .run();
71
+ }
72
+
40
73
  function getButtons(
41
74
  editor: Editor,
42
75
  toolbarMode: FormattingToolbarMode,
@@ -47,14 +80,14 @@ function getButtons(
47
80
  key: "bold",
48
81
  icon: ICONS.bold,
49
82
  title: "Bold",
50
- action: () => editor.chain().focus().toggleBold().run(),
83
+ action: () => toggleMarkAndExit(editor, "bold"),
51
84
  isActive: () => editor.isActive("bold"),
52
85
  },
53
86
  {
54
87
  key: "italic",
55
88
  icon: ICONS.italic,
56
89
  title: "Italic",
57
- action: () => editor.chain().focus().toggleItalic().run(),
90
+ action: () => toggleMarkAndExit(editor, "italic"),
58
91
  isActive: () => editor.isActive("italic"),
59
92
  },
60
93
  {
@@ -101,14 +134,14 @@ function getButtons(
101
134
  key: "bold",
102
135
  icon: ICONS.bold,
103
136
  title: "Bold",
104
- action: () => editor.chain().focus().toggleBold().run(),
137
+ action: () => toggleMarkAndExit(editor, "bold"),
105
138
  isActive: () => editor.isActive("bold"),
106
139
  },
107
140
  {
108
141
  key: "italic",
109
142
  icon: ICONS.italic,
110
143
  title: "Italic",
111
- action: () => editor.chain().focus().toggleItalic().run(),
144
+ action: () => toggleMarkAndExit(editor, "italic"),
112
145
  isActive: () => editor.isActive("italic"),
113
146
  },
114
147
  {
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import { Extension } from "@tiptap/core";
13
- import { Plugin, PluginKey } from "@tiptap/pm/state";
13
+ import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
14
14
  import type { EditorState } from "@tiptap/pm/state";
15
15
  import type { EditorView } from "@tiptap/pm/view";
16
16
  import {
@@ -331,9 +331,71 @@ export const LinkToolbar = Extension.create({
331
331
  suppressNextUpdate = true;
332
332
  }
333
333
 
334
+ /**
335
+ * Open the passive link popover when a link is clicked or tapped.
336
+ *
337
+ * The popover is otherwise shown only as a side effect of a collapsed
338
+ * caret landing inside a link (see the plugin `update` below). A mouse
339
+ * click reliably drops that caret, but a touch tap often does not — it may
340
+ * leave the selection unchanged or select a word — so on mobile the popover
341
+ * never appeared. Here we read the tapped position directly and force a
342
+ * collapsed caret inside the link, which works for both pointer types.
343
+ */
344
+ function handleLinkClick(view: EditorView, event: MouseEvent) {
345
+ if (event.button !== 0) return;
346
+ const target = event.target as HTMLElement | null;
347
+ const anchor = target?.closest("a");
348
+ if (!anchor || !view.dom.contains(anchor)) return;
349
+
350
+ const linkType = view.state.schema.marks.link;
351
+ if (!linkType) return;
352
+
353
+ // Don't disturb an active range selection (e.g. drag-selecting link
354
+ // text) — that case is owned by the bubble menu.
355
+ if (!view.state.selection.empty) return;
356
+
357
+ // When the click already dropped a caret inside a link (the desktop
358
+ // path), keep it where the user clicked. On touch the tap often doesn't
359
+ // move the caret into the link, so derive a position from the anchor
360
+ // element itself — targeting this exact link regardless of where the
361
+ // user tapped.
362
+ const cur = view.state.selection.from;
363
+ const hasLink = (p: number) =>
364
+ view.state.doc
365
+ .resolve(p)
366
+ .marks()
367
+ .some((m) => m.type === linkType);
368
+
369
+ let pos = cur;
370
+ if (!hasLink(cur)) {
371
+ try {
372
+ pos = view.posAtDOM(anchor, 0) + 1;
373
+ } catch {
374
+ return;
375
+ }
376
+ if (pos < 0 || pos > view.state.doc.content.size) return;
377
+ if (!hasLink(pos)) return;
378
+ }
379
+
380
+ // Re-show even if the user previously dismissed it, then (re)place a
381
+ // collapsed caret in the link so the passive popover flow picks it up.
382
+ suppressAutoShow = false;
383
+ view.dispatch(
384
+ view.state.tr.setSelection(TextSelection.create(view.state.doc, pos)),
385
+ );
386
+ }
387
+
334
388
  return [
335
389
  new Plugin({
336
390
  key: linkToolbarKey,
391
+ props: {
392
+ handleDOMEvents: {
393
+ click: (view, event) => {
394
+ handleLinkClick(view, event as MouseEvent);
395
+ return false;
396
+ },
397
+ },
398
+ },
337
399
  view(editorView) {
338
400
  createElements();
339
401
  const dialog = editorView.dom.closest("dialog");
@@ -0,0 +1,14 @@
1
+ CREATE TABLE `storage_purge` (
2
+ `id` text PRIMARY KEY NOT NULL,
3
+ `site_id` text NOT NULL,
4
+ `provider` text NOT NULL,
5
+ `storage_key` text NOT NULL,
6
+ `original_key` text NOT NULL,
7
+ `reason` text,
8
+ `purge_after` integer NOT NULL,
9
+ `created_at` integer NOT NULL,
10
+ FOREIGN KEY (`site_id`) REFERENCES `site`(`id`) ON UPDATE no action ON DELETE cascade
11
+ );
12
+ --> statement-breakpoint
13
+ CREATE UNIQUE INDEX `uq_storage_purge_provider_key` ON `storage_purge` (`provider`,`storage_key`);--> statement-breakpoint
14
+ CREATE INDEX `idx_storage_purge_site_provider_due` ON `storage_purge` (`site_id`,`provider`,`purge_after`);