@jant/core 0.6.10 → 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 (92) hide show
  1. package/bin/commands/import-site.js +45 -20
  2. package/dist/app-B5P1b4TF.js +6 -0
  3. package/dist/{app-CGHkOdme.js → app-D-zAhayc.js} +847 -244
  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-DYrWuaIk.js → client-S1cdvulk.js} +1 -1
  7. package/dist/client/_assets/{client-auth-B5Re0uCd.js → client-auth-YW92ZH7u.js} +158 -97
  8. package/dist/{export-DY1v5Iqu.js → export-Ckwh4BiE.js} +96 -96
  9. package/dist/{github-sync-LefaslGJ.js → github-sync-BMmK3JZ0.js} +2 -2
  10. package/dist/{github-sync-2_T7nbOv.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 +81 -0
  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/compose-types.ts +4 -0
  25. package/src/client/components/jant-command-palette.ts +67 -2
  26. package/src/client/components/jant-compose-dialog.ts +8 -2
  27. package/src/client/components/jant-compose-editor.ts +111 -0
  28. package/src/client/components/jant-nav-manager.ts +142 -2
  29. package/src/client/components/jant-settings-general.ts +56 -0
  30. package/src/client/components/nav-manager-types.ts +14 -0
  31. package/src/client/components/settings-types.ts +30 -0
  32. package/src/client/compose-bridge.ts +25 -8
  33. package/src/client/compose-launch.ts +30 -0
  34. package/src/client/compose-shortcuts.ts +46 -3
  35. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +143 -0
  36. package/src/client/tiptap/__tests__/paste-rehost-e2e.test.ts +65 -0
  37. package/src/client/tiptap/__tests__/rehost-images.test.ts +192 -0
  38. package/src/client/tiptap/create-editor.ts +3 -0
  39. package/src/client/tiptap/extensions.ts +4 -0
  40. package/src/client/tiptap/inline-image-upload.ts +174 -50
  41. package/src/client/tiptap/rehost-images.ts +108 -0
  42. package/src/i18n/__tests__/fallback.test.ts +24 -0
  43. package/src/i18n/locales/public/en.po +10 -0
  44. package/src/i18n/locales/public/en.ts +1 -1
  45. package/src/i18n/locales/public/zh-Hans.po +10 -0
  46. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  47. package/src/i18n/locales/public/zh-Hant.po +10 -0
  48. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  49. package/src/i18n/locales/settings/en.po +54 -0
  50. package/src/i18n/locales/settings/en.ts +1 -1
  51. package/src/i18n/locales/settings/zh-Hans.po +64 -10
  52. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  53. package/src/i18n/locales/settings/zh-Hant.po +56 -2
  54. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  55. package/src/lib/__tests__/feed.test.ts +96 -10
  56. package/src/lib/__tests__/upload-sideload.test.ts +78 -0
  57. package/src/lib/__tests__/url-fetch.test.ts +181 -0
  58. package/src/lib/feed.ts +63 -3
  59. package/src/lib/post-display.ts +14 -8
  60. package/src/lib/upload.ts +111 -0
  61. package/src/lib/url-fetch.ts +263 -0
  62. package/src/preset.css +60 -18
  63. package/src/routes/api/__tests__/uploads.test.ts +63 -1
  64. package/src/routes/api/uploads.ts +52 -0
  65. package/src/routes/dash/settings.tsx +41 -2
  66. package/src/routes/pages/__tests__/draft-about-edit.test.ts +65 -0
  67. package/src/routes/pages/page.tsx +44 -12
  68. package/src/services/__tests__/about-page.test.ts +116 -0
  69. package/src/services/__tests__/media.test.ts +168 -1
  70. package/src/services/__tests__/navigation.test.ts +190 -1
  71. package/src/services/__tests__/post-timeline.test.ts +119 -2
  72. package/src/services/about-page.ts +143 -0
  73. package/src/services/export-theme/layouts/_default/rss.xml +2 -1
  74. package/src/services/export-theme/layouts/partials/feed-post-content.xml +25 -1
  75. package/src/services/export-theme/styles/main.css +46 -14
  76. package/src/services/index.ts +27 -17
  77. package/src/services/media.ts +111 -0
  78. package/src/services/navigation.ts +155 -1
  79. package/src/services/post.ts +24 -7
  80. package/src/styles/components.css +20 -0
  81. package/src/styles/tokens.css +26 -8
  82. package/src/styles/ui.css +149 -7
  83. package/src/types/views.ts +13 -0
  84. package/src/ui/compose/ComposeDialog.tsx +16 -0
  85. package/src/ui/dash/appearance/NavigationContent.tsx +57 -0
  86. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +19 -0
  87. package/src/ui/dash/settings/GeneralContent.tsx +44 -0
  88. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +8 -0
  89. package/src/ui/feed/__tests__/timeline-cards.test.ts +162 -1
  90. package/src/ui/layouts/BaseLayout.tsx +12 -0
  91. package/dist/app-D24n0DoH.js +0 -6
  92. package/dist/client/_assets/client-xWDl78yi.css +0 -2
@@ -23,6 +23,7 @@ import {
23
23
  toPublicHref,
24
24
  toPublicPath,
25
25
  } from "../../lib/url.js";
26
+ import { ABOUT_PAGE_SLUG } from "../../services/about-page.js";
26
27
  import { isTextAttachment } from "../../services/media.js";
27
28
  import type { Post } from "../../types.js";
28
29
  import { renderArchivePage } from "./archive.js";
@@ -186,12 +187,30 @@ async function renderPostWithTextPreview(
186
187
  });
