@jant/core 0.6.12 → 0.6.14

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 (120) hide show
  1. package/bin/commands/import-site.js +2 -10
  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-D-zAhayc.js → app-2i7-WQHg.js} +426 -450
  7. package/dist/app-DZDlmh7C.js +6 -0
  8. package/dist/client/.vite/manifest.json +3 -3
  9. package/dist/client/_assets/{client-S1cdvulk.js → client-BvID5ucz.js} +1 -1
  10. package/dist/client/_assets/client-Cevfpm8H.css +2 -0
  11. package/dist/client/_assets/{client-auth-YW92ZH7u.js → client-auth-v3yvhbgZ.js} +596 -590
  12. package/dist/{export-Ckwh4BiE.js → export-DWn149b7.js} +24 -19
  13. package/dist/{github-sync-BMmK3JZ0.js → github-sync-C0zyF6XS.js} +2 -2
  14. package/dist/{github-sync-Dl9wQMar.js → github-sync-Ckk0sJxK.js} +1 -1
  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 +1 -2
  22. package/src/client/__tests__/site-header-nav.test.ts +36 -2
  23. package/src/client/components/__tests__/compose-title-from-heading.test.ts +57 -0
  24. package/src/client/components/__tests__/jant-command-palette.test.ts +137 -10
  25. package/src/client/components/__tests__/jant-compose-dialog.test.ts +3 -0
  26. package/src/client/components/__tests__/jant-compose-editor.test.ts +94 -2
  27. package/src/client/components/__tests__/jant-nav-manager.test.ts +107 -22
  28. package/src/client/components/__tests__/jant-settings-general.test.ts +1 -0
  29. package/src/client/components/compose-title-from-heading.ts +66 -0
  30. package/src/client/components/compose-types.ts +8 -0
  31. package/src/client/components/inline-image-issues.ts +17 -0
  32. package/src/client/components/jant-command-palette.ts +100 -70
  33. package/src/client/components/jant-compose-dialog.ts +3 -1
  34. package/src/client/components/jant-compose-editor.ts +78 -1
  35. package/src/client/components/jant-compose-fullscreen.ts +2 -0
  36. package/src/client/components/jant-nav-manager.ts +245 -155
  37. package/src/client/components/nav-manager-types.ts +0 -10
  38. package/src/client/site-header-nav.d.ts +1 -0
  39. package/src/client/site-header-nav.js +105 -47
  40. package/src/client/tiptap/__tests__/block-insertion.test.ts +35 -0
  41. package/src/client/tiptap/__tests__/footnotes.test.ts +284 -0
  42. package/src/client/tiptap/__tests__/image-input-rules.test.ts +117 -0
  43. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +148 -1
  44. package/src/client/tiptap/bubble-menu.ts +3 -3
  45. package/src/client/tiptap/create-editor.ts +7 -0
  46. package/src/client/tiptap/extensions.ts +7 -2
  47. package/src/client/tiptap/footnotes.ts +156 -0
  48. package/src/client/tiptap/image-input-rules.ts +48 -0
  49. package/src/client/tiptap/image-node.ts +189 -7
  50. package/src/client/tiptap/markdown-clipboard.ts +7 -2
  51. package/src/client-auth.ts +0 -1
  52. package/src/i18n/locales/public/en.po +20 -4
  53. package/src/i18n/locales/public/en.ts +1 -1
  54. package/src/i18n/locales/public/zh-Hans.po +20 -4
  55. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  56. package/src/i18n/locales/public/zh-Hant.po +20 -4
  57. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  58. package/src/i18n/locales/settings/en.po +23 -20
  59. package/src/i18n/locales/settings/en.ts +1 -1
  60. package/src/i18n/locales/settings/zh-Hans.po +23 -20
  61. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  62. package/src/i18n/locales/settings/zh-Hant.po +23 -20
  63. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  64. package/src/lib/__tests__/markdown-to-tiptap.test.ts +4 -0
  65. package/src/lib/__tests__/navigation.test.ts +4 -37
  66. package/src/lib/__tests__/resolve-config.test.ts +6 -1
  67. package/src/lib/__tests__/view.test.ts +6 -30
  68. package/src/lib/github-sync-site-config.ts +0 -2
  69. package/src/lib/hugo-markdown.ts +1 -1
  70. package/src/lib/markdown-manager.ts +14 -5
  71. package/src/lib/navigation.ts +5 -22
  72. package/src/lib/resolve-config.ts +9 -2
  73. package/src/lib/site-header-fragment.tsx +37 -0
  74. package/src/lib/view.ts +2 -7
  75. package/src/node/__tests__/cli-site-snapshot.test.ts +45 -45
  76. package/src/routes/api/__tests__/nav-items.test.ts +26 -0
  77. package/src/routes/api/export.ts +0 -2
  78. package/src/routes/api/internal/__tests__/uploads.test.ts +9 -13
  79. package/src/routes/api/internal/sites.ts +5 -5
  80. package/src/routes/api/nav-items.ts +25 -4
  81. package/src/routes/dash/settings.tsx +8 -2
  82. package/src/routes/feed/__tests__/feed.test.ts +42 -0
  83. package/src/routes/feed/__tests__/sitemap.test.ts +2 -13
  84. package/src/routes/feed/feed.ts +1 -0
  85. package/src/routes/feed/sitemap.ts +2 -6
  86. package/src/routes/pages/archive.tsx +1 -0
  87. package/src/routes/pages/collection.tsx +1 -0
  88. package/src/routes/pages/featured.tsx +0 -4
  89. package/src/routes/pages/home.tsx +4 -51
  90. package/src/routes/pages/latest.tsx +2 -51
  91. package/src/services/__tests__/github-app-installations.test.ts +10 -10
  92. package/src/services/__tests__/navigation.test.ts +41 -0
  93. package/src/services/__tests__/post-timeline.test.ts +56 -0
  94. package/src/services/__tests__/post.test.ts +33 -0
  95. package/src/services/__tests__/settings.test.ts +5 -20
  96. package/src/services/export-theme/assets/client-site.js +10 -10
  97. package/src/services/export-theme/layouts/_default/rss.xml +5 -15
  98. package/src/services/export-theme/layouts/index.html +26 -39
  99. package/src/services/export.ts +5 -10
  100. package/src/services/github-app-installations.ts +1 -3
  101. package/src/services/navigation.ts +44 -13
  102. package/src/services/post.ts +80 -57
  103. package/src/services/settings.ts +0 -10
  104. package/src/services/site-admin.ts +0 -2
  105. package/src/services/upload-session.ts +4 -18
  106. package/src/styles/tokens.css +2 -2
  107. package/src/styles/ui.css +316 -34
  108. package/src/types/bindings.ts +0 -1
  109. package/src/types/config.ts +3 -7
  110. package/src/ui/compose/ComposeDialog.tsx +28 -0
  111. package/src/ui/dash/appearance/NavigationContent.tsx +1 -1
  112. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  113. package/src/ui/dash/settings/SettingsRootContent.tsx +3 -3
  114. package/src/ui/feed/__tests__/timeline-cards.test.ts +8 -3
  115. package/src/ui/layouts/BaseLayout.tsx +1 -1
  116. package/src/ui/layouts/SiteLayout.tsx +266 -227
  117. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +48 -0
  118. package/dist/app-B5P1b4TF.js +0 -6
  119. package/dist/client/_assets/client-BUxR-E8O.css +0 -2
  120. package/src/client/nav-manager-bridge.ts +0 -54
