@jant/core 0.6.11 → 0.6.12

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 (72) hide show
  1. package/bin/commands/import-site.js +45 -20
  2. package/dist/app-B5P1b4TF.js +6 -0
  3. package/dist/{app-CpmficmQ.js → app-D-zAhayc.js} +331 -55
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/client-BUxR-E8O.css +2 -0
  6. package/dist/client/_assets/{client-Dd9U383b.js → client-S1cdvulk.js} +1 -1
  7. package/dist/client/_assets/{client-auth-DkpSdIDz.js → client-auth-YW92ZH7u.js} +116 -55
  8. package/dist/{export-Ba7NJImL.js → export-Ckwh4BiE.js} +4 -4
  9. package/dist/{github-sync-BD4w2m8-.js → github-sync-BMmK3JZ0.js} +2 -2
  10. package/dist/{github-sync-Cb4_6_i7.js → github-sync-Dl9wQMar.js} +1 -1
  11. package/dist/index.js +3 -3
  12. package/dist/node.js +4 -4
  13. package/package.json +1 -1
  14. package/src/__tests__/import-site-command.test.ts +75 -0
  15. package/src/__tests__/vite-dev-plugins.test.ts +34 -0
  16. package/src/client/__tests__/compose-launch.test.ts +50 -1
  17. package/src/client/__tests__/compose-shortcuts.test.ts +74 -18
  18. package/src/client/components/__tests__/jant-command-palette.test.ts +63 -0
  19. package/src/client/components/__tests__/jant-compose-dialog.test.ts +33 -0
  20. package/src/client/components/__tests__/jant-compose-editor-rehost-notice.test.ts +20 -1
  21. package/src/client/components/__tests__/jant-nav-manager.test.ts +77 -1
  22. package/src/client/components/__tests__/jant-settings-avatar.test.ts +6 -0
  23. package/src/client/components/__tests__/jant-settings-general.test.ts +71 -0
  24. package/src/client/components/jant-command-palette.ts +67 -2
  25. package/src/client/components/jant-compose-dialog.ts +8 -2
  26. package/src/client/components/jant-nav-manager.ts +142 -2
  27. package/src/client/components/jant-settings-general.ts +56 -0
  28. package/src/client/components/nav-manager-types.ts +14 -0
  29. package/src/client/components/settings-types.ts +30 -0
  30. package/src/client/compose-launch.ts +30 -0
  31. package/src/client/compose-shortcuts.ts +46 -3
  32. package/src/client/tiptap/__tests__/rehost-images.test.ts +66 -13
  33. package/src/client/tiptap/rehost-images.ts +5 -1
  34. package/src/i18n/__tests__/fallback.test.ts +24 -0
  35. package/src/i18n/locales/public/zh-Hans.po +2 -2
  36. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  37. package/src/i18n/locales/public/zh-Hant.po +2 -2
  38. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  39. package/src/i18n/locales/settings/en.po +54 -0
  40. package/src/i18n/locales/settings/en.ts +1 -1
  41. package/src/i18n/locales/settings/zh-Hans.po +64 -10
  42. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  43. package/src/i18n/locales/settings/zh-Hant.po +56 -2
  44. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  45. package/src/lib/__tests__/feed.test.ts +96 -10
  46. package/src/lib/feed.ts +63 -3
  47. package/src/lib/post-display.ts +14 -8
  48. package/src/preset.css +60 -18
  49. package/src/routes/dash/settings.tsx +41 -2
  50. package/src/routes/pages/__tests__/draft-about-edit.test.ts +65 -0
  51. package/src/routes/pages/page.tsx +44 -12
  52. package/src/services/__tests__/about-page.test.ts +116 -0
  53. package/src/services/__tests__/navigation.test.ts +190 -1
  54. package/src/services/__tests__/post-timeline.test.ts +119 -2
  55. package/src/services/about-page.ts +143 -0
  56. package/src/services/export-theme/layouts/_default/rss.xml +2 -1
  57. package/src/services/export-theme/layouts/partials/feed-post-content.xml +25 -1
  58. package/src/services/export-theme/styles/main.css +46 -14
  59. package/src/services/index.ts +27 -17
  60. package/src/services/navigation.ts +155 -1
  61. package/src/services/post.ts +24 -7
  62. package/src/styles/components.css +20 -0
  63. package/src/styles/tokens.css +26 -8
  64. package/src/styles/ui.css +149 -7
  65. package/src/types/views.ts +13 -0
  66. package/src/ui/dash/appearance/NavigationContent.tsx +57 -0
  67. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +19 -0
  68. package/src/ui/dash/settings/GeneralContent.tsx +44 -0
  69. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +8 -0
  70. package/src/ui/feed/__tests__/timeline-cards.test.ts +162 -1
  71. package/dist/app-DqKkZenB.js +0 -6
  72. package/dist/client/_assets/client-BhHHVvSY.css +0 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jant/core",
