@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
@@ -0,0 +1,63 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import {
5
+ getPathCommandSearchQuery,
6
+ getPathCommandTarget,
7
+ } from "../jant-command-palette.js";
8
+ import type { JantCommandPalette } from "../jant-command-palette.js";
9
+
10
+ function mockPaletteApi() {
11
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
12
+ new Response(JSON.stringify({ items: [] }), {
13
+ headers: { "Content-Type": "application/json" },
14
+ }),
15
+ );
16
+ }
17
+
18
+ async function renderPalette(query: string) {
19
+ mockPaletteApi();
20
+ const el = document.createElement(
21
+ "jant-command-palette",
22
+ ) as JantCommandPalette;
23
+ el._open = true;
24
+ el._query = query;
25
+ document.body.appendChild(el);
26
+ await Promise.resolve();
27
+ await el.updateComplete;
28
+ return el;
29
+ }
30
+
31
+ function resultTitles(root: globalThis.Element): string[] {
32
+ return Array.from(
33
+ root.querySelectorAll<HTMLElement>(".command-palette-result-title"),
34
+ ).map((item) => item.textContent?.trim() ?? "");
35
+ }
36
+
37
+ describe("JantCommandPalette slash path mode", () => {
38
+ beforeEach(() => {
39
+ vi.restoreAllMocks();
40
+ document.body.innerHTML = "";
41
+ globalThis.localStorage.clear();
42
+ });
43
+
44
+ it("shows go-to-path first and search second for slash queries", async () => {
45
+ const el = await renderPalette("/draft-123");
46
+
47
+ expect(resultTitles(el)).toEqual([
48
+ "Go to /draft-123",
49
+ 'Search for "draft-123"',
50
+ ]);
51
+ });
52
+
53
+ it("normalizes repeated leading slashes into a local path", () => {
54
+ expect(getPathCommandTarget("//example.com")).toBe("/example.com");
55
+ expect(getPathCommandSearchQuery("//example.com")).toBe("example.com");
56
+ });
57
+
58
+ it("accepts fullwidth slash input", async () => {
59
+ const el = await renderPalette("/notes");
60
+
61
+ expect(resultTitles(el)).toEqual(["Go to /notes", 'Search for "notes"']);
62
+ });
63
+ });
@@ -644,6 +644,9 @@ describe("JantComposeDialog", () => {
644
644
  ).toBeNull();
645
645
  // ...it sits inline above the reply editor instead.
646
646
  expect(el.querySelector(".compose-thread-post-header")).not.toBeNull();
647
+ expect(
648
+ el.querySelector(".compose-thread-layout.compose-reply-compose-layout"),
649
+ ).not.toBeNull();
647
650
  });
648
651
 