@@ -1,4 +1,4 @@
1
1
  import "./url-BMYO-Zlt.js";
2
- import "./export-Ckwh4BiE.js";
3
- import { i as classifyRepoForSync, o as createGitHubSyncService } from "./github-sync-Dl9wQMar.js";
2
+ import "./export-DWn149b7.js";
3
+ import { i as classifyRepoForSync, o as createGitHubSyncService } from "./github-sync-Ckk0sJxK.js";
4
4
  export { classifyRepoForSync, createGitHubSyncService };
@@ -1,4 +1,4 @@
1
- import { c as parseMarkdownDocument, r as parseFrontMatter, t as createExportService } from "./export-Ckwh4BiE.js";
1
+ import { c as parseMarkdownDocument, r as parseFrontMatter, t as createExportService } from "./export-DWn149b7.js";
2
2
  import { r as getInstallationToken } from "./github-app-BbklkFmU.js";
3
3
  import { r as parseRepoSlug, t as createGitHubClient } from "./github-api-BgSiE71w.js";
4
4
  //#region src/lib/markdown-to-tiptap.ts
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { y as url_exports } from "./url-BMYO-Zlt.js";
2
- import { A as MAX_MEDIA_ATTACHMENTS, C as toMediaView, D as toPostViews, E as toPostView, F as STATUSES, I as TEXT_ATTACHMENT_CONTENT_FORMATS, M as MEDIA_KINDS, N as NAV_ITEM_TYPES, O as toSearchResultView, P as SORT_ORDERS, S as toArchiveGroupsWithMedia, T as toNavItemViews, b as createMediaContext, j as MAX_PINNED_POSTS, k as FORMATS, m as defaultFeedRenderer, t as createApp, w as toNavItemView, x as toArchiveGroups } from "./app-D-zAhayc.js";
3
- import { S as time_exports, a as markdown_exports } from "./export-Ckwh4BiE.js";
2
+ import { A as MAX_MEDIA_ATTACHMENTS, C as toMediaView, D as toPostViews, E as toPostView, F as STATUSES, I as TEXT_ATTACHMENT_CONTENT_FORMATS, M as MEDIA_KINDS, N as NAV_ITEM_TYPES, O as toSearchResultView, P as SORT_ORDERS, S as toArchiveGroupsWithMedia, T as toNavItemViews, b as createMediaContext, j as MAX_PINNED_POSTS, k as FORMATS, m as defaultFeedRenderer, t as createApp, w as toNavItemView, x as toArchiveGroups } from "./app-2i7-WQHg.js";
3
+ import { S as time_exports, a as markdown_exports } from "./export-DWn149b7.js";
4
4
  import "./env-OHRKGcMj.js";