3
- "version": "0.6.11",
3
+ "version": "0.6.12",
4
4
  "description": "A modern, open-source microblogging platform built on Cloudflare Workers",
5
5
  "type": "module",
6
6
  "exports": {
@@ -24,6 +24,7 @@ const {
24
24
  uploadMediaList,
25
25
  normalizeTextAttachmentSpec,
26
26
  isAbsoluteImportUrl,
27
+ shouldImportReplyQuietly,
27
28
  } = __test__;
28
29
 
29
30
  async function writeFileTree(
@@ -368,6 +369,35 @@ describe("Hugo import CLI helpers", () => {
368
369
  });
369
370
  });
370
371
 
372
+ it("buildPostPayloadFromBundle can mark imported replies as quiet", () => {
373
+ const bundle = {
374
+ slug: "reply-a",
375
+ frontMatter: {
376
+ id: "pst_reply",
377
+ title: "Reply A",
378
+ date: "2026-04-01T01:00:00Z",
379
+ slug: "reply-a",
380
+ type: "post",
381
+ format: "note",
382
+ status: "published",
383
+ visibility: "public",
384
+ },
385
+ body: "Reply body",
386
+ };
387
+ const data = buildPostPayloadFromBundle(bundle, {
388
+ bodyMarkdown: "Reply body",
389
+ attachments: [],
390
+ memberships: { entries: [], ids: [] },
391
+ replyToId: "pst_root",
392
+ quietReply: true,
393
+ });
394
+
395
+ expect(data).toMatchObject({
396
+ replyToId: "pst_root",
397
+ quietReply: true,
398
+ });
399
+ });
400
+
371
401
  it("buildPostPayloadFromBundle preserves every canonical visibility value", () => {
372
402
  const base = {
373
403
  slug: "hello",
@@ -406,6 +436,51 @@ describe("Hugo import CLI helpers", () => {
406
436
  expect(fallback.visibility).toBeUndefined();
407
437
  });
408
438
 
439
+ it("shouldImportReplyQuietly preserves exported thread activity cutoffs", () => {
440
+ const rootFrontMatter = {
441
+ date: "2026-04-01T00:00:00Z",
442
+ last_activity_at: "2026-04-01T01:00:00Z",
443
+ };
444
+
445
+ expect(
446
+ shouldImportReplyQuietly(rootFrontMatter, {
447
+ date: "2026-04-01T00:30:00Z",
448
+ status: "published",
449
+ }),
450
+ ).toBe(false);
451
+ expect(
452
+ shouldImportReplyQuietly(rootFrontMatter, {
453
+ date: "2026-04-01T01:00:00Z",
454
+ status: "published",
455
+ }),
456
+ ).toBe(false);
457
+ expect(
458
+ shouldImportReplyQuietly(rootFrontMatter, {
459
+ date: "2026-04-01T02:00:00Z",
460
+ status: "published",
461
+ }),
462
+ ).toBe(true);
463
+ });
464
+
465
+ it("shouldImportReplyQuietly falls back to root date for all-quiet threads", () => {
466
+ const rootFrontMatter = {
467
+ date: "2026-04-01T00:00:00Z",
468
+ };
469
+
470
+ expect(
471
+ shouldImportReplyQuietly(rootFrontMatter, {
472
+ date: "2026-04-01T01:00:00Z",
473
+ status: "published",
474
+ }),
475
+ ).toBe(true);
476
+ expect(
477
+ shouldImportReplyQuietly(rootFrontMatter, {
478
+ date: "2026-04-01T01:00:00Z",
479
+ status: "draft",
480
+ }),
481
+ ).toBe(false);
482
+ });
483
+
409
484
  it("getRootAliasPathsForImport prefers root_aliases and strips reply slugs", () => {
410
485
  // Reply slug paths are normalized (no trailing slash) to match the
411
486
  // return format of normalizeImportAliasPath.
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { shouldRunLinguiBuildForFile } from "../../vite.dev-plugins.js";
3
+
4
+ describe("vite dev plugins", () => {
5
+ it("does not run Lingui build for generated i18n output", () => {
6
+ expect(
7
+ shouldRunLinguiBuildForFile(
8
+ "/repo/packages/core/src/i18n/coverage.generated.ts",
9
+ ),
10
+ ).toBe(false);
11
+ expect(
12
+ shouldRunLinguiBuildForFile(
13
+ "\\repo\\packages\\core\\src\\i18n\\coverage.generated.ts",
14
+ ),
15
+ ).toBe(false);
16
+ expect(
17
+ shouldRunLinguiBuildForFile(
18
+ "/repo/packages/core/src/i18n/locales/settings/en.ts",
19
+ ),
20
+ ).toBe(false);
21
+ });
22
+
23
+ it("runs Lingui build for source TypeScript files", () => {
24
+ expect(
25
+ shouldRunLinguiBuildForFile("/repo/packages/core/src/ui/pages/Home.tsx"),
26
+ ).toBe(true);
27
+ expect(
28
+ shouldRunLinguiBuildForFile("/repo/packages/core/src/routes/posts.ts"),
29
+ ).toBe(true);
30
+ expect(
31
+ shouldRunLinguiBuildForFile("/repo/packages/core/src/styles/ui.css"),
32
+ ).toBe(false);
33
+ });
34
+ });
@@ -1,7 +1,10 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  import { afterEach, describe, expect, it } from "vitest";
4
- import { getReplyRefreshTarget } from "../compose-launch.js";
4
+ import {
5
+ getReplyRefreshTarget,
6
+ getReplyTargetArticle,
7
+ } from "../compose-launch.js";
5
8
 
6
9
  function getArticle(): HTMLElement {
7
10
  const article = document.querySelector<HTMLElement>("article[data-post]");
@@ -9,6 +12,14 @@ function getArticle(): HTMLElement {
9
12
  return article;
10
13
  }
11
14
 
15
+ function getArticleByPostId(postId: string): HTMLElement {
16
+ const article = document.querySelector<HTMLElement>(
17
+ `article[data-post-id="${postId}"]`,
18
+ );
19
+ if (!article) throw new Error(`expected article for ${postId}`);
20
+ return article;
21
+ }
22
+
12
23
  describe("getReplyRefreshTarget", () => {
13
24
  afterEach(() => {
14
25
  document.body.innerHTML = "";
@@ -118,3 +129,41 @@ describe("getReplyRefreshTarget", () => {
118
129
  expect(getReplyRefreshTarget(getArticle())).toBeNull();
119
130
  });
120
131
  });
132
+
133
+ describe("getReplyTargetArticle", () => {
134
+ afterEach(() => {
135
+ document.body.innerHTML = "";
136
+ });
137
+
138
+ it("targets the latest thread detail post when the current page is the root", () => {
139
+ document.body.innerHTML = `
140
+ <div data-post-view data-post-view-id="pst_root">
141
+ <div class="thread-group thread-group-detail" data-page="post">
142
+ <div class="thread-item thread-detail-item" data-post-current>
143
+ <article data-post data-post-id="pst_root" data-thread-root-id="pst_root"></article>
144
+ </div>
145
+ <div class="thread-item thread-detail-item">
146
+ <article data-post data-post-id="pst_reply_1" data-thread-root-id="pst_root"></article>
147
+ </div>
148
+ <div class="thread-item thread-detail-item">
149
+ <article data-post data-post-id="pst_reply_2" data-thread-root-id="pst_root"></article>
150
+ </div>
151
+ </div>
152
+ </div>
153
+ `;
154
+
155
+ expect(getReplyTargetArticle(document)?.dataset.postId).toBe("pst_reply_2");
156
+ });
157
+
158
+ it("keeps using the current article outside thread detail pages", () => {
159
+ document.body.innerHTML = `
160
+ <div data-post-view data-post-view-id="pst_single">
161
+ <article data-post data-post-id="pst_single"></article>
162
+ </div>
163
+ `;
164
+
165
+ expect(getReplyTargetArticle(document)).toBe(
166
+ getArticleByPostId("pst_single"),
167
+ );
168
+ });
169
+ });
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { beforeEach, describe, expect, it, vi } from "vitest";
4
4
  import { __testOnly as composeDiscoveryTestOnly } from "../compose-discovery.js";
5
- import "../compose-shortcuts.js";
5
+ import { __testOnly as composeShortcutsTestOnly } from "../compose-shortcuts.js";
6
6
 
7
7
  type ComposeHarness = HTMLElement & {
8
8
  openNew: (options?: unknown) => Promise<void>;
@@ -44,6 +44,7 @@ function renderThreadDetailPage() {
44
44
  threadGroup.className = "thread-group thread-group-detail";
45
45
 
46
46
  const currentItem = document.createElement("div");
47
+ currentItem.className = "thread-item thread-detail-item";
47
48
  currentItem.dataset.postCurrent = "";
48
49
 
49
50
  const currentArticle = document.createElement("article");
@@ -58,6 +59,7 @@ function renderThreadDetailPage() {
58
59
  `;
59
60
 
60
61
  const hoveredItem = document.createElement("div");
62
+ hoveredItem.className = "thread-item thread-detail-item";
61
63
 
62
64
  const hoveredArticle = document.createElement("article");
63
65
  hoveredArticle.dataset.post = "";
@@ -70,14 +72,30 @@ function renderThreadDetailPage() {
70
72
  <div data-post-body>Hovered body</div>
71
73
  `;
72
74
 
75
+ const latestItem = document.createElement("div");
76
+ latestItem.className = "thread-item thread-detail-item";
77
+
78
+ const latestArticle = document.createElement("article");
79
+ latestArticle.dataset.post = "";
80
+ latestArticle.dataset.postId = "post-latest";
81
+ latestArticle.dataset.threadRootId = "thread-root";
82
+ latestArticle.dataset.format = "note";
83
+ latestArticle.innerHTML = `
84
+ <div data-post-meta>meta</div>
85
+ <time class="dt-published">Mar 21</time>
86
+ <div data-post-body>Latest body</div>
87
+ `;
88
+
73
89
  currentItem.appendChild(currentArticle);
74
90
  hoveredItem.appendChild(hoveredArticle);
91
+ latestItem.appendChild(latestArticle);
75
92
  threadGroup.appendChild(currentItem);
76
93
  threadGroup.appendChild(hoveredItem);
94
+ threadGroup.appendChild(latestItem);
77
95
  postView.appendChild(threadGroup);
78
96
  document.body.appendChild(postView);
79
97
 
80
- return { currentArticle, hoveredArticle };
98
+ return { currentArticle, hoveredArticle, latestArticle };
81
99
  }
82
100
 
83
101
  describe("compose shortcuts", () => {
@@ -130,7 +148,7 @@ describe("compose shortcuts", () => {
130
148
  expect(composeEl.openNew).not.toHaveBeenCalled();
131
149
  });
132
150
 
133
- it("opens a reply composer for the current post on detail pages with r", () => {
151
+ it("opens a reply composer for the latest thread post on detail pages with r", () => {
134
152
  const composeEl = createComposeHarness();
135
153
 
136
154
  const postView = document.createElement("div");
@@ -138,22 +156,45 @@ describe("compose shortcuts", () => {
138
156
  postView.dataset.postViewId = "post-current";
139
157
 
140
158
  const current = document.createElement("div");
159
+ current.className = "thread-item thread-detail-item";
141
160
  current.dataset.postCurrent = "";
142
161
 
143
- const article = document.createElement("article");
144
- article.dataset.post = "";
145
- article.dataset.postId = "post-current";
146
- article.dataset.threadRootId = "thread-root";
147
- article.dataset.format = "quote";
148
- article.innerHTML = `
162
+ const currentArticle = document.createElement("article");
163
+ currentArticle.dataset.post = "";
164
+ currentArticle.dataset.postId = "post-current";
165
+ currentArticle.dataset.threadRootId = "thread-root";
166
+ currentArticle.dataset.format = "quote";
167
+ currentArticle.innerHTML = `
149
168
  <div data-post-meta>meta</div>
150
169
  <div class="post-status-badges">badges</div>
151
170
  <time class="dt-published">Mar 19</time>
152
- <div data-post-body>Reply body</div>
171
+ <div data-post-body>Current body</div>
172
+ `;
173
+
174
+ const latest = document.createElement("div");
175
+ latest.className = "thread-item thread-detail-item";
176
+
177
+ const latestArticle = document.createElement("article");
178
+ latestArticle.dataset.post = "";
179
+ latestArticle.dataset.postId = "post-latest";
180
+ latestArticle.dataset.threadRootId = "thread-root";
181
+ latestArticle.dataset.format = "quote";
182
+ latestArticle.innerHTML = `
183
+ <div data-post-meta>meta</div>
184
+ <div class="post-status-badges">badges</div>
185
+ <time class="dt-published">Mar 21</time>
186
+ <div data-post-body>Latest reply body</div>
153
187
  `;
154
188
 
155
- current.appendChild(article);
156
- postView.appendChild(current);
189
+ const threadGroup = document.createElement("div");
190
+ threadGroup.dataset.page = "post";
191
+ threadGroup.className = "thread-group thread-group-detail";
192
+
193
+ current.appendChild(currentArticle);
194
+ latest.appendChild(latestArticle);
195
+ threadGroup.appendChild(current);
196
+ threadGroup.appendChild(latest);
197
+ postView.appendChild(threadGroup);
157
198
  document.body.appendChild(postView);
158
199
 
159
200
  const event = dispatchShortcut(document, "r");
@@ -165,13 +206,13 @@ describe("compose shortcuts", () => {
165
206
  composeEl.openReply,
166
207
  ).mock.calls[0] ?? [null, null, null, null];
167
208
 
168
- expect(postId).toBe("post-current");
209
+ expect(postId).toBe("post-latest");
169
210
  expect(threadRootId).toBe("thread-root");
170
211
  expect(refreshTarget).toEqual({ kind: "post-view", id: "post-current" });
171
212
  expect(vi.mocked(composeEl.openReply).mock.calls[0]?.[4]).toBeUndefined();
172
- expect(replyData).toMatchObject({ dateText: "Mar 19" });
213
+ expect(replyData).toMatchObject({ dateText: "Mar 21" });
173
214
  expect((replyData as { contentHtml: string }).contentHtml).toContain(
174
- "Reply body",
215
+ "Latest reply body",
175
216
  );
176
217
  expect((replyData as { contentHtml: string }).contentHtml).not.toContain(
177
218
  "meta",
@@ -181,7 +222,7 @@ describe("compose shortcuts", () => {
181
222
  );
182
223
  });
183
224
 
184
- it("prefers the hovered thread post for reply shortcuts on detail pages", () => {
225
+ it("targets the latest thread post for reply shortcuts even when an older post is hovered", () => {
185
226
  const composeEl = createComposeHarness();
186
227
  const { hoveredArticle } = renderThreadDetailPage();
187
228
  const originalQuerySelector = document.querySelector.bind(document);
@@ -199,8 +240,8 @@ describe("compose shortcuts", () => {
199
240
 
200
241
  expect(event.defaultPrevented).toBe(true);
201
242
  expect(composeEl.openReply).toHaveBeenCalledWith(
202
- "post-hovered",
203
- expect.objectContaining({ dateText: "Mar 20" }),
243
+ "post-latest",
244
+ expect.objectContaining({ dateText: "Mar 21" }),
204
245
  "thread-root",
205
246
  { kind: "post-view", id: "post-current" },
206
247
  );
@@ -228,6 +269,21 @@ describe("compose shortcuts", () => {
228
269
  expect(composeEl.openEdit).toHaveBeenCalledWith("post-hovered");
229
270
  });
230
271
 
272
+ it("opens the current post editor from the edit query parameter", async () => {
273
+ const composeEl = createComposeHarness();
274
+ composeEl.openEdit = vi.fn(async () => {});
275
+ document.body.setAttribute("data-authenticated", "true");
276
+ renderThreadDetailPage();
277
+ globalThis.history.pushState({}, "", "/about?edit=1");
278
+
279
+ composeShortcutsTestOnly.openEditFromQueryParam();
280
+ await Promise.resolve();
281
+
282
+ expect(composeEl.openEdit).toHaveBeenCalledWith("post-current");
283
+ expect(window.location.pathname).toBe("/about");
284
+ expect(window.location.search).toBe("");
285
+ });
286
+
231
287
  it("keeps using the hovered post for reply shortcuts in the timeline", () => {
232
288
  const composeEl = createComposeHarness();
233
289
  const article = document.createElement("article");
@@ -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);
@@ -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,18 @@ 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
+
87
107
  function requireElement<T>(value: T | null, message: string): T {
88
108
  if (!value) {
89
109
  throw new Error(message);
@@ -133,6 +153,7 @@ async function createElement(): Promise<JantNavManager> {
133
153
  el.labels = labels;
134
154
  el.items = items;
135
155
  el.systemNavItems = [];
156
+ el.suggestedLinks = [];
136
157
  el.siteName = "Test Site";
137
158
  document.body.appendChild(el);
138
159
  await el.updateComplete;
@@ -251,6 +272,61 @@ describe("JantNavManager", () => {
251
272
  expect(popover.getAttribute("aria-hidden")).toBe("true");
252
273
  });
253
274
 
275
+ it("adds a suggested collection link through the nav items API", async () => {
276
+ const el = await createElement();
277
+ el.suggestedLinks = suggestedLinks;
278
+ el.requestUpdate();
279
+ await el.updateComplete;
280
+
281
+ const addButton = requireElement(
282
+ el.querySelector<HTMLButtonElement>(".nav-suggestion-item button"),
283
+ "expected suggested link add button",
284
+ );
285
+ expect(el.textContent).toContain("Now");
286
+ expect(el.textContent).toContain("/now · Collection");
287
+
288
+ const created: NavManagerItem = {
289
+ id: "nav-now",
290
+ type: "collection",
291
+ collectionId: "col_now",
292
+ label: "Now",
293
+ url: "/now",
294
+ placement: "header",
295
+ };
296
+ const fetchMock = vi.fn().mockResolvedValue({
297
+ ok: true,
298
+ status: 201,
299
+ json: async () => created,
300
+ });
301
+ vi.stubGlobal("fetch", fetchMock);
302
+
303
+ addButton.click();
304
+ await Promise.resolve();
305
+ await Promise.resolve();
306
+ await el.updateComplete;
307
+
308
+ expect(fetchMock).toHaveBeenCalledWith(
309
+ "/api/nav-items",
310
+ expect.objectContaining({
311
+ method: "POST",
312
+ body: JSON.stringify({
313
+ type: "collection",
314
+ collectionId: "col_now",
315
+ placement: "header",
316
+ }),
317
+ }),
318
+ );
319
+ expect(
320
+ getListIds(
321
+ requireElement(
322
+ el.querySelector<HTMLElement>("#nav-items-header"),
323
+ "expected header nav list",
324
+ ),
325
+ ),
326
+ ).toContain("nav-now");
327
+ expect(el.querySelector(".nav-suggestion-item")).toBeNull();
328
+ });
329
+
254
330
  it("confirms before dispatching nav deletion", async () => {
255
331
  const el = await createElement();
256
332
  const deleteHandler = vi.fn();
@@ -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",