@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
package/src/preset.css CHANGED
@@ -182,9 +182,9 @@ html {
182
182
  --tw-prose-quote-borders: var(--color-border);
183
183
  --tw-prose-captions: var(--color-muted-foreground);
184
184
  --tw-prose-kbd: var(--color-foreground);
185
- --tw-prose-code: var(--color-foreground);
186
- --tw-prose-pre-code: var(--color-muted-foreground);
187
- --tw-prose-pre-bg: var(--color-muted);
185
+ --tw-prose-code: var(--site-code-text);
186
+ --tw-prose-pre-code: var(--site-code-text);
187
+ --tw-prose-pre-bg: var(--site-code-block-bg);
188
188
  --tw-prose-th-borders: var(--color-border);
189
189
  --tw-prose-td-borders: var(--color-border);
190
190
  font-family: var(--font-body);
@@ -213,14 +213,12 @@ html {
213
213
 
214
214
  :where(h2) {
215
215
  font-size: var(--type-content-title);
216
- font-style: italic;
217
216
  margin-top: 2.1rem;
218
217
  margin-bottom: 1.4rem;
219
218
  }
220
219
 
221
220
  :where(h3) {
222
221
  font-size: var(--type-content-subtitle);
223
- font-style: italic;
224
222
  margin-top: 2rem;
225
223
  margin-bottom: 1.4rem;
226
224
  }
@@ -236,12 +234,12 @@ html {
236
234
 
237
235
  :where(ul, ol) {
238
236
  margin: 1.25em 0;
239
- padding-left: 1.625em;
237
+ padding-left: 1.4em;
240
238
  }
241
239
 
242
240
  :where(li, dt) {
243
- margin-top: 1.2em;
244
- margin-bottom: 1.2em;
241
+ margin-top: 0.65em;
242
+ margin-bottom: 0.65em;
245
243
  }
246
244
 
247
245
  /* Normalize li > p so list spacing is controlled by li alone,
@@ -257,18 +255,35 @@ html {
257
255
  :where(code, pre code) {
258
256
  font-size: var(--type-code);
259
257
  line-height: 1.42;
258
+ color: var(--site-code-text);
260
259
  overflow-wrap: break-word;
261
260
  }
262
261
 
263
262
  :where(:not(pre) > code) {
264
- padding: 0.1em 0.35em;
265
- background-color: var(--site-feed-card-bg);
266
- border: 1px solid var(--site-feed-card-border);
267
- border-radius: 0.25rem;
263
+ padding: 0.08em 0.28em;
264
+ background-color: var(--site-code-bg);
265
+ border-radius: 0.22em;
266
+ box-decoration-break: clone;
267
+ -webkit-box-decoration-break: clone;
268
+ }
269
+
270
+ :where(:not(pre) > code)::before,
271
+ :where(:not(pre) > code)::after {
272
+ content: none;
273
+ }
274
+
275
+ :where(pre) {
276
+ background-color: var(--site-code-block-bg);
277
+ border: 1px solid var(--site-code-block-border);
278
+ border-radius: 6px;
268
279
  }
269
280
 
270
281
  :where(pre code) {
271
282
  font-size: var(--type-code-block);
283
+ color: inherit;
284
+ background: transparent;
285
+ border: 0;
286
+ padding: 0;
272
287
  }
273
288
 
274
289
  :where(h1, h2, h3) :where(code) {
@@ -425,6 +440,37 @@ html {
425
440
  margin-bottom: 0;
426
441
  }
427
442
 
443
+ /*
444
+ * Feed body headings
445
+ *
446
+ * In list contexts, the post title owns the card heading level. Authored
447
+ * headings inside the body should remain visible, but they should read as
448
+ * sections within the card rather than as peer post titles.
449
+ */
450
+ [data-post]:not([data-page="post"]) [data-post-body].prose {
451
+ :where(h1, h2) {
452
+ font-size: calc(var(--type-content-body) * 1.12);
453
+ font-weight: var(--fw-medium);
454
+ margin-top: 1.45rem;
455
+ margin-bottom: 0.55rem;
456
+ }
457
+
458
+ :where(h3, h4) {
459
+ font-size: var(--type-content-body);
460
+ font-weight: var(--fw-medium);
461
+ margin-top: 1.2rem;
462
+ margin-bottom: 0.45rem;
463
+ }
464
+
465
+ :where(h5, h6) {
466
+ font-size: var(--type-secondary);
467
+ font-weight: var(--fw-medium);
468
+ color: var(--site-text-secondary);
469
+ margin-top: 1rem;
470
+ margin-bottom: 0.35rem;
471
+ }
472
+ }
473
+
428
474
  /*
429
475
  * Detail-page reading prose
430
476
  *
@@ -455,12 +501,8 @@ html {
455
501
  );
456
502
  --tw-prose-captions: var(--site-reading-caption);
457
503
  --tw-prose-kbd: var(--site-reading-heading);
458
- --tw-prose-code: var(--site-reading-heading);
459
- --tw-prose-pre-code: color-mix(
460
- in srgb,
461
- var(--site-reading-caption) 86%,
462
- var(--site-reading-body)
463
- );
504
+ --tw-prose-code: var(--site-code-text);
505
+ --tw-prose-pre-code: var(--site-code-text);
464
506
  --tw-prose-th-borders: color-mix(
465
507
  in srgb,
466
508
  var(--site-divider) 88%,
@@ -77,6 +77,32 @@ describe("Nav Items API Routes", () => {
77
77
  expect(body.label).toBe("GitHub");
78
78
  expect(body.url).toBe("https://github.com");
79
79
  expect(body.type).toBe("link");
80
+ expect(body.headerHtml).toBeUndefined();
81
+ });
82
+
83
+ it("includes a site header fragment for navigation editor requests", async () => {
84
+ const { app } = createTestApp({ authenticated: true });
85
+ app.route("/api/nav-items", navItemsApiRoutes);
86
+
87
+ const res = await app.request("/api/nav-items", {
88
+ method: "POST",
89
+ headers: {
90
+ "Content-Type": "application/json",
91
+ "X-Jant-Site-Header": "include",
92
+ },
93
+ body: JSON.stringify({
94
+ type: "link",
95
+ label: "Docs",
96
+ url: "/docs",
97
+ }),
98
+ });
99
+
100
+ expect(res.status).toBe(201);
101
+ const body = await res.json();
102
+ expect(body.label).toBe("Docs");
103
+ expect(body.headerHtml).toContain('data-site-header-fragment="header"');
104
+ expect(body.headerHtml).toContain('data-site-header-fragment="drawer"');
105
+ expect(body.headerHtml).toContain("Docs");
80
106
  });
81
107
 
82
108
  it("creates a system nav item when authenticated", async () => {
@@ -5,7 +5,6 @@
5
5
  import { Hono } from "hono";
6
6
  import type { Bindings } from "../../types.js";
7
7
  import type { AppVariables } from "../../types/app-context.js";
8
- import { getHomeDefaultViewFromNavItems } from "../../lib/navigation.js";
9
8
  import { requireAuthApi } from "../../middleware/auth.js";
10
9
  import { createExportService } from "../../services/export.js";
11
10
 
@@ -25,7 +24,6 @@ exportApiRoutes.post("/hugo", requireAuthApi(), async (c) => {
25
24
  siteDescription: appConfig.siteDescription,
26
25
  siteLanguage: appConfig.siteLanguage,
27
26
  showJantBrandingOnHome: appConfig.showJantBrandingOnHome,
28
- homeDefaultView: getHomeDefaultViewFromNavItems(navItems),
29
27
  mainRssFeed: appConfig.mainRssFeed,
30
28
  siteFooter: appConfig.siteFooter,
31
29
  showHeaderAvatar: appConfig.showHeaderAvatar,
@@ -136,7 +136,7 @@ describe("Internal upload admin routes", () => {
136
136
  expect(storage.files.has(row.tempStorageKey)).toBe(false);
137
137
  });
138
138
 
139
- it("deletes orphaned media past the grace window and keeps fresh orphans", async () => {
139
+ it("keeps finalized unattached media during upload cleanup", async () => {
140
140
  const storage = createMockStorage();
141
141
  const { app, services, sqlite } = createTestApp({
142
142
  authenticated: false,
@@ -187,28 +187,24 @@ describe("Internal upload admin routes", () => {
187
187
  await expect(res.json()).resolves.toEqual({
188
188
  abortedMultipartUploads: 0,
189
189
  deletedSessions: 0,
190
- deletedOrphanMedia: 1,
191
- // The reaped orphan's object is freshly enqueued (30 days out), so the
192
- // same sweep purges nothing yet.
190
+ deletedOrphanMedia: 0,
193
191
  purgedStorageObjects: 0,
194
192
  });
195
193
 
196
- // Old orphan: DB row gone, original key freed immediately, bytes moved to
197
- // a trash/ key recorded in storage_purge (recoverable within the window).
194
+ // Finalized media may be referenced from post body JSON without being a
195
+ // post attachment, so upload cleanup must not delete it from `postId IS
196
+ // NULL` alone.
198
197
  expect(
199
198
  sqlite.prepare("select id from media where id = ?").get(oldOrphan.id),
200
- ).toBeUndefined();
201
- expect(storage.files.has(oldStorageKey)).toBe(false);
202
- expect([...storage.files.keys()].some((k) => k.startsWith("trash/"))).toBe(
203
- true,
204
- );
199
+ ).toBeDefined();
200
+ expect(storage.files.has(oldStorageKey)).toBe(true);
205
201
  expect(
206
202
  sqlite
207
203
  .prepare("select id from storage_purge where original_key = ?")
208
204
  .get(oldStorageKey),
209
- ).toBeDefined();
205
+ ).toBeUndefined();
210
206
 
211
- // Fresh orphan: untouched.
207
+ // Fresh unattached media is also untouched.
212
208
  expect(
213
209
  sqlite.prepare("select id from media where id = ?").get(freshOrphan.id),
214
210
  ).toBeDefined();
@@ -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
  });
@@ -75,6 +75,7 @@ import {
75
75
  isSyncPending,
76
76
  markSyncPending,
77
77
  runBackgroundSync,
78
+ triggerGitHubSyncInline,
78
79
  } from "../../lib/github-sync-trigger.js";
79
80
  import { buildSyncSiteConfig } from "../../lib/github-sync-site-config.js";
80
81
  import {
@@ -117,6 +118,10 @@ function publicPath(c: Context<Env>, path: string): string {
117
118
  return toPublicPath(path, c.var.appConfig.sitePathPrefix);
118
119
  }
119
120
 
121
+ function aboutEditPath(c: Context<Env>): string {
122
+ return `${publicPath(c, "/about")}?edit=1`;
123
+ }
124
+
120
125
  /**
121
126
  * Breadcrumb labels for admin settings pages.
122
127
  *
@@ -184,7 +189,10 @@ function breadcrumbLabel(
184
189
  );
185
190
  case "account":
186
191
  return i18n._(
187
- msg({ message: "Account", comment: "@context: Breadcrumb label" }),
192
+ msg({
193
+ message: "Account & Data",
194
+ comment: "@context: Breadcrumb label",
195
+ }),
188
196
  );
189
197
  case "sessions":
190
198
  return i18n._(
@@ -302,7 +310,10 @@ settingsRoutes.get("/general", async (c) => {
302
310
  const dbSiteDescription = allSettings["SITE_DESCRIPTION"] ?? "";
303
311
 
304
312
  const saved = c.req.query("saved") !== undefined;
305
- const navData = await getNavigationData(c);
313
+ const [navData, aboutPage] = await Promise.all([
314
+ getNavigationData(c),
315
+ c.var.services.aboutPage.getStatus(),
316
+ ]);
306
317
  const siteUrlForDisplay =
307
318
  appConfig.siteUrl || new URL(publicPath(c, "/"), c.req.url).toString();
308
319
 
@@ -356,6 +367,9 @@ settingsRoutes.get("/general", async (c) => {
356
367
  noindex={appConfig.noindex}
357
368
  demoMode={appConfig.demoMode}
358
369
  timezones={TIMEZONES}
370
+ aboutPage={aboutPage}
371
+ aboutEditUrl={aboutEditPath(c)}
372
+ aboutCreateUrl={publicPath(c, "/settings/general/about-page")}
359
373
  />
360
374
  </>
361
375
  ),
@@ -534,6 +548,32 @@ settingsRoutes.post("/general/search", async (c) => {
534
548
  );
535
549
  });
536
550
 
551
+ settingsRoutes.post("/general/about-page", async (c) => {
552
+ try {
553
+ await c.var.services.aboutPage.ensurePage();
554
+ await triggerGitHubSyncInline(c);
555
+ } catch (error) {
556
+ if (error instanceof ValidationError) {
557
+ const wantsJson = c.req.header("accept")?.includes("application/json");
558
+ if (wantsJson) {
559
+ return c.json({ error: error.message, code: error.code }, 400);
560
+ }
561
+ return c.text(error.message, 400);
562
+ }
563
+ throw error;
564
+ }
565
+
566
+ const wantsJson = c.req.header("accept")?.includes("application/json");
567
+ if (wantsJson) {
568
+ return c.json({
569
+ status: "redirect" as const,
570
+ url: aboutEditPath(c),
571
+ });
572
+ }
573
+
574
+ return c.redirect(aboutEditPath(c));
575
+ });
576
+
537
577
  // ===========================================================================
538
578
  // Avatar
539
579
  // ===========================================================================
@@ -743,9 +783,13 @@ settingsRoutes.post("/avatar/display", async (c) => {
743
783
  // ===========================================================================
744
784
 
745
785
  settingsRoutes.get("/navigation", async (c) => {
746
- const [navItems, directoryData] = await Promise.all([
786
+ const [navItems, directoryData, suggestedLinks] = await Promise.all([
747
787
  c.var.services.navItems.list(),
748
788
  c.var.services.collections.listDirectoryData(),
789
+ c.var.services.navItems.listSuggestedLinks({
790
+ siteOrigin: c.var.appConfig.siteOrigin,
791
+ sitePathPrefix: c.var.appConfig.sitePathPrefix,
792
+ }),
749
793
  ]);
750
794
  const navData = await getNavigationData(c);
751
795
 
@@ -762,6 +806,7 @@ settingsRoutes.get("/navigation", async (c) => {
762
806
  <NavigationContent
763
807
  navItems={navItems}
764
808
  directoryData={directoryData}
809
+ suggestedLinks={suggestedLinks}
765
810
  mainRssFeed={c.var.appConfig.mainRssFeed}
766
811
  siteName={navData.siteName}
767
812
  sitePathPrefix={c.var.appConfig.sitePathPrefix}
@@ -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
  });
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createTestApp } from "../../../__tests__/helpers/app.js";
3
+ import { pageRoutes } from "../page.js";
4
+
5
+ function createPageTestApp(options: { authenticated?: boolean } = {}) {
6
+ const testApp = createTestApp(options);
7
+ const { app } = testApp;
8
+
9
+ app.use("*", async (c, next) => {
10
+ c.set("publicPath", c.req.path);
11
+ c.set("publicRequestUrl", c.req.url);
12
+ await next();
13
+ });
14
+
15
+ app.route("/", pageRoutes);
16
+
17
+ return testApp;
18
+ }
19
+
20
+ async function createDraftAbout(
21
+ services: ReturnType<typeof createTestApp>["services"],
22
+ ) {
23
+ return services.posts.create({
24
+ format: "note",
25
+ title: "About",
26
+ slug: "about",
27
+ bodyMarkdown: "Draft About body",
28
+ status: "draft",
29
+ visibility: "latest_hidden",
30
+ });
31
+ }
32
+
33
+ describe("draft About edit route", () => {
34
+ it("keeps draft About hidden from anonymous edit requests", async () => {
35
+ const { app, services } = createPageTestApp();
36
+ await createDraftAbout(services);
37
+
38
+ const res = await app.request("/about?edit=1");
39
+
40
+ expect(res.status).toBe(404);
41
+ });
42
+
43
+ it("keeps draft About hidden from normal authenticated page requests", async () => {
44
+ const { app, services } = createPageTestApp({ authenticated: true });
45
+ await createDraftAbout(services);
46
+
47
+ const res = await app.request("/about");
48
+
49
+ expect(res.status).toBe(404);
50
+ });
51
+
52
+ it("renders draft About for authenticated edit requests", async () => {
53
+ const { app, services } = createPageTestApp({ authenticated: true });
54
+ const post = await createDraftAbout(services);
55
+
56
+ const res = await app.request("/about?edit=1");
57
+
58
+ expect(res.status).toBe(200);
59
+ const html = await res.text();
60
+ expect(html).toContain(`data-post-id="${post.id}"`);
61
+ expect(html).toContain("data-authenticated");
62
+ expect(html).not.toContain('rel="canonical"');
63
+ expect(html).not.toContain("application/ld+json");
64
+ });
65
+ });
@@ -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({