187
188
  }
188
189
 
189
- async function renderPost(c: Context<Env>, post: Post) {
190
+ function canRenderDraftAboutEditor(
191
+ c: Context<Env>,
192
+ fullPath: string,
193
+ post: Post,
194
+ ): boolean {
195
+ return (
196
+ post.status === "draft" &&
197
+ fullPath === ABOUT_PAGE_SLUG &&
198
+ c.req.query("edit") === "1" &&
199
+ c.var.isAuthenticated
200
+ );
201
+ }
202
+
203
+ async function renderPost(
204
+ c: Context<Env>,
205
+ post: Post,
206
+ options: { allowDraft?: boolean } = {},
207
+ ) {
190
208
  // Start navData fetch immediately — it's independent of thread/media queries
191
209
  const navDataPromise = getNavigationData(c);
192
210
  const display = await assemblePostPageDisplay(c, post, {
193
211
  // Private-post access is validated before renderPost() is called.
194
212
  isAuthenticated: true,
213
+ allowDraft: options.allowDraft,
195
214
  });
196
215
  if (!display) {
197
216
  return c.notFound();
@@ -208,15 +227,25 @@ async function renderPost(c: Context<Env>, post: Post) {
208
227
  return renderPublicPage(c, {
209
228
  title: buildPageTitle(meta.title, navData.siteName),
210
229
  description: meta.description,
211
- canonicalHref,
212
- socialImageUrl: display.socialImage?.url,
213
- socialImageAlt: display.socialImage?.alt,
214
- socialImageWidth: display.socialImage?.width,
215
- socialImageHeight: display.socialImage?.height,
216
- ogType: "article",
217
- articlePublishedTime: display.articlePublishedTime,
218
- articleModifiedTime: display.articleModifiedTime,
219
- jsonLd: buildPostJsonLd(c, display, meta, canonicalHref, navData.siteName),
230
+ ...(post.status === "published"
231
+ ? {
232
+ canonicalHref,
233
+ socialImageUrl: display.socialImage?.url,
234
+ socialImageAlt: display.socialImage?.alt,
235
+ socialImageWidth: display.socialImage?.width,
236
+ socialImageHeight: display.socialImage?.height,
237
+ ogType: "article" as const,
238
+ articlePublishedTime: display.articlePublishedTime,
239
+ articleModifiedTime: display.articleModifiedTime,
240
+ jsonLd: buildPostJsonLd(
241
+ c,
242
+ display,
243
+ meta,
244
+ canonicalHref,
245
+ navData.siteName,
246
+ ),
247
+ }
248
+ : {}),
220
249
  navData,
221
250
  content: (
222
251
  <PostPage post={display.postView} threadPosts={display.threadPostViews} />
@@ -350,7 +379,10 @@ pageRoutes.get("/*", async (c) => {
350
379
 
351
380
  if (resolved.postId) {
352
381
  const post = await c.var.services.posts.getById(resolved.postId);
353
- if (!post || post.status === "draft") return c.notFound();
382
+ if (!post) return c.notFound();
383
+
384
+ const allowDraft = canRenderDraftAboutEditor(c, fullPath, post);
385
+ if (post.status === "draft" && !allowDraft) return c.notFound();
354
386
 
355
387
  if (post.visibility === "private") {
356
388
  const navData = await getNavigationData(c);
@@ -368,7 +400,7 @@ pageRoutes.get("/*", async (c) => {
368
400
  }
369
401
  }
370
402
 
371
- return renderPost(c, post);
403
+ return renderPost(c, post, { allowDraft });
372
404
  }
373
405
 
374
406
  if (resolved.collectionId) {
@@ -0,0 +1,116 @@
1
+ import { describe, expect, it, beforeEach } from "vitest";
2
+ import {
3
+ createTestDatabase,
4
+ DEFAULT_TEST_SITE_ID,
5
+ } from "../../__tests__/helpers/db.js";
6
+ import type { Database } from "../../db/index.js";
7
+ import { createAboutPageService } from "../about-page.js";
8
+ import { createCollectionService } from "../collection.js";
9
+ import { createPathService } from "../path.js";
10
+ import { createPostService } from "../post.js";
11
+
12
+ const ABOUT_BODY = JSON.stringify({
13
+ type: "doc",
14
+ content: [
15
+ {
16
+ type: "paragraph",
17
+ content: [{ type: "text", text: "About this site." }],
18
+ },
19
+ ],
20
+ });
21
+
22
+ describe("AboutPageService", () => {
23
+ let aboutPage: ReturnType<typeof createAboutPageService>;
24
+ let posts: ReturnType<typeof createPostService>;
25
+ let collections: ReturnType<typeof createCollectionService>;
26
+
27
+ beforeEach(() => {
28
+ const testDb = createTestDatabase();
29
+ const db = testDb.db as unknown as Database;
30
+ const paths = createPathService(db, DEFAULT_TEST_SITE_ID);
31
+ posts = createPostService(
32
+ db,
33
+ { slugIdLength: 5 },
34
+ DEFAULT_TEST_SITE_ID,
35
+ paths,
36
+ );
37
+ collections = createCollectionService(db, DEFAULT_TEST_SITE_ID, paths);
38
+ aboutPage = createAboutPageService({
39
+ paths,
40
+ posts,
41
+ collections,
42
+ });
43
+ });
44
+
45
+ it("reports a missing About page when /about is unused", async () => {
46
+ await expect(aboutPage.getStatus()).resolves.toMatchObject({
47
+ state: "missing",
48
+ path: "/about",
49
+ });
50
+ });
51
+
52
+ it("recognizes a post at /about as the About page", async () => {
53
+ const post = await posts.create({
54
+ format: "note",
55
+ title: "About",
56
+ slug: "about",
57
+ visibility: "latest_hidden",
58
+ body: ABOUT_BODY,
59
+ });
60
+
61
+ await expect(aboutPage.getStatus()).resolves.toMatchObject({
62
+ state: "ready",
63
+ post: {
64
+ id: post.id,
65
+ title: "About",
66
+ visibility: "latest_hidden",
67
+ },
68
+ });
69
+ });
70
+
71
+ it("creates a hidden About page when /about is unused", async () => {
72
+ const post = await aboutPage.ensurePage();
73
+ expect(post).toMatchObject({
74
+ title: "About",
75
+ status: "published",
76
+ visibility: "latest_hidden",
77
+ });
78
+
79
+ await expect(aboutPage.getStatus()).resolves.toMatchObject({
80
+ state: "ready",
81
+ post: {
82
+ id: post.id,
83
+ title: "About",
84
+ status: "published",
85
+ visibility: "latest_hidden",
86
+ },
87
+ });
88
+ });
89
+
90
+ it("reports a conflict when a collection owns /about", async () => {
91
+ const collection = await collections.create({
92
+ title: "About",
93
+ slug: "about",
94
+ });
95
+
96
+ await expect(aboutPage.getStatus()).resolves.toMatchObject({
97
+ state: "conflict",
98
+ conflict: {
99
+ targetType: "collection",
100
+ id: collection.id,
101
+ title: "About",
102
+ },
103
+ });
104
+ });
105
+
106
+ it("rejects About creation when another item owns /about", async () => {
107
+ await collections.create({
108
+ title: "About",
109
+ slug: "about",
110
+ });
111
+
112
+ await expect(aboutPage.ensurePage()).rejects.toThrow(
113
+ "/about is already used.",
114
+ );
115
+ });
116
+ });
@@ -1,5 +1,5 @@
1
1
  /* eslint-disable @typescript-eslint/no-non-null-assertion -- Test assertions use ! for readability */
2
- import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
3
3
  import { eq } from "drizzle-orm";
4
4
  import {
5
5
  createTestDatabase,
@@ -1546,3 +1546,170 @@ describe("MediaService", () => {
1546
1546
  });
1547
1547
  });
1548
1548
  });
1549
+
1550
+ /** Minimal valid PNG header (signature + IHDR with width 4, height 6). */
1551
+ function createPngBytes(): Uint8Array {
1552
+ return new Uint8Array([
1553
+ 0x89,
1554
+ 0x50,
1555
+ 0x4e,
1556
+ 0x47,
1557
+ 0x0d,
1558
+ 0x0a,
1559
+ 0x1a,
1560
+ 0x0a, // signature
1561
+ 0x00,
1562
+ 0x00,
1563
+ 0x00,
1564
+ 0x0d, // IHDR length (13)
1565
+ 0x49,
1566
+ 0x48,
1567
+ 0x44,
1568
+ 0x52, // "IHDR"
1569
+ 0x00,
1570
+ 0x00,
1571
+ 0x00,
1572
+ 0x04, // width = 4
1573
+ 0x00,
1574
+ 0x00,
1575
+ 0x00,
1576
+ 0x06, // height = 6
1577
+ 0x08,
1578
+ 0x06,
1579
+ 0x00,
1580
+ 0x00,
1581
+ 0x00, // bit depth, color type, ...
1582
+ ]);
1583
+ }
1584
+
1585
+ describe("MediaService.ingestFromUrl", () => {
1586
+ let db: Database;
1587
+ let mediaService: ReturnType<typeof createMediaService>;
1588
+
1589
+ beforeEach(() => {
1590
+ const testDb = createTestDatabase();
1591
+ db = testDb.db as unknown as Database;
1592
+ mediaService = createMediaService(db, DEFAULT_TEST_SITE_ID);
1593
+ });
1594
+
1595
+ afterEach(() => {
1596
+ vi.restoreAllMocks();
1597
+ });
1598
+
1599
+ const deps = () => ({
1600
+ storage: createMockStorage(),
1601
+ storageDriver: "local",
1602
+ maxFileSizeMB: 25,
1603
+ });
1604
+
1605
+ it("fetches a remote image, stores it, and creates a media row", async () => {
1606
+ const bytes = createPngBytes();
1607
+ vi.stubGlobal(
1608
+ "fetch",
1609
+ vi.fn(
1610
+ async () =>
1611
+ new Response(bytes, { headers: { "content-type": "image/png" } }),
1612
+ ),
1613
+ );
1614
+
1615
+ const d = deps();
1616
+ const media = await mediaService.ingestFromUrl(
1617
+ { url: "https://example.com/photo.png", alt: "A photo" },
1618
+ d,
1619
+ );
1620
+
1621
+ expect(media.mimeType).toBe("image/png");
1622
+ expect(media.mediaKind).toBe("image");
1623
+ expect(media.width).toBe(4);
1624
+ expect(media.height).toBe(6);
1625
+ expect(media.alt).toBe("A photo");
1626
+ expect(media.postId).toBeNull();
1627
+
1628
+ const stored = d.storage.files.get(media.storageKey);
1629
+ expect(stored).toBeDefined();
1630
+ expect(stored?.contentType).toBe("image/png");
1631
+ expect(stored?.contentDisposition).toBe("inline");
1632
+ });
1633
+
1634
+ it("stores SVG with attachment disposition (XSS-safe direct navigation)", async () => {
1635
+ const svg = new TextEncoder().encode(
1636
+ '<svg xmlns="http://www.w3.org/2000/svg"></svg>',
1637
+ );
1638
+ vi.stubGlobal(
1639
+ "fetch",
1640
+ vi.fn(
1641
+ async () =>
1642
+ new Response(svg, {
1643
+ headers: { "content-type": "image/svg+xml" },
1644
+ }),
1645
+ ),
1646
+ );
1647
+
1648
+ const d = deps();
1649
+ const media = await mediaService.ingestFromUrl(
1650
+ { url: "https://example.com/icon.svg" },
1651
+ d,
1652
+ );
1653
+
1654
+ expect(media.mimeType).toBe("image/svg+xml");
1655
+ const stored = d.storage.files.get(media.storageKey);
1656
+ expect(stored?.contentDisposition).toBe("attachment");
1657
+ });
1658
+
1659
+ it("rejects content that isn't a real image (content-type spoofing)", async () => {
1660
+ const html = new TextEncoder().encode(
1661
+ "<!doctype html><script>alert(1)</script>",
1662
+ );
1663
+ vi.stubGlobal(
1664
+ "fetch",
1665
+ vi.fn(
1666
+ // Server lies: claims PNG, returns HTML.
1667
+ async () =>
1668
+ new Response(html, { headers: { "content-type": "image/png" } }),
1669
+ ),
1670
+ );
1671
+
1672
+ await expect(
1673
+ mediaService.ingestFromUrl(
1674
+ { url: "https://example.com/evil.png" },
1675
+ deps(),
1676
+ ),
1677
+ ).rejects.toThrow(/supported image/i);
1678
+ });
1679
+
1680
+ it("rejects an oversize image", async () => {
1681
+ const stream = new ReadableStream<Uint8Array>({
1682
+ start(controller) {
1683
+ controller.enqueue(new Uint8Array(2 * 1024 * 1024));
1684
+ controller.close();
1685
+ },
1686
+ });
1687
+ vi.stubGlobal(
1688
+ "fetch",
1689
+ vi.fn(
1690
+ async () =>
1691
+ new Response(stream, { headers: { "content-type": "image/png" } }),
1692
+ ),
1693
+ );
1694
+
1695
+ await expect(
1696
+ mediaService.ingestFromUrl(
1697
+ { url: "https://example.com/huge.png" },
1698
+ { ...deps(), maxFileSizeMB: 1 },
1699
+ ),
1700
+ ).rejects.toThrow(/too large/i);
1701
+ });
1702
+
1703
+ it("rejects a private/SSRF URL before fetching", async () => {
1704
+ const fetchMock = vi.fn();
1705
+ vi.stubGlobal("fetch", fetchMock);
1706
+
1707
+ await expect(
1708
+ mediaService.ingestFromUrl(
1709
+ { url: "http://169.254.169.254/latest/meta-data" },
1710
+ deps(),
1711
+ ),
1712
+ ).rejects.toThrow(/private address/i);
1713
+ expect(fetchMock).not.toHaveBeenCalled();
1714
+ });
1715
+ });
@@ -9,11 +9,50 @@ import { now } from "../../lib/time.js";
9
9
 
10
10
  const TEST_COLLECTION_ID = "col_test00000000000000000000001";
11
11
  const TEST_COLLECTION_ID_2 = "col_test00000000000000000000002";
12
+ const TEST_POST_ID = "pst_test00000000000000000000001";
13
+ const TEST_POST_ID_2 = "pst_test00000000000000000000002";
14
+
15
+ function insertTestPath(
16
+ sqlite: ReturnType<typeof createTestDatabase>["sqlite"],
17
+ input: {
18
+ id: string;
19
+ path: string;
20
+ kind: "slug" | "alias" | "redirect" | "archive";
21
+ postId?: string | null;
22
+ collectionId?: string | null;
23
+ redirectToPath?: string | null;
24
+ redirectType?: 301 | 302 | null;
25
+ archiveQuery?: string | null;
26
+ },
27
+ ) {
28
+ const ts = now();
29
+ sqlite
30
+ .prepare(
31
+ `INSERT INTO path_registry (
32
+ id, site_id, path, kind, post_id, collection_id, redirect_to_path,
33
+ redirect_type, archive_query, created_at, updated_at
34
+ )
35
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
36
+ )
37
+ .run(
38
+ input.id,
39
+ DEFAULT_TEST_SITE_ID,
40
+ input.path,
41
+ input.kind,
42
+ input.postId ?? null,
43
+ input.collectionId ?? null,
44
+ input.redirectToPath ?? null,
45
+ input.redirectType ?? null,
46
+ input.archiveQuery ?? null,
47
+ ts,
48
+ ts,
49
+ );
50
+ }
12
51
 
13
52
  function insertTestCollection(
14
53
  sqlite: ReturnType<typeof createTestDatabase>["sqlite"],
15
54
  id: string,
16
- _slug: string,
55
+ slug: string,
17
56
  title: string,
18
57
  ) {
19
58
  const ts = now();
@@ -23,6 +62,51 @@ function insertTestCollection(
23
62
  VALUES (?, ?, ?, 'newest', ?, ?)`,
24
63
  )
25
64
  .run(id, DEFAULT_TEST_SITE_ID, title, ts, ts);
65
+ insertTestPath(sqlite, {
66
+ id: `pth_col_${id.slice(4)}`,
67
+ path: slug,
68
+ kind: "slug",
69
+ collectionId: id,
70
+ });
71
+ }
72
+
73
+ function insertTestPost(
74
+ sqlite: ReturnType<typeof createTestDatabase>["sqlite"],
75
+ input: {
76
+ id: string;
77
+ slug: string;
78
+ title: string | null;
79
+ status?: "draft" | "published";
80
+ visibility?: "public" | "latest_hidden" | "private";
81
+ },
82
+ ) {
83
+ const ts = now();
84
+ sqlite
85
+ .prepare(
86
+ `INSERT INTO post (
87
+ id, site_id, format, status, visibility, title, thread_id,
88
+ published_at, last_activity_at, created_at, updated_at
89
+ )
90
+ VALUES (?, ?, 'note', ?, ?, ?, ?, ?, ?, ?, ?)`,
91
+ )
92
+ .run(
93
+ input.id,
94
+ DEFAULT_TEST_SITE_ID,
95
+ input.status ?? "published",
96
+ input.visibility ?? "latest_hidden",
97
+ input.title,
98
+ input.id,
99
+ ts,
100
+ ts,
101
+ ts,
102
+ ts,
103
+ );
104
+ insertTestPath(sqlite, {
105
+ id: `pth_pst_${input.id.slice(4)}`,
106
+ path: input.slug,
107
+ kind: "slug",
108
+ postId: input.id,
109
+ });
26
110
  }
27
111
 
28
112
  describe("NavItemService", () => {
@@ -277,6 +361,111 @@ describe("NavItemService", () => {
277
361
  });
278
362
  });
279
363
 
364
+ describe("listSuggestedLinks", () => {
365
+ it("suggests a published /about page as a normal link", async () => {
366
+ insertTestPost(sqlite, {
367
+ id: TEST_POST_ID,
368
+ slug: "about",
369
+ title: "About me",
370
+ });
371
+
372
+ const suggestions = await navItemService.listSuggestedLinks();
373
+
374
+ expect(suggestions).toEqual([
375
+ {
376
+ key: "about",
377
+ label: "About me",
378
+ url: "/about",
379
+ targetType: "page",
380
+ navItemType: "link",
381
+ },
382
+ ]);
383
+ });
384
+
385
+ it("suggests a canonical /now collection as a collection nav item", async () => {
386
+ insertTestCollection(sqlite, TEST_COLLECTION_ID, "now", "Now");
387
+
388
+ const suggestions = await navItemService.listSuggestedLinks();
389
+
390
+ expect(suggestions).toEqual([
391
+ {
392
+ key: "now",
393
+ label: "Now",
394
+ url: "/now",
395
+ targetType: "collection",
396
+ navItemType: "collection",
397
+ collectionId: TEST_COLLECTION_ID,
398
+ },
399
+ ]);
400
+ });
401
+
402
+ it("suggests a /now collection alias as a normal link to preserve the path", async () => {
403
+ insertTestCollection(sqlite, TEST_COLLECTION_ID, "updates", "Updates");
404
+ insertTestPath(sqlite, {
405
+ id: "pth_now_alias",
406
+ path: "now",
407
+ kind: "alias",
408
+ collectionId: TEST_COLLECTION_ID,
409
+ });
410
+
411
+ const suggestions = await navItemService.listSuggestedLinks();
412
+
413
+ expect(suggestions).toEqual([
414
+ {
415
+ key: "now",
416
+ label: "Updates",
417
+ url: "/now",
418
+ targetType: "collection",
419
+ navItemType: "link",
420
+ },
421
+ ]);
422
+ });
423
+
424
+ it("hides suggestions already represented by path or collection id", async () => {
425
+ insertTestPost(sqlite, {
426
+ id: TEST_POST_ID,
427
+ slug: "about",
428
+ title: "About",
429
+ });
430
+ insertTestCollection(sqlite, TEST_COLLECTION_ID, "now", "Now");
431
+
432
+ await navItemService.create({
433
+ type: "link",
434
+ label: "About",
435
+ url: "/about/",
436
+ });
437
+ await navItemService.create({
438
+ type: "collection",
439
+ collectionId: TEST_COLLECTION_ID,
440
+ label: "Now",
441
+ url: "/now",
442
+ });
443
+
444
+ const suggestions = await navItemService.listSuggestedLinks();
445
+
446
+ expect(suggestions).toEqual([]);
447
+ });
448
+
449
+ it("does not suggest draft or private posts", async () => {
450
+ insertTestPost(sqlite, {
451
+ id: TEST_POST_ID,
452
+ slug: "about",
453
+ title: "About",
454
+ status: "draft",
455
+ });
456
+ insertTestPost(sqlite, {
457
+ id: TEST_POST_ID_2,
458
+ slug: "now",
459
+ title: "Now",
460
+ visibility: "private",
461
+ });
462
+
463
+ const suggestions = await navItemService.listSuggestedLinks();
464
+
465
+ expect(suggestions).toEqual([]);
466
+ });
467
+ });
468
+
280
469
  describe("update", () => {
281
470
  it("updates a nav item's label", async () => {
282
471
  const created = await navItemService.create({