@jant/core 0.6.12 → 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 (106) 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-CWJFPjuR.js +6 -0
  7. package/dist/{app-D-zAhayc.js → app-HnzmsaqU.js} +416 -444
  8. package/dist/client/.vite/manifest.json +3 -3
  9. package/dist/client/_assets/{client-S1cdvulk.js → client-3_OaxhTs.js} +1 -1
  10. package/dist/client/_assets/{client-BUxR-E8O.css → client-DCrcHVJl.css} +1 -1
  11. package/dist/client/_assets/{client-auth-YW92ZH7u.js → client-auth-TsYq90xm.js} +570 -567
  12. package/dist/{export-Ckwh4BiE.js → export-Dfbq9aot.js} +10 -13
  13. package/dist/{github-sync-Dl9wQMar.js → github-sync--PSoE-Ne.js} +1 -1
  14. package/dist/{github-sync-BMmK3JZ0.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 +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-compose-editor.test.ts +94 -2
  25. package/src/client/components/__tests__/jant-nav-manager.test.ts +107 -22
  26. package/src/client/components/__tests__/jant-settings-general.test.ts +1 -0
  27. package/src/client/components/compose-title-from-heading.ts +66 -0
  28. package/src/client/components/compose-types.ts +8 -0
  29. package/src/client/components/inline-image-issues.ts +17 -0
  30. package/src/client/components/jant-compose-dialog.ts +2 -0
  31. package/src/client/components/jant-compose-editor.ts +78 -1
  32. package/src/client/components/jant-compose-fullscreen.ts +2 -0
  33. package/src/client/components/jant-nav-manager.ts +245 -155
  34. package/src/client/components/nav-manager-types.ts +0 -10
  35. package/src/client/site-header-nav.d.ts +1 -0
  36. package/src/client/site-header-nav.js +105 -47
  37. package/src/client/tiptap/__tests__/block-insertion.test.ts +35 -0
  38. package/src/client/tiptap/__tests__/image-input-rules.test.ts +117 -0
  39. package/src/client/tiptap/create-editor.ts +7 -0
  40. package/src/client/tiptap/extensions.ts +7 -2
  41. package/src/client/tiptap/image-input-rules.ts +48 -0
  42. package/src/client/tiptap/image-node.ts +189 -7
  43. package/src/client-auth.ts +0 -1
  44. package/src/i18n/locales/public/en.po +20 -4
  45. package/src/i18n/locales/public/en.ts +1 -1
  46. package/src/i18n/locales/public/zh-Hans.po +20 -4
  47. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  48. package/src/i18n/locales/public/zh-Hant.po +20 -4
  49. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  50. package/src/i18n/locales/settings/en.po +23 -20
  51. package/src/i18n/locales/settings/en.ts +1 -1
  52. package/src/i18n/locales/settings/zh-Hans.po +23 -20
  53. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  54. package/src/i18n/locales/settings/zh-Hant.po +23 -20
  55. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  56. package/src/lib/__tests__/navigation.test.ts +4 -37
  57. package/src/lib/__tests__/resolve-config.test.ts +6 -1
  58. package/src/lib/__tests__/view.test.ts +6 -30
  59. package/src/lib/github-sync-site-config.ts +0 -2
  60. package/src/lib/hugo-markdown.ts +1 -1
  61. package/src/lib/navigation.ts +5 -22
  62. package/src/lib/resolve-config.ts +9 -2
  63. package/src/lib/site-header-fragment.tsx +37 -0
  64. package/src/lib/view.ts +2 -7
  65. package/src/node/__tests__/cli-site-snapshot.test.ts +45 -45
  66. package/src/routes/api/__tests__/nav-items.test.ts +26 -0
  67. package/src/routes/api/export.ts +0 -2
  68. package/src/routes/api/internal/__tests__/uploads.test.ts +9 -13
  69. package/src/routes/api/internal/sites.ts +5 -5
  70. package/src/routes/api/nav-items.ts +25 -4
  71. package/src/routes/dash/settings.tsx +8 -2
  72. package/src/routes/feed/__tests__/feed.test.ts +42 -0
  73. package/src/routes/feed/__tests__/sitemap.test.ts +2 -13
  74. package/src/routes/feed/feed.ts +1 -0
  75. package/src/routes/feed/sitemap.ts +2 -6
  76. package/src/routes/pages/archive.tsx +1 -0
  77. package/src/routes/pages/collection.tsx +1 -0
  78. package/src/routes/pages/featured.tsx +0 -4
  79. package/src/routes/pages/home.tsx +4 -51
  80. package/src/routes/pages/latest.tsx +2 -51
  81. package/src/services/__tests__/github-app-installations.test.ts +10 -10
  82. package/src/services/__tests__/navigation.test.ts +41 -0
  83. package/src/services/__tests__/post-timeline.test.ts +56 -0
  84. package/src/services/__tests__/post.test.ts +33 -0
  85. package/src/services/__tests__/settings.test.ts +5 -20
  86. package/src/services/export-theme/assets/client-site.js +10 -10
  87. package/src/services/export-theme/layouts/_default/rss.xml +5 -15
  88. package/src/services/export-theme/layouts/index.html +26 -39
  89. package/src/services/export.ts +5 -10
  90. package/src/services/github-app-installations.ts +1 -3
  91. package/src/services/navigation.ts +44 -13
  92. package/src/services/post.ts +80 -57
  93. package/src/services/settings.ts +0 -10
  94. package/src/services/site-admin.ts +0 -2
  95. package/src/services/upload-session.ts +4 -18
  96. package/src/styles/tokens.css +2 -2
  97. package/src/styles/ui.css +200 -6
  98. package/src/types/bindings.ts +0 -1
  99. package/src/types/config.ts +3 -7
  100. package/src/ui/compose/ComposeDialog.tsx +28 -0
  101. package/src/ui/dash/appearance/NavigationContent.tsx +1 -1
  102. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  103. package/src/ui/dash/settings/SettingsRootContent.tsx +3 -3
  104. package/src/ui/layouts/SiteLayout.tsx +249 -216
  105. package/dist/app-B5P1b4TF.js +0 -6
  106. package/src/client/nav-manager-bridge.ts +0 -54
@@ -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-Dfbq9aot.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
@@ -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-Dfbq9aot.js";
3
+ import { i as classifyRepoForSync, o as createGitHubSyncService } from "./github-sync--PSoE-Ne.js";
4
4
  export { classifyRepoForSync, createGitHubSyncService };
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-HnzmsaqU.js";
3
+ import { S as time_exports, a as markdown_exports } from "./export-Dfbq9aot.js";
4
4
  import "./env-OHRKGcMj.js";
5
- import "./github-sync-Dl9wQMar.js";
5
+ import "./github-sync--PSoE-Ne.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-HnzmsaqU.js";
3
+ import { t as createExportService } from "./export-Dfbq9aot.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--PSoE-Ne.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-CWJFPjuR.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.13",
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
+ });
@@ -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
  });
