@jant/core 0.6.12 → 0.6.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/bin/commands/import-site.js +2 -10
  2. package/bin/commands/migrate-text-attachments.js +1 -3
  3. package/bin/commands/site/export.js +1 -3
  4. package/bin/commands/telegram/register-webhooks.js +9 -3
  5. package/bin/lib/site-snapshot.js +1 -5
  6. package/dist/{app-D-zAhayc.js → app-2i7-WQHg.js} +426 -450
  7. package/dist/app-DZDlmh7C.js +6 -0
  8. package/dist/client/.vite/manifest.json +3 -3
  9. package/dist/client/_assets/{client-S1cdvulk.js → client-BvID5ucz.js} +1 -1
  10. package/dist/client/_assets/client-Cevfpm8H.css +2 -0
  11. package/dist/client/_assets/{client-auth-YW92ZH7u.js → client-auth-v3yvhbgZ.js} +596 -590
  12. package/dist/{export-Ckwh4BiE.js → export-DWn149b7.js} +24 -19
  13. package/dist/{github-sync-BMmK3JZ0.js → github-sync-C0zyF6XS.js} +2 -2
  14. package/dist/{github-sync-Dl9wQMar.js → github-sync-Ckk0sJxK.js} +1 -1
  15. package/dist/index.js +3 -3
  16. package/dist/node.js +4 -4
  17. package/package.json +1 -1
  18. package/src/__tests__/export-service.test.ts +1 -2
  19. package/src/__tests__/helpers/app.ts +3 -0
  20. package/src/__tests__/helpers/export-fixtures.ts +0 -1
  21. package/src/__tests__/import-site-command.test.ts +1 -2
  22. package/src/client/__tests__/site-header-nav.test.ts +36 -2
  23. package/src/client/components/__tests__/compose-title-from-heading.test.ts +57 -0
  24. package/src/client/components/__tests__/jant-command-palette.test.ts +137 -10
  25. package/src/client/components/__tests__/jant-compose-dialog.test.ts +3 -0
  26. package/src/client/components/__tests__/jant-compose-editor.test.ts +94 -2
  27. package/src/client/components/__tests__/jant-nav-manager.test.ts +107 -22
  28. package/src/client/components/__tests__/jant-settings-general.test.ts +1 -0
  29. package/src/client/components/compose-title-from-heading.ts +66 -0
  30. package/src/client/components/compose-types.ts +8 -0
  31. package/src/client/components/inline-image-issues.ts +17 -0
  32. package/src/client/components/jant-command-palette.ts +100 -70
  33. package/src/client/components/jant-compose-dialog.ts +3 -1
  34. package/src/client/components/jant-compose-editor.ts +78 -1
  35. package/src/client/components/jant-compose-fullscreen.ts +2 -0
  36. package/src/client/components/jant-nav-manager.ts +245 -155
  37. package/src/client/components/nav-manager-types.ts +0 -10
  38. package/src/client/site-header-nav.d.ts +1 -0
  39. package/src/client/site-header-nav.js +105 -47
  40. package/src/client/tiptap/__tests__/block-insertion.test.ts +35 -0
  41. package/src/client/tiptap/__tests__/footnotes.test.ts +284 -0
  42. package/src/client/tiptap/__tests__/image-input-rules.test.ts +117 -0
  43. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +148 -1
  44. package/src/client/tiptap/bubble-menu.ts +3 -3
  45. package/src/client/tiptap/create-editor.ts +7 -0
  46. package/src/client/tiptap/extensions.ts +7 -2
  47. package/src/client/tiptap/footnotes.ts +156 -0
  48. package/src/client/tiptap/image-input-rules.ts +48 -0
  49. package/src/client/tiptap/image-node.ts +189 -7
  50. package/src/client/tiptap/markdown-clipboard.ts +7 -2
  51. package/src/client-auth.ts +0 -1
  52. package/src/i18n/locales/public/en.po +20 -4
  53. package/src/i18n/locales/public/en.ts +1 -1
  54. package/src/i18n/locales/public/zh-Hans.po +20 -4
  55. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  56. package/src/i18n/locales/public/zh-Hant.po +20 -4
  57. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  58. package/src/i18n/locales/settings/en.po +23 -20
  59. package/src/i18n/locales/settings/en.ts +1 -1
  60. package/src/i18n/locales/settings/zh-Hans.po +23 -20
  61. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  62. package/src/i18n/locales/settings/zh-Hant.po +23 -20
  63. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  64. package/src/lib/__tests__/markdown-to-tiptap.test.ts +4 -0
  65. package/src/lib/__tests__/navigation.test.ts +4 -37
  66. package/src/lib/__tests__/resolve-config.test.ts +6 -1
  67. package/src/lib/__tests__/view.test.ts +6 -30
  68. package/src/lib/github-sync-site-config.ts +0 -2
  69. package/src/lib/hugo-markdown.ts +1 -1
  70. package/src/lib/markdown-manager.ts +14 -5
  71. package/src/lib/navigation.ts +5 -22
  72. package/src/lib/resolve-config.ts +9 -2
  73. package/src/lib/site-header-fragment.tsx +37 -0
  74. package/src/lib/view.ts +2 -7
  75. package/src/node/__tests__/cli-site-snapshot.test.ts +45 -45
  76. package/src/routes/api/__tests__/nav-items.test.ts +26 -0
  77. package/src/routes/api/export.ts +0 -2
  78. package/src/routes/api/internal/__tests__/uploads.test.ts +9 -13
  79. package/src/routes/api/internal/sites.ts +5 -5
  80. package/src/routes/api/nav-items.ts +25 -4
  81. package/src/routes/dash/settings.tsx +8 -2
  82. package/src/routes/feed/__tests__/feed.test.ts +42 -0
  83. package/src/routes/feed/__tests__/sitemap.test.ts +2 -13
  84. package/src/routes/feed/feed.ts +1 -0
  85. package/src/routes/feed/sitemap.ts +2 -6
  86. package/src/routes/pages/archive.tsx +1 -0
  87. package/src/routes/pages/collection.tsx +1 -0
  88. package/src/routes/pages/featured.tsx +0 -4
  89. package/src/routes/pages/home.tsx +4 -51
  90. package/src/routes/pages/latest.tsx +2 -51
  91. package/src/services/__tests__/github-app-installations.test.ts +10 -10
  92. package/src/services/__tests__/navigation.test.ts +41 -0
  93. package/src/services/__tests__/post-timeline.test.ts +56 -0
  94. package/src/services/__tests__/post.test.ts +33 -0
  95. package/src/services/__tests__/settings.test.ts +5 -20
  96. package/src/services/export-theme/assets/client-site.js +10 -10
  97. package/src/services/export-theme/layouts/_default/rss.xml +5 -15
  98. package/src/services/export-theme/layouts/index.html +26 -39
  99. package/src/services/export.ts +5 -10
  100. package/src/services/github-app-installations.ts +1 -3
  101. package/src/services/navigation.ts +44 -13
  102. package/src/services/post.ts +80 -57
  103. package/src/services/settings.ts +0 -10
  104. package/src/services/site-admin.ts +0 -2
  105. package/src/services/upload-session.ts +4 -18
  106. package/src/styles/tokens.css +2 -2
  107. package/src/styles/ui.css +316 -34
  108. package/src/types/bindings.ts +0 -1
  109. package/src/types/config.ts +3 -7
  110. package/src/ui/compose/ComposeDialog.tsx +28 -0
  111. package/src/ui/dash/appearance/NavigationContent.tsx +1 -1
  112. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  113. package/src/ui/dash/settings/SettingsRootContent.tsx +3 -3
  114. package/src/ui/feed/__tests__/timeline-cards.test.ts +8 -3
  115. package/src/ui/layouts/BaseLayout.tsx +1 -1
  116. package/src/ui/layouts/SiteLayout.tsx +266 -227
  117. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +48 -0
  118. package/dist/app-B5P1b4TF.js +0 -6
  119. package/dist/client/_assets/client-BUxR-E8O.css +0 -2
  120. package/src/client/nav-manager-bridge.ts +0 -54