649
652
  it("shows an Edit title with the format selector above the post when editing a reply", async () => {
@@ -3065,6 +3068,36 @@ describe("JantComposeDialog", () => {
3065
3068
  expect(css).toMatch(
3066
3069
  /\.compose-editor-row\s+\.compose-attachments-dock\s*\{[\s\S]*max-height:\s*min\(240px,\s*34dvh\);[\s\S]*overflow-y:\s*auto;/,
3067
3070
  );
3071
+ expect(css).toMatch(
3072
+ /\.compose-reply-compose-layout\s+\.compose-editor-row\s+\.compose-attachments-dock\s*\{[\s\S]*--compose-reply-attachment-width:\s*96px;[\s\S]*--compose-reply-attachment-height:\s*72px;[\s\S]*max-height:\s*min\(116px,\s*18dvh\);/,
3073
+ );
3074
+ expect(css).toMatch(
3075
+ /\.compose-reply-compose-layout[\s\S]*\.compose-attachment:only-child[\s\S]*\.compose-attachment-img,[\s\S]*\.compose-reply-compose-layout[\s\S]*\.compose-attachment:not\(:only-child\)[\s\S]*\.compose-attachment-img\s*\{[\s\S]*height:\s*var\(--compose-reply-attachment-height\);[\s\S]*object-fit:\s*cover;/,
3076
+ );
3077
+ expect(css).toMatch(
3078
+ /\.compose-reply-compose-layout\s+\.compose-thread-post-header\s*\+\s*\.compose-body\s*\{[\s\S]*padding-top:\s*12px;/,
3079
+ );
3080
+ expect(css).toMatch(
3081
+ /\.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
+ );
3083
+ expect(css).toMatch(
3084
+ /\.compose-quote-text\s*\{[\s\S]*font-size:\s*var\(--compose-quote-input-size\);[\s\S]*line-height:\s*var\(--compose-quote-input-leading\);/,
3085
+ );
3086
+ expect(css).toMatch(
3087
+ /\.compose-reply-compose-layout\s*\{[\s\S]*--compose-title-input-size:\s*var\(--type-content-subtitle\);[\s\S]*--compose-quote-input-size:\s*calc\(var\(--type-content-body\) \* 1\.06\);[\s\S]*--compose-quote-input-leading:\s*1\.42;[\s\S]*--compose-inline-input-size:\s*var\(--type-base\);/,
3088
+ );
3089
+ expect(css).toMatch(
3090
+ /\.compose-reply-compose-layout\s+\.compose-quote-wrap\s*\{[\s\S]*margin-top:\s*0\.4rem;[\s\S]*padding:\s*24px 18px 18px;[\s\S]*border-radius:\s*0\.95rem;/,
3091
+ );
3092
+ expect(css).toMatch(
3093
+ /\.compose-reply-compose-layout\s+\.compose-link-url-wrap\s*\{[\s\S]*margin-top:\s*0\.4rem;/,
3094
+ );
3095
+ expect(css).toMatch(
3096
+ /\.compose-reply-compose-layout\s+\.compose-quote-text\s*\{[\s\S]*min-height:\s*6\.5rem;[\s\S]*padding-top:\s*0\.35rem;/,
3097
+ );
3098
+ expect(css).toMatch(
3099
+ /\.compose-reply-compose-layout\s+\.compose-tiptap-body\s+\.tiptap\s*\{[\s\S]*font-size:\s*var\(--type-content-body\);/,
3100
+ );
3068
3101
  });
3069
3102
 
3070
3103
  it("remove button clears attachment", async () => {
@@ -21,6 +21,22 @@ function editorOf(el: JantComposeEditor): Editor {
21
21
 
22
22
  const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
23
23
 
24
+ function dispatchPaste(editor: Editor, html: string, text = "") {
25
+ const event = new Event("paste", {
26
+ bubbles: true,
27
+ cancelable: true,
28
+ }) as Event & { clipboardData: unknown };
29
+ event.clipboardData = {
30
+ getData: (type: string) =>
31
+ type === "text/html" ? html : type === "text/plain" ? text : "",
32
+ files: [],
33
+ items: [],
34
+ types: ["text/html", "text/plain"],
35
+ };
36
+ editor.commands.focus();
37
+ editor.view.dom.dispatchEvent(event);
38
+ }
39
+
24
40
  beforeEach(() => {
25
41
  const container = document.createElement("div");
26
42
  container.id = "toast-container";
@@ -48,7 +64,10 @@ describe("compose editor: rehost failure notice", () => {
48
64
  document.body.appendChild(el);
49
65
  await el.updateComplete;
50
66
 
51
- editorOf(el).commands.setImage({ src: "https://ext.example/blocked.png" });
67
+ dispatchPaste(
68
+ editorOf(el),
69
+ '<p><img src="https://ext.example/blocked.png"></p>',
70
+ );
52
71
 
53
72
  // Let the rehost fire, the sideload fail, then the debounce window elapse.
54
73
  await wait(50);
@@ -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
  });
@@ -15,7 +15,11 @@ vi.mock("sortablejs", () => ({
15
15
  },
16
16
  }));
17
17
 
18
- import type { NavManagerItem, NavManagerLabels } from "../nav-manager-types.js";
18
+ import type {
19
+ NavManagerItem,
20
+ NavManagerLabels,
21
+ NavManagerSuggestedLink,
22
+ } from "../nav-manager-types.js";
19
23
  import "../jant-nav-manager.js";
20
24
  import type { JantNavManager } from "../jant-nav-manager.js";
21
25
 
@@ -43,6 +47,10 @@ const labels: NavManagerLabels = {
43
47
  addLinkDescription: "Add a custom nav link.",
44
48
  urlPlaceholder: "/about",
45
49
  labelAndUrlRequired: "Add a label and URL.",
50
+ suggestedLinks: "Suggested links",
51
+ suggestedLinksDescription: "Add common destinations.",
52
+ addSuggestedLink: "Add",
53
+ suggestedLinkAdded: "Link added to navigation.",
46
54
  headerSection: "Header",
47
55
  moreSection: "More",
48
56
  moreEmptyHint: "Move links here to hide them under More.",
@@ -84,6 +92,58 @@ const items: NavManagerItem[] = [
84
92
  },
85
93
  ];
86
94
 
95
+ const suggestedLinks: NavManagerSuggestedLink[] = [
96
+ {
97
+ key: "now",
98
+ label: "Now",
99
+ url: "/now",
100
+ targetType: "collection",
101
+ targetLabel: "Collection",
102
+ navItemType: "collection",
103
+ collectionId: "col_now",
104
+ },
105
+ ];
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
+
87
147
  function requireElement<T>(value: T | null, message: string): T {
88
148
  if (!value) {
89
149
  throw new Error(message);
@@ -128,11 +188,18 @@ function getSortableOptions(
128
188
  return options as Record<string, ((event: unknown) => void) | undefined>;
129
189
  }
130
190
 
191
+ async function flushAsyncWork(): Promise<void> {
192
+ await Promise.resolve();
193
+ await Promise.resolve();
194
+ await Promise.resolve();
195
+ }
196
+
131
197
  async function createElement(): Promise<JantNavManager> {
132
198
  const el = document.createElement("jant-nav-manager") as JantNavManager;
133
199
  el.labels = labels;
134
200
  el.items = items;
135
201
  el.systemNavItems = [];
202
+ el.suggestedLinks = [];
136
203
  el.siteName = "Test Site";
137
204
  document.body.appendChild(el);
138
205
  await el.updateComplete;
@@ -149,6 +216,7 @@ describe("JantNavManager", () => {
149
216
  vi.fn().mockResolvedValue({
150
217
  ok: true,
151
218
  status: 200,
219
+ json: async () => ({ ...items[1] }),
152
220
  }),
153
221
  );
154
222
  });
@@ -188,8 +256,7 @@ describe("JantNavManager", () => {
188
256
  });
189
257
 
190
258
  await el.updateComplete;
191
- await Promise.resolve();
192
- await Promise.resolve();
259
+ await flushAsyncWork();
193
260
 
194
261
  expect(getListIds(headerList)).toEqual(["nav-1"]);
195
262
  expect(getListIds(moreList)).toEqual(["nav-2", "nav-3"]);
@@ -213,6 +280,9 @@ describe("JantNavManager", () => {
213
280
  "/api/nav-items/nav-2/move",
214
281
  expect.objectContaining({
215
282
  method: "PUT",
283
+ headers: expect.objectContaining({
284
+ "X-Jant-Site-Header": "include",
285
+ }),
216
286
  body: JSON.stringify({
217
287
  after: null,
218
288
  before: "nav-3",
@@ -251,10 +321,84 @@ describe("JantNavManager", () => {
251
321
  expect(popover.getAttribute("aria-hidden")).toBe("true");
252
322
  });
253
323
 
254
- it("confirms before dispatching nav deletion", async () => {
324
+ it("adds a suggested collection link through the nav items API", async () => {
255
325
  const el = await createElement();
256
- const deleteHandler = vi.fn();
257
- el.addEventListener("jant:nav-delete", deleteHandler);
326
+ el.suggestedLinks = suggestedLinks;
327
+ el.requestUpdate();
328
+ await el.updateComplete;
329
+
330
+ const addButton = requireElement(
331
+ el.querySelector<HTMLButtonElement>(".nav-suggestion-item button"),
332
+ "expected suggested link add button",
333
+ );
334
+ expect(el.textContent).toContain("Now");
335
+ expect(el.textContent).toContain("/now · Collection");
336
+
337
+ const created: NavManagerItem = {
338
+ id: "nav-now",
339
+ type: "collection",
340
+ collectionId: "col_now",
341
+ label: "Now",
342
+ url: "/now",
343
+ placement: "header",
344
+ };
345
+ installCurrentHeaderFragment("Old");
346
+ const fetchMock = vi.fn().mockResolvedValue({
347
+ ok: true,
348
+ status: 201,
349
+ json: async () => ({
350
+ ...created,
351
+ headerHtml: renderHeaderFragment("Now"),
352
+ }),
353
+ });
354
+ vi.stubGlobal("fetch", fetchMock);
355
+
356
+ addButton.click();
357
+ await flushAsyncWork();
358
+ await el.updateComplete;
359
+
360
+ expect(fetchMock).toHaveBeenCalledWith(
361
+ "/api/nav-items",
362
+ expect.objectContaining({
363
+ method: "POST",
364
+ headers: expect.objectContaining({
365
+ "X-Jant-Site-Header": "include",
366
+ }),
367
+ body: JSON.stringify({
368
+ type: "collection",
369
+ collectionId: "col_now",
370
+ placement: "header",
371
+ }),
372
+ }),
373
+ );
374
+ expect(
375
+ getListIds(
376
+ requireElement(
377
+ el.querySelector<HTMLElement>("#nav-items-header"),
378
+ "expected header nav list",
379
+ ),
380
+ ),
381
+ ).toContain("nav-now");
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");
388
+ });
389
+
390
+ it("confirms before deleting a navigation item", async () => {
391
+ const el = await createElement();
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");
258
402
 
259
403
  (
260
404
  el as unknown as { _editingId: string | null; requestUpdate: () => void }
@@ -267,7 +411,7 @@ describe("JantNavManager", () => {
267
411
  "expected nav delete button",
268
412
  );
269
413
  deleteButton.click();
270
- await Promise.resolve();
414
+ await flushAsyncWork();
271
415
 
272
416
  const host = requireElement(
273
417
  document.querySelector<HTMLElement>("jant-confirm-dialog"),
@@ -280,19 +424,37 @@ describe("JantNavManager", () => {
280
424
  "expected confirm button",
281
425
  );
282
426
  confirmButton.click();
283
- await Promise.resolve();
284
- await Promise.resolve();
427
+ await flushAsyncWork();
428
+ await el.updateComplete;
285
429
 
286
- expect(deleteHandler).toHaveBeenCalledTimes(1);
287
- expect(deleteHandler.mock.calls[0]?.[0]).toMatchObject({
288
- detail: { id: "nav-1" },
289
- });
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");
290
452
  });
291
453
 
292
- it("does not dispatch nav deletion when confirmation is canceled", async () => {
454
+ it("does not delete when confirmation is canceled", async () => {
293
455
  const el = await createElement();
294
- const deleteHandler = vi.fn();
295
- el.addEventListener("jant:nav-delete", deleteHandler);
456
+ const fetchMock = vi.fn();
457
+ vi.stubGlobal("fetch", fetchMock);
296
458
 
297
459
  (
298
460
  el as unknown as { _editingId: string | null; requestUpdate: () => void }
@@ -305,7 +467,7 @@ describe("JantNavManager", () => {
305
467
  "expected nav delete button",
306
468
  );
307
469
  deleteButton.click();
308
- await Promise.resolve();
470
+ await flushAsyncWork();
309
471
 
310
472
  const host = requireElement(
311
473
  document.querySelector<HTMLElement>("jant-confirm-dialog"),
@@ -318,9 +480,8 @@ describe("JantNavManager", () => {
318
480
  "expected cancel button",
319
481
  );
320
482
  cancelButton.click();
321
- await Promise.resolve();
322
- await Promise.resolve();
483
+ await flushAsyncWork();
323
484
 
324
- expect(deleteHandler).not.toHaveBeenCalled();
485
+ expect(fetchMock).not.toHaveBeenCalled();
325
486
  });
326
487
  });
@@ -32,6 +32,12 @@ const labels: SettingsLabels = {
32
32
  uploadError: "Upload failed.",
33
33
  general: "General",
34
34
  site: "Site",
35
+ aboutPage: "About page",
36
+ aboutPagePrompt: "Want to write a fuller introduction?",
37
+ aboutPageConflict:
38
+ "/about is already used. Rename that item before creating an About page.",
39
+ createAboutPage: "Create About page",
40
+ editAboutPage: "Edit About page",
35
41
  languageAndTime: "Language & Time",
36
42
  home: "Home",
37
43
  search: "Search",
@@ -7,6 +7,7 @@ import type {
7
7
  SettingsCjkFont,
8
8
  SettingsDashboardLanguage,
9
9
  SettingsSaveDetail,
10
+ SettingsAboutPageStatus,
10
11
  } from "../settings-types.js";
11
12
  import { MAX_SITE_NAME_LENGTH } from "../../../types.js";
12
13
  import "../jant-settings-general.js";
@@ -76,6 +77,12 @@ const labels: SettingsLabels = {
76
77
  uploadError: "Upload failed.",
77
78
  general: "General",
78
79
  site: "Site",
80
+ aboutPage: "About page",
81
+ aboutPagePrompt: "Want to write a fuller introduction?",
82
+ aboutPageConflict:
83
+ "/about is already used. Rename that item before creating an About page.",
84
+ createAboutPage: "Create About page",
85
+ editAboutPage: "Edit About page",
79
86
  languageAndTime: "Language & Time",
80
87
  home: "Home",
81
88
  search: "Search",
@@ -167,6 +174,7 @@ function findCheckboxByLabel(
167
174
  async function createElement(
168
175
  opts: {
169
176
  demoMode?: boolean;
177
+ aboutPage?: SettingsAboutPageStatus;
170
178
  } = {},
171
179
  ): Promise<JantSettingsGeneral> {
172
180
  const el = document.createElement(
@@ -182,6 +190,14 @@ async function createElement(
182
190
  el.latestFeedUrl = "/latest/feed";
183
191
  el.featuredFeedUrl = "/featured/feed";
184
192
  el.archiveFeedUrl = "/archive/feed";
193
+ el.aboutPage =
194
+ opts.aboutPage ??
195
+ ({
196
+ state: "missing",
197
+ path: "/about",
198
+ } satisfies SettingsAboutPageStatus);
199
+ el.aboutEditUrl = "/about?edit=1";
200
+ el.aboutCreateUrl = "/settings/general/about-page";
185
201
  el.demoMode = opts.demoMode ?? false;
186
202
  document.body.appendChild(el);
187
203
  await el.updateComplete;
@@ -209,6 +225,61 @@ describe("JantSettingsGeneral", () => {
209
225
  labels.home,
210
226
  labels.search,
211
227
  ]);
228
+
229
+ const siteSection = requireElement(
230
+ findSectionByHeading(el, labels.site),
231
+ "expected site section",
232
+ );
233
+ expect(siteSection.querySelector("[data-about-page-row]")).not.toBeNull();
234
+ });
235
+
236
+ it("renders a create About form from the missing About prompt", async () => {
237
+ const el = await createElement();
238
+ const aboutRow = requireElement(
239
+ el.querySelector<HTMLElement>("[data-about-page-row]"),
240
+ "expected About page row",
241
+ );
242
+ const form = requireElement(
243
+ aboutRow.querySelector<HTMLFormElement>("form"),
244
+ "expected create About page form",
245
+ );
246
+ const button = requireElement(
247
+ form.querySelector<HTMLButtonElement>("button"),
248
+ "expected create About page button",
249
+ );
250
+
251
+ expect(aboutRow.textContent).toContain(labels.aboutPagePrompt);
252
+ expect(form.method).toBe("post");
253
+ expect(form.action).toContain("/settings/general/about-page");
254
+ expect(button.textContent).toContain(labels.createAboutPage);
255
+ });
256
+
257
+ it("renders an edit link when the About page exists", async () => {
258
+ const el = await createElement({
259
+ aboutPage: {
260
+ state: "ready",
261
+ path: "/about",
262
+ post: {
263
+ id: "pst_about000000000000000000000",
264
+ title: "About",
265
+ status: "published",
266
+ visibility: "latest_hidden",
267
+ },
268
+ },
269
+ });
270
+
271
+ const aboutRow = requireElement(
272
+ el.querySelector<HTMLElement>("[data-about-page-row]"),
273
+ "expected About page row",
274
+ );
275
+ const editLink = requireElement(
276
+ aboutRow.querySelector<HTMLAnchorElement>("a"),
277
+ "expected About edit link",
278
+ );
279
+
280
+ expect(aboutRow.textContent).toContain(labels.aboutPagePrompt);
281
+ expect(editLink.textContent).toContain(labels.editAboutPage);
282
+ expect(editLink.href).toContain("/about?edit=1");
212
283
  });
213
284
 
214
285
  it("renders form fields with initial values", async () => {
@@ -586,6 +657,7 @@ describe("JantSettingsGeneral", () => {
586
657
  const d = detail as unknown as SettingsSaveDetail;
587
658
  expect(d.endpoint).toBe("/settings/general/home");
588
659
  expect(d.section).toBe("home");
660
+ expect(d.data).not.toHaveProperty("homeDefaultView");
589
661
  expect(d.data.showJantBrandingOnHome).toBe(true);
590
662
  expect(siteSaveBtn?.disabled).toBe(true);
591
663
  });