@@ -104,6 +104,46 @@ const suggestedLinks: NavManagerSuggestedLink[] = [
104
104
  },
105
105
  ];
106
106
 
107
+ function renderHeaderFragment(label: string): string {
108
+ return `
109
+ <header class="site-header" data-site-header-fragment="header">
110
+ <div class="site-header-inner">
111
+ <div class="site-header-top">
112
+ <a href="/" class="site-logo">Test Site</a>
113
+ <nav class="site-header-nav" aria-label="Primary">
114
+ <a href="/now" class="site-header-link">${label}</a>
115
+ </nav>
116
+ <button
117
+ type="button"
118
+ class="site-header-hamburger"
119
+ aria-controls="site-nav-drawer"
120
+ aria-expanded="false"
121
+ ></button>
122
+ </div>
123
+ </div>
124
+ </header>
125
+ <div
126
+ class="site-nav-drawer-backdrop"
127
+ data-site-header-fragment="drawer-backdrop"
128
+ aria-hidden="true"
129
+ ></div>
130
+ <div
131
+ id="site-nav-drawer"
132
+ class="site-nav-drawer"
133
+ data-site-header-fragment="drawer"
134
+ aria-hidden="true"
135
+ inert
136
+ >
137
+ <button class="site-nav-drawer-close" type="button"></button>
138
+ <a href="/now" class="site-nav-drawer-link">${label}</a>
139
+ </div>
140
+ `;
141
+ }
142
+
143
+ function installCurrentHeaderFragment(label = "Old"): void {
144
+ document.body.insertAdjacentHTML("afterbegin", renderHeaderFragment(label));
145
+ }
146
+
107
147
  function requireElement<T>(value: T | null, message: string): T {
108
148
  if (!value) {
109
149
  throw new Error(message);
@@ -148,6 +188,12 @@ function getSortableOptions(
148
188
  return options as Record<string, ((event: unknown) => void) | undefined>;
149
189
  }
150
190
 
191
+ async function flushAsyncWork(): Promise<void> {
192
+ await Promise.resolve();
193
+ await Promise.resolve();
194
+ await Promise.resolve();
195
+ }
196
+
151
197
  async function createElement(): Promise<JantNavManager> {
152
198
  const el = document.createElement("jant-nav-manager") as JantNavManager;
153
199
  el.labels = labels;
@@ -170,6 +216,7 @@ describe("JantNavManager", () => {
170
216
  vi.fn().mockResolvedValue({
171
217
  ok: true,
172
218
  status: 200,
219
+ json: async () => ({ ...items[1] }),
173
220
  }),
174
221
  );
175
222
  });
@@ -209,8 +256,7 @@ describe("JantNavManager", () => {
209
256
  });
210
257
 
211
258
  await el.updateComplete;
212
- await Promise.resolve();
213
- await Promise.resolve();
259
+ await flushAsyncWork();
214
260
 
215
261
  expect(getListIds(headerList)).toEqual(["nav-1"]);
216
262
  expect(getListIds(moreList)).toEqual(["nav-2", "nav-3"]);
@@ -234,6 +280,9 @@ describe("JantNavManager", () => {
234
280
  "/api/nav-items/nav-2/move",
235
281
  expect.objectContaining({
236
282
  method: "PUT",
283
+ headers: expect.objectContaining({
284
+ "X-Jant-Site-Header": "include",
285
+ }),
237
286
  body: JSON.stringify({
238
287
  after: null,
239
288
  before: "nav-3",
@@ -293,22 +342,28 @@ describe("JantNavManager", () => {
293
342
  url: "/now",
294
343
  placement: "header",
295
344
  };
345
+ installCurrentHeaderFragment("Old");
296
346
  const fetchMock = vi.fn().mockResolvedValue({
297
347
  ok: true,
298
348
  status: 201,
299
- json: async () => created,
349
+ json: async () => ({
350
+ ...created,
351
+ headerHtml: renderHeaderFragment("Now"),
352
+ }),
300
353
  });
301
354
  vi.stubGlobal("fetch", fetchMock);
302
355
 
303
356
  addButton.click();
304
- await Promise.resolve();
305
- await Promise.resolve();
357
+ await flushAsyncWork();
306
358
  await el.updateComplete;
307
359
 
308
360
  expect(fetchMock).toHaveBeenCalledWith(
309
361
  "/api/nav-items",
310
362
  expect.objectContaining({
311
363
  method: "POST",
364
+ headers: expect.objectContaining({
365
+ "X-Jant-Site-Header": "include",
366
+ }),
312
367
  body: JSON.stringify({
313
368
  type: "collection",
314
369
  collectionId: "col_now",
@@ -325,12 +380,25 @@ describe("JantNavManager", () => {
325
380
  ),
326
381
  ).toContain("nav-now");
327
382
  expect(el.querySelector(".nav-suggestion-item")).toBeNull();
383
+ expect(
384
+ document
385
+ .querySelector<HTMLElement>('[data-site-header-fragment="header"]')
386
+ ?.textContent?.trim(),
387
+ ).toContain("Now");
328
388
  });
329
389
 
330
- it("confirms before dispatching nav deletion", async () => {
390
+ it("confirms before deleting a navigation item", async () => {
331
391
  const el = await createElement();
332
- const deleteHandler = vi.fn();
333
- el.addEventListener("jant:nav-delete", deleteHandler);
392
+ const fetchMock = vi.fn().mockResolvedValue({
393
+ ok: true,
394
+ status: 200,
395
+ json: async () => ({
396
+ success: true,
397
+ headerHtml: renderHeaderFragment("Links"),
398
+ }),
399
+ });
400
+ vi.stubGlobal("fetch", fetchMock);
401
+ installCurrentHeaderFragment("About");
334
402
 
335
403
  (
336
404
  el as unknown as { _editingId: string | null; requestUpdate: () => void }
@@ -343,7 +411,7 @@ describe("JantNavManager", () => {
343
411
  "expected nav delete button",
344
412
  );
345
413
  deleteButton.click();
346
- await Promise.resolve();
414
+ await flushAsyncWork();
347
415
 
348
416
  const host = requireElement(
349
417
  document.querySelector<HTMLElement>("jant-confirm-dialog"),
@@ -356,19 +424,37 @@ describe("JantNavManager", () => {
356
424
  "expected confirm button",
357
425
  );
358
426
  confirmButton.click();
359
- await Promise.resolve();
360
- await Promise.resolve();
427
+ await flushAsyncWork();
428
+ await el.updateComplete;
361
429
 
362
- expect(deleteHandler).toHaveBeenCalledTimes(1);
363
- expect(deleteHandler.mock.calls[0]?.[0]).toMatchObject({
364
- detail: { id: "nav-1" },
365
- });
430
+ expect(fetchMock).toHaveBeenCalledWith(
431
+ "/api/nav-items/nav-1",
432
+ expect.objectContaining({
433
+ method: "DELETE",
434
+ headers: expect.objectContaining({
435
+ "X-Jant-Site-Header": "include",
436
+ }),
437
+ }),
438
+ );
439
+ expect(
440
+ getListIds(
441
+ requireElement(
442
+ el.querySelector<HTMLElement>("#nav-items-header"),
443
+ "expected header nav list",
444
+ ),
445
+ ),
446
+ ).toEqual(["nav-2"]);
447
+ expect(
448
+ document
449
+ .querySelector<HTMLElement>('[data-site-header-fragment="header"]')
450
+ ?.textContent?.trim(),
451
+ ).toContain("Links");
366
452
  });
367
453
 
368
- it("does not dispatch nav deletion when confirmation is canceled", async () => {
454
+ it("does not delete when confirmation is canceled", async () => {
369
455
  const el = await createElement();
370
- const deleteHandler = vi.fn();
371
- el.addEventListener("jant:nav-delete", deleteHandler);
456
+ const fetchMock = vi.fn();
457
+ vi.stubGlobal("fetch", fetchMock);
372
458
 
373
459
  (
374
460
  el as unknown as { _editingId: string | null; requestUpdate: () => void }
@@ -381,7 +467,7 @@ describe("JantNavManager", () => {
381
467
  "expected nav delete button",
382
468
  );
383
469
  deleteButton.click();
384
- await Promise.resolve();
470
+ await flushAsyncWork();
385
471
 
386
472
  const host = requireElement(
387
473
  document.querySelector<HTMLElement>("jant-confirm-dialog"),
@@ -394,9 +480,8 @@ describe("JantNavManager", () => {
394
480
  "expected cancel button",
395
481
  );
396
482
  cancelButton.click();
397
- await Promise.resolve();
398
- await Promise.resolve();
483
+ await flushAsyncWork();
399
484
 
400
- expect(deleteHandler).not.toHaveBeenCalled();
485
+ expect(fetchMock).not.toHaveBeenCalled();
401
486
  });
402
487
  });
@@ -657,6 +657,7 @@ describe("JantSettingsGeneral", () => {
657
657
  const d = detail as unknown as SettingsSaveDetail;
658
658
  expect(d.endpoint).toBe("/settings/general/home");
659
659
  expect(d.section).toBe("home");
660
+ expect(d.data).not.toHaveProperty("homeDefaultView");
660
661
  expect(d.data.showJantBrandingOnHome).toBe(true);
661
662
  expect(siteSaveBtn?.disabled).toBe(true);
662
663
  });
@@ -0,0 +1,66 @@
1
+ import type { JSONContent } from "@tiptap/core";
2
+
3
+ export interface LeadingH1TitlePromotion {
4
+ title: string;
5
+ bodyJson: JSONContent | null;
6
+ headingIndex: number;
7
+ }
8
+
9
+ function textFromNode(node: JSONContent): string {
10
+ if (typeof node.text === "string") return node.text;
11
+ if (!node.content) return node.type === "hardBreak" ? " " : "";
12
+ return node.content.map(textFromNode).join("");
13
+ }
14
+
15
+ function hasOnlyEmptyInlineContent(node: JSONContent): boolean {
16
+ if (!node.content || node.content.length === 0) return true;
17
+ return node.content.every((child) => {
18
+ if (child.type === "text") return !child.text?.trim();
19
+ if (child.type === "hardBreak") return true;
20
+ return false;
21
+ });
22
+ }
23
+
24
+ function isIgnorableLeadingBlock(node: JSONContent): boolean {
25
+ return (
26
+ (node.type === "paragraph" || node.type === "heading") &&
27
+ hasOnlyEmptyInlineContent(node)
28
+ );
29
+ }
30
+
31
+ function normalizeTitleText(text: string): string {
32
+ return text.replace(/\s+/g, " ").trim();
33
+ }
34
+
35
+ export function promoteLeadingH1Title(
36
+ bodyJson: JSONContent | null,
37
+ ): LeadingH1TitlePromotion | null {
38
+ const content = bodyJson?.content;
39
+ if (!bodyJson || !content?.length) return null;
40
+
41
+ const headingIndex = content.findIndex((node) => {
42
+ return !isIgnorableLeadingBlock(node);
43
+ });
44
+
45
+ if (headingIndex < 0) return null;
46
+
47
+ const heading = content[headingIndex];
48
+ if (heading?.type !== "heading" || heading.attrs?.level !== 1) return null;
49
+
50
+ const title = normalizeTitleText(textFromNode(heading));
51
+ if (!title) return null;
52
+
53
+ const nextContent = content.slice(headingIndex + 1);
54
+ if (nextContent.length === 0) {
55
+ return { title, bodyJson: null, headingIndex };
56
+ }
57
+
58
+ return {
59
+ title,
60
+ bodyJson: {
61
+ ...bodyJson,
62
+ content: nextContent,
63
+ },
64
+ headingIndex,
65
+ };
66
+ }