@@ -163,11 +163,11 @@ internalSitesRoutes.get(
163
163
  },
164
164
  );
165
165
 
166
- // Clean up a single managed site's expired upload sessions and orphaned media
167
- // (uploaded during compose but never published). Invoked per-site by the
168
- // hosted control plane's scheduled maintenance. Self-hosted operators use the
169
- // host-scoped `POST /api/internal/uploads/cleanup` route / `jant uploads
170
- // cleanup` CLI instead.
166
+ // Clean up a single managed site's expired upload sessions and due trash
167
+ // objects. Invoked per-site by the hosted control plane's scheduled
168
+ // maintenance. Self-hosted operators use the host-scoped
169
+ // `POST /api/internal/uploads/cleanup` route / `jant uploads cleanup` CLI
170
+ // instead.
171
171
  internalSitesRoutes.post(
172
172
  "/:siteId/uploads/cleanup",
173
173
  requireInternalAdminApi(),
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { Hono } from "hono";
6
+ import type { Context } from "hono";
6
7
  import { z } from "zod";
7
8
  import type { Bindings } from "../../types.js";
8
9
  import type { AppVariables } from "../../types/app-context.js";
@@ -16,16 +17,36 @@ import {
16
17
  import { assertFound, parseIdParam, NotFoundError } from "../../lib/errors.js";
17
18
  import { ID_PREFIX } from "../../lib/ids.js";
18
19
  import { getCollectionPagePath } from "../../lib/collection-paths.js";
20
+ import { renderSiteHeaderHtml } from "../../lib/site-header-fragment.js";
19
21
 
20
22
  type Env = { Bindings: Bindings; Variables: AppVariables };
21
23
 
22
24
  export const navItemsApiRoutes = new Hono<Env>();
23
25
 
26
+ const INCLUDE_SITE_HEADER_RESPONSE = "include";
27
+ const SITE_HEADER_RESPONSE_HEADER = "x-jant-site-header";
28
+
24
29
  const MoveSchema = z.object({
25
30
  after: NavItemIdSchema.nullable().optional(),
26
31
  before: NavItemIdSchema.nullable().optional(),
27
32
  });
28
33
 
34
+ async function withSiteHeaderHtml<T extends object>(
35
+ c: Context<Env>,
36
+ body: T,
37
+ ): Promise<T | (T & { headerHtml: string })> {
38
+ if (
39
+ c.req.header(SITE_HEADER_RESPONSE_HEADER) !== INCLUDE_SITE_HEADER_RESPONSE
40
+ ) {
41
+ return body;
42
+ }
43
+
44
+ return {
45
+ ...body,
46
+ headerHtml: await renderSiteHeaderHtml(c),
47
+ };
48
+ }
49
+
29
50
  // List nav items
30
51
  navItemsApiRoutes.get("/", async (c) => {
31
52
  const items = await c.var.services.navItems.list();
@@ -46,7 +67,7 @@ navItemsApiRoutes.put("/:id/move", requireAuthApi(), async (c) => {
46
67
  "Nav item",
47
68
  );
48
69
 
49
- return c.json(item);
70
+ return c.json(await withSiteHeaderHtml(c, item));
50
71
  });
51
72
 
52
73
  // Create nav item (requires auth)
@@ -82,7 +103,7 @@ navItemsApiRoutes.post("/", requireAuthApi(), async (c) => {
82
103
  });
83
104
  }
84
105
 
85
- return c.json(item, 201);
106
+ return c.json(await withSiteHeaderHtml(c, item), 201);
86
107
  });