5
- import "./github-sync-Dl9wQMar.js";
5
+ import "./github-sync-Ckk0sJxK.js";
6
6
  export { FORMATS, MAX_MEDIA_ATTACHMENTS, MAX_PINNED_POSTS, MEDIA_KINDS, NAV_ITEM_TYPES, SORT_ORDERS, STATUSES, TEXT_ATTACHMENT_CONTENT_FORMATS, createApp, createMediaContext, defaultFeedRenderer, markdown_exports as markdown, time_exports as time, toArchiveGroups, toArchiveGroupsWithMedia, toMediaView, toNavItemView, toNavItemViews, toPostView, toPostViews, toSearchResultView, url_exports as url };
package/dist/node.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import "./url-BMYO-Zlt.js";
2
- import { B as isAssetPath, L as buildThemeStyle, R as BUILTIN_COLOR_THEMES, _ as sqliteSchemaBundle, a as resolveDatabaseDialect, c as getWebhookUrl, d as BUILTIN_FONT_THEMES, f as getCjkSerifCssVariables, g as pgSchemaBundle, h as createStorageDriver, i as createSiteService, l as setMyCommands, n as createNodeCliRuntime, o as getHostBasedStartupConfigurationIssues, p as getFontThemeCssVariables, r as createNodeRequestRuntime, s as resolveConfig, t as createApp, u as setWebhook, v as createNodeDatabase, y as schema_exports, z as getPublicAssetBasePath } from "./app-D-zAhayc.js";
3
- import { t as createExportService } from "./export-Ckwh4BiE.js";
2
+ import { B as isAssetPath, L as buildThemeStyle, R as BUILTIN_COLOR_THEMES, _ as sqliteSchemaBundle, a as resolveDatabaseDialect, c as getWebhookUrl, d as BUILTIN_FONT_THEMES, f as getCjkSerifCssVariables, g as pgSchemaBundle, h as createStorageDriver, i as createSiteService, l as setMyCommands, n as createNodeCliRuntime, o as getHostBasedStartupConfigurationIssues, p as getFontThemeCssVariables, r as createNodeRequestRuntime, s as resolveConfig, t as createApp, u as setWebhook, v as createNodeDatabase, y as schema_exports, z as getPublicAssetBasePath } from "./app-2i7-WQHg.js";
3
+ import { t as createExportService } from "./export-DWn149b7.js";
4
4
  import { C as shouldTrustProxy, S as getTelegramWebhookSecret, b as getSiteResolutionMode, d as getHostedControlPlaneBaseUrl, i as getConfiguredSingleSitePathPrefix, l as getEnvString, r as getConfiguredSingleSiteOrigin, x as getTelegramBotPool, y as getPort } from "./env-OHRKGcMj.js";
5
- import "./github-sync-Dl9wQMar.js";
5
+ import "./github-sync-Ckk0sJxK.js";
6
6
  import { drizzle } from "drizzle-orm/better-sqlite3";
7
7
  import { serve } from "@hono/node-server";
8
8
  import Database from "better-sqlite3";