87
108
 
88
109
  // Update nav item (requires auth)
@@ -95,7 +116,7 @@ navItemsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
95
116
  "Nav item",
96
117
  );
97
118
 
98
- return c.json(item);
119
+ return c.json(await withSiteHeaderHtml(c, item));
99
120
  });
100
121
 
101
122
  // Delete nav item (requires auth)
@@ -105,5 +126,5 @@ navItemsApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
105
126
  const success = await c.var.services.navItems.delete(id);
106
127
  if (!success) throw new NotFoundError("Nav item");
107
128
 
108
- return c.json({ success: true });
129
+ return c.json(await withSiteHeaderHtml(c, { success: true }));
109
130
  });
@@ -189,7 +189,10 @@ function breadcrumbLabel(
189
189
  );
190
190
  case "account":
191
191
  return i18n._(
192
- msg({ message: "Account", comment: "@context: Breadcrumb label" }),
192
+ msg({
193
+ message: "Account & Data",
194
+ comment: "@context: Breadcrumb label",
195
+ }),
193
196
  );
194
197
  case "sessions":
195
198
  return i18n._(
@@ -783,7 +786,10 @@ settingsRoutes.get("/navigation", async (c) => {
783
786
  const [navItems, directoryData, suggestedLinks] = await Promise.all([
784
787
  c.var.services.navItems.list(),
785
788
  c.var.services.collections.listDirectoryData(),
786
- c.var.services.navItems.listSuggestedLinks(),
789
+ c.var.services.navItems.listSuggestedLinks({
790
+ siteOrigin: c.var.appConfig.siteOrigin,
791
+ sitePathPrefix: c.var.appConfig.sitePathPrefix,
792
+ }),
787
793
  ]);
788
794
  const navData = await getNavigationData(c);
789
795
 
@@ -121,6 +121,48 @@ describe("Atom Feed Routes", () => {
121
121
  expect(xml).not.toContain("Hidden Post");
122
122
  });
123
123
 
124
+ it("ignores pinned ordering when the main feed is latest", async () => {
125
+ const { app, services } = createFeedTestApp({
126
+ RSS_FEED_LIMIT: "2",
127
+ });
128
+
129
+ await services.settings.set("MAIN_RSS_FEED", "latest");
130
+
131
+ await services.posts.create({
132
+ format: "note",
133
+ title: "Older pinned",
134
+ bodyMarkdown: "Pinned but old",
135
+ status: "published",
136
+ pinned: true,
137
+ publishedAt: 1000,
138
+ });
139
+ await services.posts.create({
140
+ format: "note",
141
+ title: "Newer unpinned",
142
+ bodyMarkdown: "Newer",
143
+ status: "published",
144
+ publishedAt: 2000,
145
+ });
146
+ await services.posts.create({
147
+ format: "note",
148
+ title: "Newest unpinned",
149
+ bodyMarkdown: "Newest",
150
+ status: "published",
151
+ publishedAt: 3000,
152
+ });
153
+
154
+ const res = await app.request("/feed");
155
+ expect(res.status).toBe(200);
156
+
157
+ const xml = await res.text();
158
+ expect(xml).toContain("Newest unpinned");
159
+ expect(xml).toContain("Newer unpinned");
160
+ expect(xml).not.toContain("Older pinned");
161
+ expect(xml.indexOf("Newest unpinned")).toBeLessThan(
162
+ xml.indexOf("Newer unpinned"),
163
+ );
164
+ });
165
+
124
166
  it("returns Atom content type", async () => {
125
167
  const { app } = createFeedTestApp();
126
168
 
@@ -345,19 +345,8 @@ describe("Sitemap Routes", () => {
345
345
  expect(xml).toContain(`${TEST_SITE_ORIGIN}/archive`);
346
346
  });
347
347
 
348
- it("includes /latest when the homepage default is 'featured'", async () => {
349
- const { app, services } = createSitemapTestApp();
350
- await services.settings.set("HOME_DEFAULT_VIEW", "featured");
351
-
352
- const xml = await (await app.request("/sitemap-pages.xml")).text();
353
- expect(xml).toContain(`${TEST_SITE_ORIGIN}/latest`);
354
- expect(xml).not.toContain(`${TEST_SITE_ORIGIN}/featured`);
355
- });
356
-
357
- it("includes /featured when the homepage default is 'latest'", async () => {
358
- const { app, services } = createSitemapTestApp();
359
- await services.settings.set("HOME_DEFAULT_VIEW", "latest");
360
-
348
+ it("includes /featured as the secondary aggregate page", async () => {
349
+ const { app } = createSitemapTestApp();
361
350
  const xml = await (await app.request("/sitemap-pages.xml")).text();
362
351
  expect(xml).toContain(`${TEST_SITE_ORIGIN}/featured`);
363
352
  expect(xml).not.toContain(`${TEST_SITE_ORIGIN}/latest`);
@@ -126,6 +126,7 @@ async function buildLatestFeedData(
126
126
  excludeLatestHidden: true,
127
127
  excludePrivate: true,
128
128
  format,
129
+ ignorePinnedSort: true,
129
130
  limit: feedLimit,
130
131
  });
131
132
 
@@ -185,7 +185,7 @@ sitemapRoutes.get("/sitemap-collections.xml", async (c) => {
185
185
 
186
186
  sitemapRoutes.get("/sitemap-pages.xml", async (c) => {
187
187
  const { appConfig } = c.var;
188
- const { siteUrl, sitePathPrefix, homeDefaultView } = appConfig;
188
+ const { siteUrl, sitePathPrefix } = appConfig;
189
189
 
190
190
  const urls: SitemapUrlEntry[] = [
191
191
  {
@@ -200,12 +200,8 @@ sitemapRoutes.get("/sitemap-pages.xml", async (c) => {
200
200
  },
201
201
  ];
202
202
 
203
- // Whichever of /latest and /featured is NOT the homepage default is a
204
- // standalone URL worth indexing; the other 302-redirects to `/`.
205
- const secondaryAggregate =
206
- homeDefaultView === "featured" ? "/latest" : "/featured";
207
203
  urls.push({
208
- loc: absoluteUrl(secondaryAggregate, siteUrl, sitePathPrefix),
204
+ loc: absoluteUrl("/featured", siteUrl, sitePathPrefix),
209
205
  priority: "0.6",
210
206
  changefreq: "daily",
211
207
  });
@@ -630,6 +630,7 @@ async function buildArchiveFeedData(
630
630
  hasMedia: params.hasMedia,
631
631
  hasTitle: params.hasTitle,
632
632
  hasReplies: params.hasReplies,
633
+ ignorePinnedSort: true,
633
634
  ...(params.validYear
634
635
  ? {
635
636
  publishedAfter: Date.UTC(params.validYear, 0, 1) / 1000,
@@ -263,6 +263,7 @@ export async function renderCollectionFeed(
263
263
  {
264
264
  status: "published",
265
265
  excludePrivate: true,
266
+ ignoreCollectionPinnedSort: true,
266
267
  limit: feedLimit,
267
268
  },
268
269
  );
@@ -27,10 +27,6 @@ featuredRoutes.get("/", async (c) => {
27
27
  const navData = await getNavigationData(c);
28
28
  const i18n = getI18n(c);
29
29
 
30
- // When homepage already shows featured, redirect to avoid duplicate content
31
- if (navData.homeDefaultView === "featured") {
32
- return c.redirect(toPublicPath("/", navData.sitePathPrefix), 302);
33
- }
34
30
  const page = parsePageNumber(c.req.query("page"));
35
31
  const featuredTitle = i18n._(
36
32
  msg({
@@ -3,31 +3,21 @@
3
3
  *
4
4
  * Timeline feed with per-type card components and thread previews.
5
5
  * Uses page-based pagination.
6
- *
7
- * The homepage shows whichever built-in feed comes first between
8
- * Latest and Featured in navigation. The explicit feed routes still work.
9
6
  */
10
7
 
11
8
  import { Hono } from "hono";
12
9
  import { msg } from "@lingui/core/macro";
13
10
  import type { Bindings } from "../../types.js";
14
11
  import type { AppVariables } from "../../types/app-context.js";
15
- import {
16
- getHomeDefaultViewFromNavItems,
17
- getNavigationData,
18
- } from "../../lib/navigation.js";
12
+ import { getNavigationData } from "../../lib/navigation.js";
19
13
  import { getI18n } from "../../i18n/index.js";
20
14
  import { formatPageLabel, parsePageNumber } from "../../lib/pagination.js";
21
15
  import { buildPageTitle } from "../../lib/page-title.js";
22
16
  import { renderPublicPage } from "../../lib/render.js";
23
- import {
24
- assembleFeaturedTimeline,
25
- assembleTimeline,
26
- } from "../../lib/timeline.js";
17
+ import { assembleTimeline } from "../../lib/timeline.js";
27
18
  import { toAbsoluteSiteUrl, toPublicPath } from "../../lib/url.js";
28
19
  import { buildWebSiteJsonLd } from "../../lib/structured-data.js";
29
20
  import { HomePage } from "../../ui/pages/HomePage.js";
30
- import { FeaturedPage } from "../../ui/pages/FeaturedPage.js";
31
21
 
32
22
  type Env = { Bindings: Bindings; Variables: AppVariables };
33
23
 
@@ -39,19 +29,10 @@ homeRoutes.get("/", async (c) => {
39
29
  const paginatedPageTitle = formatPageLabel(page);
40
30
  const isAuthenticated = c.var.isAuthenticated;
41
31
 
42
- // Fetch nav items once — we need `homeDefaultView` to decide which timeline
43
- // to assemble, but `getNavigationData` also consumes them. Passing them
44
- // through avoids a duplicate DB query and unlocks the Promise.all below.
45
- const navItems = await c.var.services.navItems.list();
46
- const homeDefaultView = getHomeDefaultViewFromNavItems(navItems);
47
-
48
- const timelinePromise =
49
- homeDefaultView === "featured"
50
- ? assembleFeaturedTimeline(c, { page, isAuthenticated })
51
- : assembleTimeline(c, { page, isAuthenticated });
32
+ const timelinePromise = assembleTimeline(c, { page, isAuthenticated });
52
33
 
53
34
  const [navData, timeline] = await Promise.all([
54
- getNavigationData(c, { preloadedItems: navItems }),
35
+ getNavigationData(c),
55
36
  timelinePromise,
56
37
  ]);
57
38
 
@@ -74,34 +55,6 @@ homeRoutes.get("/", async (c) => {
74
55
  })
75
56
  : undefined;
76
57
 
77
- if (homeDefaultView === "featured") {
78
- const featuredTitle = i18n._(
79
- msg({
80
- message: "Featured",
81
- comment: "@context: Browser page title for the featured feed",
82
- }),
83
- );
84
-
85
- return renderPublicPage(c, {
86
- title:
87
- page > 1
88
- ? buildPageTitle(featuredTitle, paginatedPageTitle, navData.siteName)
89
- : navData.siteName,
90
- jsonLd: websiteJsonLd,
91
- navData,
92
- showHomeBranding:
93
- c.var.appConfig.showJantBrandingOnHome && currentPage === 1,
94
- content: (
95
- <FeaturedPage
96
- items={items}
97
- currentPage={currentPage}
98
- totalPages={totalPages}
99
- baseUrl={toPublicPath("/", navData.sitePathPrefix)}
100
- />
101
- ),
102
- });
103
- }
104
-
105
58
  const latestTitle = i18n._(
106
59
  msg({
107
60
  message: "Latest",
@@ -1,71 +1,22 @@
1
1
  /**
2
2
  * Latest Page Route
3
3
  *
4
- * Explicit /latest URL that always shows the latest posts timeline.
5
- * When Latest is the homepage default, this redirects to / to avoid
6
- * duplicate content. When Featured comes first, this serves as the
7
- * explicit latest view.
4
+ * The homepage is the canonical latest timeline, so /latest redirects to /.
8
5
  */
9
6
 
10
7
  import { Hono } from "hono";
11
- import { msg } from "@lingui/core/macro";
12
8
  import type { Bindings } from "../../types.js";
13
9
  import type { AppVariables } from "../../types/app-context.js";
14
- import { getI18n } from "../../i18n/index.js";
15
- import { getNavigationData } from "../../lib/navigation.js";
16
- import { formatPageLabel, parsePageNumber } from "../../lib/pagination.js";
17
- import { buildPageTitle } from "../../lib/page-title.js";
18
- import { renderPublicPage } from "../../lib/render.js";
19
- import { assembleTimeline } from "../../lib/timeline.js";
20
10
  import { toPublicPath } from "../../lib/url.js";
21
11
  import { defaultFeedRenderer } from "../../lib/feed.js";
22
12
  import { buildFeedData, parseFormatQuery, renderFeed } from "../feed/feed.js";
23
- import { HomePage } from "../../ui/pages/HomePage.js";
24
13
 
25
14
  type Env = { Bindings: Bindings; Variables: AppVariables };
26
15
 
27
16
  export const latestRoutes = new Hono<Env>();
28
17
 
29
18
  latestRoutes.get("/", async (c) => {
30
- const navData = await getNavigationData(c);
31
- const i18n = getI18n(c);
32
-
33
- // When homepage already shows latest, redirect to avoid duplicate content
34
- if (navData.homeDefaultView !== "featured") {
35
- return c.redirect(toPublicPath("/", navData.sitePathPrefix), 302);
36
- }
37
-
38
- const page = parsePageNumber(c.req.query("page"));
39
- const latestTitle = i18n._(
40
- msg({
41
- message: "Latest",
42
- comment: "@context: Browser page title for the latest feed",
43
- }),
44
- );
45
- const paginatedPageTitle = formatPageLabel(page);
46
-
47
- const { items, currentPage, totalPages } = await assembleTimeline(c, {
48
- page,
49
- isAuthenticated: navData.isAuthenticated,
50
- });
51
-
52
- return renderPublicPage(c, {
53
- title:
54
- page > 1
55
- ? buildPageTitle(latestTitle, paginatedPageTitle, navData.siteName)
56
- : buildPageTitle(latestTitle, navData.siteName),
57
- navData,
58
- content: (
59
- <HomePage
60
- items={items}
61
- baseUrl={toPublicPath("/latest", navData.sitePathPrefix)}
62
- currentPage={currentPage}
63
- totalPages={totalPages}
64
- isAuthenticated={navData.isAuthenticated}
65
- signinUrl={`${toPublicPath("/signin", navData.sitePathPrefix)}?redirect=${encodeURIComponent(toPublicPath("/latest", navData.sitePathPrefix))}`}
66
- />
67
- ),
68
- });
19
+ return c.redirect(toPublicPath("/", c.var.appConfig.sitePathPrefix), 302);
69
20
  });
70
21
 
71
22
  // Atom — /latest/feed (canonical latest feed; accepts ?format=note|link|quote)
@@ -14,7 +14,9 @@ import {
14
14
  const SECOND_SITE_ID = "sit_second00000000000000000000000";
15
15
  const SECOND_SITE_KEY = "second";
16
16
 
17
- function insertSecondSite(sqlite: ReturnType<typeof createTestDatabase>["sqlite"]) {
17
+ function insertSecondSite(
18
+ sqlite: ReturnType<typeof createTestDatabase>["sqlite"],
19
+ ) {
18
20
  const timestamp = Math.floor(Date.now() / 1000);
19
21
  sqlite
20
22
  .prepare(
@@ -48,9 +50,9 @@ describe("GitHubAppInstallationsService", () => {
48
50
  });
49
51
 
50
52
  it("returns empty list for an unknown site", async () => {
51
- expect(await service.listInstallationsForSite(DEFAULT_TEST_SITE_ID)).toEqual(
52
- [],
53
- );
53
+ expect(
54
+ await service.listInstallationsForSite(DEFAULT_TEST_SITE_ID),
55
+ ).toEqual([]);
54
56
  });
55
57
 
56
58
  it("returns empty list of sites for an unknown installation", async () => {
@@ -78,9 +80,8 @@ describe("GitHubAppInstallationsService", () => {
78
80
  DEFAULT_TEST_SITE_ID,
79
81
  makeAccount({ login: "old-name" }),
80
82
  );
81
- const initial = await service.listInstallationsForSite(
82
- DEFAULT_TEST_SITE_ID,
83
- );
83
+ const initial =
84
+ await service.listInstallationsForSite(DEFAULT_TEST_SITE_ID);
84
85
  const addedAt = initial[0]!.addedAt;
85
86
 
86
87
  await service.upsertInstallation(
@@ -88,9 +89,8 @@ describe("GitHubAppInstallationsService", () => {
88
89
  DEFAULT_TEST_SITE_ID,
89
90
  makeAccount({ login: "renamed-org", avatarUrl: "https://x.png" }),
90
91
  );
91
- const updated = await service.listInstallationsForSite(
92
- DEFAULT_TEST_SITE_ID,
93
- );
92
+ const updated =
93
+ await service.listInstallationsForSite(DEFAULT_TEST_SITE_ID);
94
94
  expect(updated).toHaveLength(1);
95
95
  expect(updated[0]!.addedAt).toBe(addedAt);
96
96
  expect(updated[0]!.account.login).toBe("renamed-org");
@@ -446,6 +446,47 @@ describe("NavItemService", () => {
446
446
  expect(suggestions).toEqual([]);
447
447
  });
448
448
 
449
+ it("hides suggestions already represented by a same-site absolute URL", async () => {
450
+ insertTestPost(sqlite, {
451
+ id: TEST_POST_ID,
452
+ slug: "about",
453
+ title: "About",
454
+ });
455
+
456
+ await navItemService.create({
457
+ type: "link",
458
+ label: "About",
459
+ url: "https://preview-test.owenyoung.com/about",
460
+ });
461
+
462
+ const suggestions = await navItemService.listSuggestedLinks({
463
+ siteOrigin: "https://preview-test.owenyoung.com",
464
+ });
465
+
466
+ expect(suggestions).toEqual([]);
467
+ });
468
+
469
+ it("hides suggestions already represented by a public path prefix", async () => {
470
+ insertTestPost(sqlite, {
471
+ id: TEST_POST_ID,
472
+ slug: "about",
473
+ title: "About",
474
+ });
475
+
476
+ await navItemService.create({
477
+ type: "link",
478
+ label: "About",
479
+ url: "https://example.com/blog/about",
480
+ });
481
+
482
+ const suggestions = await navItemService.listSuggestedLinks({
483
+ siteOrigin: "https://example.com",
484
+ sitePathPrefix: "/blog",
485
+ });
486
+
487
+ expect(suggestions).toEqual([]);
488
+ });
489
+
449
490
  it("does not suggest draft or private posts", async () => {
450
491
  insertTestPost(sqlite, {
451
492
  id: TEST_POST_ID,
@@ -591,6 +591,62 @@ describe("PostService - Timeline features", () => {
591
591
  expect(entries[1]?.collectedAt).toBe(100);
592
592
  });
593
593
 
594
+ it("can ignore collection pins for subscription feed ordering", async () => {
595
+ const collection = await collectionService.create({
596
+ slug: "rss",
597
+ title: "RSS",
598
+ });
599
+ const olderRoot = await postService.create({
600
+ format: "note",
601
+ bodyMarkdown: "Older pinned root",
602
+ publishedAt: 1000,
603
+ });
604
+ const newerRoot = await postService.create({
605
+ format: "note",
606
+ bodyMarkdown: "Newer unpinned root",
607
+ publishedAt: 2000,
608
+ });
609
+
610
+ await db.insert(postCollections).values([
611
+ {
612
+ siteId: DEFAULT_TEST_SITE_ID,
613
+ postId: olderRoot.id,
614
+ collectionId: collection.id,
615
+ createdAt: 100,
616
+ pinnedAt: 5000,
617
+ },
618
+ {
619
+ siteId: DEFAULT_TEST_SITE_ID,
620
+ postId: newerRoot.id,
621
+ collectionId: collection.id,
622
+ createdAt: 200,
623
+ },
624
+ ]);
625
+
626
+ const pageEntries = await postService.listCollectionFeedEntries(
627
+ collection.id,
628
+ {
629
+ status: "published",
630
+ },
631
+ );
632
+ const feedEntries = await postService.listCollectionFeedEntries(
633
+ collection.id,
634
+ {
635
+ status: "published",
636
+ ignoreCollectionPinnedSort: true,
637
+ },
638
+ );
639
+
640
+ expect(pageEntries.map((entry) => entry.post.id)).toEqual([
641
+ olderRoot.id,
642
+ newerRoot.id,
643
+ ]);
644
+ expect(feedEntries.map((entry) => entry.post.id)).toEqual([
645
+ newerRoot.id,
646
+ olderRoot.id,
647
+ ]);
648
+ });
649
+
594
650
  it("dedupes shared threads across multiple collections", async () => {
595
651
  const smart = await collectionService.create({
596
652
  slug: "smart",
@@ -786,6 +786,39 @@ describe("PostService", () => {
786
786
  ]);
787
787
  });
788
788
 
789
+ it("can ignore pinned sort for subscription feed queries", async () => {
790
+ await postService.create({
791
+ format: "note",
792
+ bodyMarkdown: "older pinned",
793
+ pinned: true,
794
+ status: "published",
795
+ publishedAt: 1000,
796
+ });
797
+ await postService.create({
798
+ format: "note",
799
+ bodyMarkdown: "newer unpinned",
800
+ status: "published",
801
+ publishedAt: 2000,
802
+ });
803
+ await postService.create({
804
+ format: "note",
805
+ bodyMarkdown: "newest unpinned",
806
+ status: "published",
807
+ publishedAt: 3000,
808
+ });
809
+
810
+ const results = await postService.list({
811
+ status: "published",
812
+ ignorePinnedSort: true,
813
+ limit: 2,
814
+ });
815
+
816
+ expect(results.map((p) => p.bodyText)).toEqual([
817
+ "newest unpinned",
818
+ "newer unpinned",
819
+ ]);
820
+ });
821
+
789
822
  it("excludes deleted posts", async () => {
790
823
  const post = await postService.create({
791
824
  format: "note",
@@ -164,7 +164,6 @@ describe("SettingsService", () => {
164
164
  siteLanguage: "en",
165
165
  cjkSerifFont: "off",
166
166
  showJantBrandingOnHome: false,
167
- homeDefaultView: "latest",
168
167
  mainRssFeed: "featured",
169
168
  timeZone: "UTC",
170
169
  };
@@ -198,25 +197,6 @@ describe("SettingsService", () => {
198
197
  expect(await settingsService.get("SITE_NAME")).toBe("Trimmed");
199
198
  });
200
199
 
201
- it("removes HOME_DEFAULT_VIEW when set to default", async () => {
202
- await settingsService.set("HOME_DEFAULT_VIEW", "featured");
203
- await settingsService.updateGeneral(
204
- { ...defaults, homeDefaultView: "latest" },
205
- { oldLanguage: "en", fallbackSiteName: "Jant" },
206
- );
207
-
208
- expect(await settingsService.get("HOME_DEFAULT_VIEW")).toBeNull();
209
- });
210
-
211
- it("stores HOME_DEFAULT_VIEW when set to featured", async () => {
212
- await settingsService.updateGeneral(
213
- { ...defaults, homeDefaultView: "featured" },
214
- { oldLanguage: "en", fallbackSiteName: "Jant" },
215
- );
216
-
217
- expect(await settingsService.get("HOME_DEFAULT_VIEW")).toBe("featured");
218
- });
219
-
220
200
  it("removes MAIN_RSS_FEED when set to default (featured)", async () => {
221
201
  await settingsService.set("MAIN_RSS_FEED", "latest");
222
202
  await settingsService.updateGeneral(
@@ -514,6 +494,11 @@ describe("SettingsService", () => {
514
494
 
515
495
  await settingsService.updateSearchSettings(true, { demoMode: false });
516
496
  expect(await settingsService.get("NOINDEX")).toBeNull();
497
+
498
+ await settingsService.updateHomeBranding(false);
499
+ expect(
500
+ await settingsService.get("SHOW_JANT_BRANDING_ON_HOME"),
501
+ ).toBeNull();
517
502
  });
518
503
  });
519
504
  });