@@ -529,7 +529,7 @@ async function createNodeRequestHandler(options) {
529
529
  async function start(env = process.env, app) {
530
530
  const handler = await createNodeRequestHandler({
531
531
  env,
532
- app: async () => app ?? (await import("./app-B5P1b4TF.js")).createApp()
532
+ app: async () => app ?? (await import("./app-DZDlmh7C.js")).createApp()
533
533
  });
534
534
  const hostname = resolveHost(env);
535
535
  const port = resolvePort(env);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jant/core",
3
- "version": "0.6.12",
3
+ "version": "0.6.14",
4
4
  "description": "A modern, open-source microblogging platform built on Cloudflare Workers",
5
5
  "type": "module",
6
6
  "exports": {
@@ -281,7 +281,6 @@ describe("createExportService (Hugo)", () => {
281
281
  makeSiteConfig({
282
282
  siteName: "My Site",
283
283
  siteUrl: "https://my.example",
284
- homeDefaultView: "featured",
285
284
  }),
286
285
  );
287
286
  const files = filesToMap(await service.generateHugoFiles());
@@ -292,7 +291,7 @@ describe("createExportService (Hugo)", () => {
292
291
  expect(toml).toContain('theme = "jant"');
293
292
  expect(toml).toMatch(/\[permalinks\][\s\S]*post = "\/:slug\/"/);
294
293
  expect(toml).toContain("[params]");
295
- expect(toml).toContain('home_default_view = "featured"');
294
+ expect(toml).not.toContain("home_default_view");
296
295
  });
297
296
 
298
297
  it("configures hugo.toml for Atom RSS output with per-section opt-in", async () => {
@@ -123,6 +123,9 @@ export function createTestApp(options: TestAppOptions = {}) {
123
123
  c.set("appConfig", resolveConfig(c.env, allSettings));
124
124
  c.set("storage", options.storage ?? null);
125
125
  c.set("rateLimiter", rateLimiter);
126
+ const publicRequestUrl = c.req.url;
127
+ c.set("publicRequestUrl", publicRequestUrl);
128
+ c.set("publicPath", new URL(publicRequestUrl).pathname);
126
129
 
127
130
  // i18n (English default for tests)
128
131
  const i18n = createI18n("en");
@@ -88,7 +88,6 @@ export function makeSiteConfig(over: Partial<SiteConfig> = {}): SiteConfig {
88
88
  siteDescription: "Export fixture",
89
89
  siteLanguage: "en",
90
90
  showJantBrandingOnHome: true,
91
- homeDefaultView: "latest",
92
91
  mainRssFeed: "latest",
93
92
  siteFooter: "",
94
93
  showHeaderAvatar: false,
@@ -98,7 +98,6 @@ describe("Hugo import CLI helpers", () => {
98
98
  'site_name = "Example Site"',
99
99
  'site_description = "A description"',
100
100
  'site_language = "en"',
101
- 'home_default_view = "featured"',
102
101
  "show_jant_branding_on_home = true",
103
102
  "show_header_avatar = false",
104
103
  "noindex = false",
@@ -136,7 +135,7 @@ describe("Hugo import CLI helpers", () => {
136
135
  expect(siteConfig.title).toBe("Example Site");
137
136
  expect(siteConfig.base_url).toBe("https://example.com/");
138
137
  expect(siteConfig.extra.jant.theme_id).toBe("paper");
139
- expect(siteConfig.extra.jant.home_default_view).toBe("featured");
138
+ expect(siteConfig.extra.jant).not.toHaveProperty("home_default_view");
140
139
  expect(siteConfig.extra.jant.nav_exported).toBe(true);
141
140
  expect(siteConfig.extra.jant.nav).toHaveLength(1);
142
141
  expect(siteConfig.extra.jant.collections_directory_exported).toBe(true);
@@ -3,6 +3,8 @@
3
3
  import { beforeEach, describe, expect, it, vi } from "vitest";
4
4
 
5
5
  type SiteHeaderInit = typeof import("../site-header-nav.js").initSiteHeaderNav;
6
+ type SiteHeaderDestroy =
7
+ typeof import("../site-header-nav.js").destroySiteHeaderNav;
6
8
 
7
9
  function createDrawerDOM(): HTMLElement {
8
10
  const root = document.createElement("div");
@@ -50,13 +52,15 @@ function createMoreDropdownDOM(): HTMLElement {
50
52
 
51
53
  describe("site header more dropdown", () => {
52
54
  let initSiteHeaderNav: SiteHeaderInit;
55
+ let destroySiteHeaderNav: SiteHeaderDestroy;
53
56
  let root: HTMLElement;
54
57
 
55
58
  beforeEach(async () => {
56
59
  document.body.innerHTML = "";
57
60
  vi.resetModules();
58
61
  root = createMoreDropdownDOM();
59
- ({ initSiteHeaderNav } = await import("../site-header-nav.js"));
62
+ ({ initSiteHeaderNav, destroySiteHeaderNav } =
63
+ await import("../site-header-nav.js"));
60
64
  initSiteHeaderNav(root);
61
65
  });
62
66
 
@@ -130,10 +134,26 @@ describe("site header more dropdown", () => {
130
134
  expect(trigger.getAttribute("aria-expanded")).toBe("false");
131
135
  expect(popover.getAttribute("aria-hidden")).toBe("true");
132
136
  });
137
+
138
+ it("removes dropdown listeners when destroyed", () => {
139
+ const trigger = root.querySelector(
140
+ ".site-header-more-btn",
141
+ ) as HTMLButtonElement;
142
+ const popover = root.querySelector(
143
+ ".site-header-more-popover",
144
+ ) as HTMLElement;
145
+
146
+ destroySiteHeaderNav(root);
147
+ trigger.click();
148
+
149
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
150
+ expect(popover.getAttribute("aria-hidden")).toBe("true");
151
+ });
133
152
  });
134
153
 
135
154
  describe("site header nav drawer", () => {
136
155
  let initSiteHeaderNav: SiteHeaderInit;
156
+ let destroySiteHeaderNav: SiteHeaderDestroy;
137
157
  let root: HTMLElement;
138
158
 
139
159
  beforeEach(async () => {
@@ -141,7 +161,8 @@ describe("site header nav drawer", () => {
141
161
  document.documentElement.classList.remove("drawer-open");
142
162
  vi.resetModules();
143
163
  root = createDrawerDOM();
144
- ({ initSiteHeaderNav } = await import("../site-header-nav.js"));
164
+ ({ initSiteHeaderNav, destroySiteHeaderNav } =
165
+ await import("../site-header-nav.js"));
145
166
  initSiteHeaderNav(root);
146
167
  });
147
168
 
@@ -247,4 +268,17 @@ describe("site header nav drawer", () => {
247
268
  expect(drawer.getAttribute("aria-hidden")).toBe("true");
248
269
  expect(hamburger.getAttribute("aria-expanded")).toBe("false");
249
270
  });
271
+
272
+ it("removes drawer listeners when destroyed", () => {
273
+ const hamburger = root.querySelector(
274
+ ".site-header-hamburger",
275
+ ) as HTMLButtonElement;
276
+ const drawer = root.querySelector("#site-nav-drawer") as HTMLElement;
277
+
278
+ destroySiteHeaderNav(root);
279
+ hamburger.click();
280
+
281
+ expect(drawer.getAttribute("aria-hidden")).toBe("true");
282
+ expect(hamburger.getAttribute("aria-expanded")).toBe("false");
283
+ });
250
284
  });
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { JSONContent } from "@tiptap/core";
3
+
4
+ import { promoteLeadingH1Title } from "../compose-title-from-heading.js";
5
+
6
+ function doc(...content: JSONContent[]): JSONContent {
7
+ return { type: "doc", content };
8
+ }
9
+
10
+ function paragraph(text = ""): JSONContent {
11
+ return text
12
+ ? { type: "paragraph", content: [{ type: "text", text }] }
13
+ : { type: "paragraph" };
14
+ }
15
+
16
+ function heading(level: number, text = ""): JSONContent {
17
+ return text
18
+ ? {
19
+ type: "heading",
20
+ attrs: { level },
21
+ content: [{ type: "text", text }],
22
+ }
23
+ : { type: "heading", attrs: { level } };
24
+ }
25
+
26
+ describe("promoteLeadingH1Title", () => {
27
+ it("extracts the first non-empty H1 and removes it from the body", () => {
28
+ const result = promoteLeadingH1Title(
29
+ doc(paragraph(), heading(1, " My Title "), paragraph("Body")),
30
+ );
31
+
32
+ expect(result?.title).toBe("My Title");
33
+ expect(result?.headingIndex).toBe(1);
34
+ expect(result?.bodyJson).toEqual(doc(paragraph("Body")));
35
+ });
36
+
37
+ it("returns null when the first non-empty block is not an H1", () => {
38
+ const result = promoteLeadingH1Title(
39
+ doc(paragraph("Intro"), heading(1, "Title")),
40
+ );
41
+
42
+ expect(result).toBeNull();
43
+ });
44
+
45
+ it("returns null for empty H1 blocks", () => {
46
+ const result = promoteLeadingH1Title(doc(heading(1), paragraph("Body")));
47
+
48
+ expect(result).toBeNull();
49
+ });
50
+
51
+ it("returns a null body when the promoted H1 is the only content", () => {
52
+ const result = promoteLeadingH1Title(doc(heading(1, "Only title")));
53
+
54
+ expect(result?.title).toBe("Only title");
55
+ expect(result?.bodyJson).toBeNull();
56
+ });
57
+ });
@@ -7,23 +7,29 @@ import {
7
7
  } from "../jant-command-palette.js";
8
8
  import type { JantCommandPalette } from "../jant-command-palette.js";
9
9
 
10
- function mockPaletteApi() {
11
- vi.spyOn(globalThis, "fetch").mockResolvedValue(
12
- new Response(JSON.stringify({ items: [] }), {
13
- headers: { "Content-Type": "application/json" },
14
- }),
10
+ interface MockPaletteItem {
11
+ title: string;
12
+ path: string;
13
+ type: "post" | "collection" | "system";
14
+ }
15
+
16
+ function mockPaletteApi(items: MockPaletteItem[] = []) {
17
+ vi.spyOn(globalThis, "fetch").mockImplementation(
18
+ async () =>
19
+ new Response(JSON.stringify({ items }), {
20
+ headers: { "Content-Type": "application/json" },
21
+ }),
15
22
  );
16
23
  }
17
24
 
18
- async function renderPalette(query: string) {
19
- mockPaletteApi();
25
+ async function renderPalette(query: string, items: MockPaletteItem[] = []) {
26
+ mockPaletteApi(items);
20
27
  const el = document.createElement(
21
28
  "jant-command-palette",
22
29
  ) as JantCommandPalette;
23
- el._open = true;
24
- el._query = query;
25
30
  document.body.appendChild(el);
26
- await Promise.resolve();
31
+ await el.open();
32
+ el._query = query;
27
33
  await el.updateComplete;
28
34
  return el;
29
35
  }
@@ -39,6 +45,7 @@ describe("JantCommandPalette slash path mode", () => {
39
45
  vi.restoreAllMocks();
40
46
  document.body.innerHTML = "";
41
47
  globalThis.localStorage.clear();
48
+ globalThis.history.replaceState({}, "", "/");
42
49
  });
43
50
 
44
51
  it("shows go-to-path first and search second for slash queries", async () => {
@@ -61,3 +68,123 @@ describe("JantCommandPalette slash path mode", () => {
61
68
  expect(resultTitles(el)).toEqual(["Go to /notes", 'Search for "notes"']);
62
69
  });
63
70
  });
71
+
72
+ describe("JantCommandPalette persistent search action", () => {
73
+ beforeEach(() => {
74
+ vi.restoreAllMocks();
75
+ document.body.innerHTML = "";
76
+ globalThis.localStorage.clear();
77
+ globalThis.history.replaceState({}, "", "/");
78
+ });
79
+
80
+ const matchingItems: MockPaletteItem[] = Array.from(
81
+ { length: 12 },
82
+ (_, index) => ({
83
+ title: `Match ${index + 1}`,
84
+ path: `match-${index + 1}`,
85
+ type: "post",
86
+ }),
87
+ );
88
+
89
+ it("keeps search visible below all navigation matches", async () => {
90
+ const el = await renderPalette("match", matchingItems);
91
+
92
+ expect(
93
+ el.querySelectorAll(".command-palette-results > .command-palette-result"),
94
+ ).toHaveLength(12);
95
+ expect(resultTitles(el)).toEqual([
96
+ "Match 1",
97
+ "Match 2",
98
+ "Match 3",
99
+ "Match 4",
100
+ "Match 5",
101
+ "Match 6",
102
+ "Match 7",
103
+ "Match 8",
104
+ "Match 9",
105
+ "Match 10",
106
+ "Match 11",
107
+ "Match 12",
108
+ 'Search all content for "match"',
109
+ ]);
110
+ expect(
111
+ el
112
+ .querySelector(".command-palette-results-container")
113
+ ?.lastElementChild?.classList.contains("command-palette-search-action"),
114
+ ).toBe(true);
115
+ });
116
+
117
+ it("uses plain Enter without a shortcut hint when search is the only result", async () => {
118
+ const el = await renderPalette("nothing", matchingItems);
119
+ const dialog = el.querySelector<HTMLDialogElement>(".command-palette");
120
+
121
+ expect(resultTitles(el)).toEqual(['Search all content for "nothing"']);
122
+ expect(el.querySelector(".command-palette-result-shortcut")).toBeNull();
123
+
124
+ dialog?.dispatchEvent(
125
+ new globalThis.KeyboardEvent("keydown", {
126
+ key: "Enter",
127
+ bubbles: true,
128
+ cancelable: true,
129
+ }),
130
+ );
131
+
132
+ expect(globalThis.location.pathname).toBe("/search");
133
+ expect(globalThis.location.search).toBe("?q=nothing");
134
+ });
135
+
136
+ it("wraps ArrowUp from the best match to the search action", async () => {
137
+ const el = await renderPalette("match", matchingItems);
138
+ const dialog = el.querySelector<HTMLDialogElement>(".command-palette");
139
+
140
+ dialog?.dispatchEvent(
141
+ new globalThis.KeyboardEvent("keydown", {
142
+ key: "ArrowUp",
143
+ bubbles: true,
144
+ cancelable: true,
145
+ }),
146
+ );
147
+ await el.updateComplete;
148
+
149
+ expect(
150
+ el.querySelector(
151
+ ".command-palette-result-selected .command-palette-result-title",
152
+ )?.textContent,
153
+ ).toBe('Search all content for "match"');
154
+ });
155
+
156
+ it.each(["metaKey", "ctrlKey"] as const)(
157
+ "runs full-text search with the %s Enter shortcut",
158
+ async (modifier) => {
159
+ const el = await renderPalette("match", matchingItems);
160
+ const dialog = el.querySelector<HTMLDialogElement>(".command-palette");
161
+
162
+ dialog?.dispatchEvent(
163
+ new globalThis.KeyboardEvent("keydown", {
164
+ key: "Enter",
165
+ [modifier]: true,
166
+ bubbles: true,
167
+ cancelable: true,
168
+ }),
169
+ );
170
+
171
+ expect(globalThis.location.pathname).toBe("/search");
172
+ expect(globalThis.location.search).toBe("?q=match");
173
+ },
174
+ );
175
+
176
+ it("keeps plain Enter on the best navigation match", async () => {
177
+ const el = await renderPalette("match", matchingItems);
178
+ const dialog = el.querySelector<HTMLDialogElement>(".command-palette");
179
+
180
+ dialog?.dispatchEvent(
181
+ new globalThis.KeyboardEvent("keydown", {
182
+ key: "Enter",
183
+ bubbles: true,
184
+ cancelable: true,
185
+ }),
186
+ );
187
+
188
+ expect(globalThis.location.pathname).toBe("/match-1");
189
+ });
190
+ });
@@ -3077,6 +3077,9 @@ describe("JantComposeDialog", () => {
3077
3077
  expect(css).toMatch(
3078
3078
  /\.compose-reply-compose-layout\s+\.compose-thread-post-header\s*\+\s*\.compose-body\s*\{[\s\S]*padding-top:\s*12px;/,
3079
3079
  );
3080
+ expect(css).toMatch(
3081
+ /@media \(max-width:\s*760px\),\s*\(hover:\s*none\) and \(pointer:\s*coarse\)\s*\{[\s\S]*\.compose-thread-layout\s*\{[\s\S]*padding-top:\s*0\.75rem;[\s\S]*\.compose-thread-layout::before\s*\{[\s\S]*top:\s*0\.75rem;/,
3082
+ );
3080
3083
  expect(css).toMatch(
3081
3084
  /\.compose-dialog,[\s\S]*\.compose-page-shell\s*>\s*jant-compose-dialog\s*\{[\s\S]*--compose-quote-input-size:\s*var\(--type-content-subtitle\);[\s\S]*--compose-quote-input-leading:\s*1\.32;/,
3082
3085
  );
@@ -4,7 +4,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
4
4
  vi.mock("../../upload-with-metadata.js", () => ({
5
5
  uploadWithMetadata: vi.fn(),
6
6
  }));
7
- import type { Editor } from "@tiptap/core";
7
+ import type { Editor, JSONContent } from "@tiptap/core";
8
8
  import type { Slice } from "@tiptap/pm/model";
9
9
  import * as ProseMirrorView from "@tiptap/pm/view";
10
10
  import type { ComposeLabels } from "../compose-types.js";
@@ -301,6 +301,25 @@ async function createElement(
301
301
  return el;
302
302
  }
303
303
 
304
+ function tiptapDoc(...content: JSONContent[]): JSONContent {
305
+ return { type: "doc", content };
306
+ }
307
+
308
+ function tiptapHeading(level: number, text: string): JSONContent {
309
+ return {
310
+ type: "heading",
311
+ attrs: { level },
312
+ content: [{ type: "text", text }],
313
+ };
314
+ }
315
+
316
+ function tiptapParagraph(text: string): JSONContent {
317
+ return {
318
+ type: "paragraph",
319
+ content: [{ type: "text", text }],
320
+ };
321
+ }
322
+
304
323
  async function toggleEmojiPicker(el: JantComposeEditor) {
305
324
  (
306
325
  el as unknown as {
@@ -905,6 +924,77 @@ describe("JantComposeEditor", () => {
905
924
  expect(el.getData().title).toBe("My Title");
906
925
  });
907
926
 
927
+ it("promotes a leading H1 to the note title", async () => {
928
+ const el = await createElement("note");
929
+ const editor = requireEditor(el);
930
+ editor.commands.setContent(
931
+ tiptapDoc(tiptapHeading(1, "My Markdown Title"), tiptapParagraph("Body")),
932
+ );
933
+
934
+ el.promoteLeadingH1Title({ force: true });
935
+ await el.updateComplete;
936
+
937
+ const data = el.getData();
938
+ const body = JSON.parse(data.body) as JSONContent;
939
+
940
+ expect(data.title).toBe("My Markdown Title");
941
+ expect(el._showTitle).toBe(true);
942
+ expect(body.content?.[0]).toEqual(tiptapParagraph("Body"));
943
+ });
944
+
945
+ it("keeps a leading H1 in the body while the cursor is still in it", async () => {
946
+ const el = await createElement("note");
947
+ const editor = requireEditor(el);
948
+
949
+ editor.commands.setContent(tiptapDoc(tiptapHeading(1, "Draft title")));
950
+ await el.updateComplete;
951
+
952
+ const data = el.getData();
953
+ const body = JSON.parse(data.body) as JSONContent;
954
+
955
+ expect(data.title).toBe("");
956
+ expect(el._showTitle).toBe(false);
957
+ expect(body.content?.[0]).toEqual(tiptapHeading(1, "Draft title"));
958
+ });
959
+
960
+ it("does not overwrite an explicit note title with a leading H1", async () => {
961
+ const el = await createElement("note");
962
+ const editor = requireEditor(el);
963
+ el._title = "Manual title";
964
+ el._showTitle = true;
965
+
966
+ editor.commands.setContent(
967
+ tiptapDoc(tiptapHeading(1, "Markdown Title"), tiptapParagraph("Body")),
968
+ );
969
+
970
+ el.promoteLeadingH1Title({ force: true });
971
+ await el.updateComplete;
972
+
973
+ const data = el.getData();
974
+ const body = JSON.parse(data.body) as JSONContent;
975
+
976
+ expect(data.title).toBe("Manual title");
977
+ expect(body.content?.[0]).toEqual(tiptapHeading(1, "Markdown Title"));
978
+ });
979
+
980
+ it("does not promote a leading H1 for link posts", async () => {
981
+ const el = await createElement("link");
982
+ const editor = requireEditor(el);
983
+
984
+ editor.commands.setContent(
985
+ tiptapDoc(tiptapHeading(1, "Markdown Title"), tiptapParagraph("Body")),
986
+ );
987
+
988
+ el.promoteLeadingH1Title({ force: true });
989
+ await el.updateComplete;
990
+
991
+ const data = el.getData();
992
+ const body = JSON.parse(data.body) as JSONContent;
993
+
994
+ expect(data.title).toBe("");
995
+ expect(body.content?.[0]).toEqual(tiptapHeading(1, "Markdown Title"));
996
+ });
997
+
908
998
  it("omits rating when the rating control is hidden", async () => {
909
999
  const el = await createElement("note");
910
1000
  el._rating = 3;
@@ -1016,7 +1106,9 @@ describe("JantComposeEditor", () => {
1016
1106
  image.dispatchEvent(new Event("error"));
1017
1107
  await el.updateComplete;
1018
1108
 
1019
- expect(el.querySelector("[data-preview-failed='image']")).not.toBeNull();
1109
+ const fallback = el.querySelector("[data-preview-failed='image']");
1110
+ expect(fallback).not.toBeNull();
1111
+ expect(fallback?.textContent).toContain("Image unavailable");
1020
1112
  expect(el.querySelector(".compose-attachment-remove")).not.toBeNull();
1021
1113
  expect(el.querySelector(".compose-attachment-img")).toBeNull();
1022
1114
  });