@jant/core 0.6.15 → 0.6.16

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 (112) hide show
  1. package/bin/commands/import-site.js +7 -0
  2. package/dist/app-C75Zc8dD.js +6 -0
  3. package/dist/{app-C-oi_t8W.js → app-C7_4oab9.js} +480 -196
  4. package/dist/client/.vite/manifest.json +8 -8
  5. package/dist/client/_assets/chunks/{url-pLre2DM_.js → url-CU9pEbk5.js} +1 -1
  6. package/dist/client/_assets/{client-DxY5BFe8.js → client-Bq0vre8Y.js} +3 -3
  7. package/dist/client/_assets/client-D3VZyG4L.css +2 -0
  8. package/dist/client/_assets/{client-auth-CakPESv9.js → client-auth-F2rb9WfF.js} +1015 -977
  9. package/dist/{env-OHRKGcMj.js → env-BPYViDJJ.js} +1 -1
  10. package/dist/{export-CsFx6F_M.js → export-Dl5KCiEs.js} +93 -7
  11. package/dist/{github-api-BgSiE71w.js → github-api-BNF35Lkx.js} +1 -1
  12. package/dist/{github-app-BbklkFmU.js → github-app-DdiD-bC4.js} +1 -1
  13. package/dist/{github-sync-ppWXN3jb.js → github-sync-Bqg62IvV.js} +3 -3
  14. package/dist/{github-sync-Cq1pzzOI.js → github-sync-HdK2EgvJ.js} +3 -3
  15. package/dist/index.js +5 -5
  16. package/dist/node.js +6 -6
  17. package/dist/{url-BMYO-Zlt.js → url-CbLAtlZe.js} +517 -12
  18. package/package.json +1 -1
  19. package/src/__tests__/mise-config.test.ts +22 -0
  20. package/src/client/__tests__/compose-bridge.test.ts +252 -2
  21. package/src/client/__tests__/compose-shortcuts.test.ts +20 -0
  22. package/src/client/__tests__/toast.test.ts +65 -1
  23. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  24. package/src/client/components/__tests__/jant-command-palette.test.ts +51 -0
  25. package/src/client/components/__tests__/jant-compose-dialog.test.ts +2015 -318
  26. package/src/client/components/__tests__/jant-compose-editor.test.ts +53 -62
  27. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +35 -89
  28. package/src/client/components/compose-format-convert.ts +5 -1
  29. package/src/client/components/compose-types.ts +47 -3
  30. package/src/client/components/jant-command-palette.ts +12 -6
  31. package/src/client/components/jant-compose-dialog.ts +1464 -680
  32. package/src/client/components/jant-compose-editor.ts +225 -93
  33. package/src/client/components/jant-compose-fullscreen.ts +3 -5
  34. package/src/client/components/jant-post-menu.ts +91 -21
  35. package/src/client/compose-bridge.ts +143 -122
  36. package/src/client/compose-launch.ts +13 -0
  37. package/src/client/compose-shortcuts.ts +5 -1
  38. package/src/client/post-refresh.ts +135 -0
  39. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  40. package/src/client/tiptap/link-input-rules.ts +4 -4
  41. package/src/client/toast.ts +130 -38
  42. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  43. package/src/db/__tests__/thread-activity.test.ts +113 -0
  44. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  45. package/src/db/migrations/0031_many_ego.sql +3 -0
  46. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  47. package/src/db/migrations/meta/_journal.json +7 -0
  48. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  49. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  50. package/src/db/migrations/pg/meta/_journal.json +7 -0
  51. package/src/db/pg/schema.ts +18 -0
  52. package/src/db/schema.ts +20 -0
  53. package/src/db/thread-activity.ts +101 -0
  54. package/src/i18n/locales/public/en.po +89 -21
  55. package/src/i18n/locales/public/en.ts +1 -1
  56. package/src/i18n/locales/public/zh-Hans.po +88 -20
  57. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  58. package/src/i18n/locales/public/zh-Hant.po +88 -20
  59. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  60. package/src/lib/__tests__/feed.test.ts +168 -0
  61. package/src/lib/__tests__/markdown.test.ts +16 -0
  62. package/src/lib/__tests__/slug.test.ts +18 -0
  63. package/src/lib/__tests__/translit.test.ts +87 -0
  64. package/src/lib/__tests__/url.test.ts +53 -0
  65. package/src/lib/feed.ts +113 -11
  66. package/src/lib/hugo-markdown.ts +5 -0
  67. package/src/lib/link-preview.ts +25 -0
  68. package/src/lib/markdown-manager.ts +6 -1
  69. package/src/lib/post-display.ts +42 -9
  70. package/src/lib/timeline.ts +39 -9
  71. package/src/lib/tiptap-render.ts +4 -2
  72. package/src/lib/translit.ts +288 -0
  73. package/src/lib/url.ts +123 -12
  74. package/src/lib/view.ts +28 -0
  75. package/src/routes/api/__tests__/palette.test.ts +44 -0
  76. package/src/routes/api/posts.ts +13 -9
  77. package/src/routes/api/public/__tests__/posts.test.ts +38 -0
  78. package/src/routes/api/public/posts.ts +7 -0
  79. package/src/routes/compose.tsx +6 -2
  80. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  81. package/src/routes/pages/__tests__/archive-params.test.ts +292 -0
  82. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  83. package/src/routes/pages/__tests__/preview.test.ts +3 -1
  84. package/src/routes/pages/archive.tsx +65 -17
  85. package/src/routes/pages/page.tsx +5 -2
  86. package/src/services/__tests__/collection.test.ts +32 -1
  87. package/src/services/__tests__/post-timeline.test.ts +142 -11
  88. package/src/services/__tests__/post.test.ts +277 -0
  89. package/src/services/collection.ts +8 -10
  90. package/src/services/export-theme/assets/client-site.css +1 -1
  91. package/src/services/export.ts +3 -5
  92. package/src/services/path.ts +4 -1
  93. package/src/services/post.ts +297 -100
  94. package/src/services/search.ts +9 -0
  95. package/src/styles/components.css +0 -14
  96. package/src/styles/site-media.css +4 -4
  97. package/src/styles/tokens.css +62 -1
  98. package/src/styles/ui.css +1363 -1283
  99. package/src/types/entities.ts +5 -0
  100. package/src/types/props.ts +12 -0
  101. package/src/types/views.ts +6 -0
  102. package/src/ui/compose/ComposeDialog.tsx +17 -17
  103. package/src/ui/feed/LinkPreview.tsx +2 -7
  104. package/src/ui/feed/PostStatusBadges.tsx +42 -3
  105. package/src/ui/pages/ArchivePage.tsx +131 -24
  106. package/src/ui/pages/__tests__/ArchivePage.test.tsx +63 -0
  107. package/src/ui/shared/DraftPreviewBar.tsx +25 -9
  108. package/src/ui/shared/PostFooter.tsx +35 -12
  109. package/src/ui/shared/custom-icons.ts +5 -0
  110. package/src/ui/shared/post-article-attributes.ts +6 -0
  111. package/dist/app-BxCOR3Uc.js +0 -6
  112. package/dist/client/_assets/client-B_eGKN5p.css +0 -2
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { describe, expect, it, vi } from "vitest";
2
2
  import { Hono } from "hono";
3
3
  import type { Bindings } from "../../../types.js";
4
4
  import type { AppVariables } from "../../../types/app-context.js";
@@ -154,6 +154,73 @@ describe("Sitemap Routes", () => {
154
154
  expect(indices).toEqual([...indices].sort((a, b) => a - b));
155
155
  });
156
156
 
157
+ // A Thread root's page renders its replies, so anything that changes a
158
+ // reply changes the page. Unlike ordering, edits count for <lastmod> —
159
+ // the page really did change.
160
+ it("moves <lastmod> when a reply is added", async () => {
161
+ const { app, services } = createSitemapTestApp();
162
+ const readLastmod = async () => {
163
+ const xml = await (await app.request("/sitemap-posts-1.xml")).text();
164
+ return /<lastmod>([^<]+)<\/lastmod>/.exec(xml)?.[1];
165
+ };
166
+
167
+ vi.useFakeTimers();
168
+ try {
169
+ // lastmod is a date, so the two writes need to land on different days.
170
+ vi.setSystemTime(new Date("2026-03-01T00:00:00Z"));
171
+ const root = await services.posts.create({
172
+ format: "note",
173
+ bodyMarkdown: "root",
174
+ title: "Threaded",
175
+ status: "published",
176
+ });
177
+ expect(await readLastmod()).toBe("2026-03-01");
178
+
179
+ vi.setSystemTime(new Date("2026-03-05T00:00:00Z"));
180
+ await services.posts.create({
181
+ format: "note",
182
+ bodyMarkdown: "reply",
183
+ replyToId: root.id,
184
+ status: "published",
185
+ });
186
+ expect(await readLastmod()).toBe("2026-03-05");
187
+ } finally {
188
+ vi.useRealTimers();
189
+ }
190
+ });
191
+
192
+ it("ignores a draft reply in <lastmod>", async () => {
193
+ const { app, services } = createSitemapTestApp();
194
+ const readLastmod = async () => {
195
+ const xml = await (await app.request("/sitemap-posts-1.xml")).text();
196
+ return /<lastmod>([^<]+)<\/lastmod>/.exec(xml)?.[1];
197
+ };
198
+
199
+ vi.useFakeTimers();
200
+ try {
201
+ vi.setSystemTime(new Date("2026-03-01T00:00:00Z"));
202
+ const root = await services.posts.create({
203
+ format: "note",
204
+ bodyMarkdown: "root",
205
+ title: "Threaded",
206
+ status: "published",
207
+ });
208
+
209
+ vi.setSystemTime(new Date("2026-03-05T00:00:00Z"));
210
+ await services.posts.create({
211
+ format: "note",
212
+ bodyMarkdown: "unpublished reply",
213
+ replyToId: root.id,
214
+ status: "draft",
215
+ });
216
+
217
+ // Not on the page, so it must not claim the page changed.
218
+ expect(await readLastmod()).toBe("2026-03-01");
219
+ } finally {
220
+ vi.useRealTimers();
221
+ }
222
+ });
223
+
157
224
  it("excludes private posts, replies, and drafts", async () => {
158
225
  const { app, services } = createSitemapTestApp();
159
226
  const root = await services.posts.create({
@@ -116,10 +116,23 @@ describe("archive feed filter params", () => {
116
116
  format: "note",
117
117
  bodyMarkdown: "text only body",
118
118
  });
119
+ await services.media.create({
120
+ filename: "unattached.jpg",
121
+ originalName: "unattached.jpg",
122
+ mimeType: "image/jpeg",
123
+ size: 1024,
124
+ storageKey: "media/unattached.jpg",
125
+ });
119
126
 
120
127
  const fresh = await fetchFeed(app, "?media=none");
121
128
  expect(fresh).toContain("text only body");
122
129
 
130
+ const page = await app.request("/archive?media=none");
131
+ expect(page.status).toBe(200);
132
+ const pageHtml = await page.text();
133
+ expect(pageHtml).toContain('aria-label="1 thread"');
134
+ expect(pageHtml).toContain("text only body");
135
+
123
136
  const legacy = await fetchFeed(app, "?hasMedia=0");
124
137
  expect(legacy).toContain("text only body");
125
138
 
@@ -128,6 +141,134 @@ describe("archive feed filter params", () => {
128
141
  });
129
142
  });
130
143
 
144
+ describe("archive feed ordering", () => {
145
+ // The feed belongs to the archive page, so it uses the page's default axis.
146
+ // Ordering by activity would let a reply to an old Thread reshuffle the
147
+ // feed window under a fixed rssFeedLimit, and readers key entries by id
148
+ // anyway — a moved entry is not a re-surfaced one. /latest/feed is where
149
+ // activity ordering lives.
150
+ it("orders by publication, not by recent replies", async () => {
151
+ const { app, services } = setupApp();
152
+ const oldThread = await services.posts.create({
153
+ format: "note",
154
+ title: "Old thread with a new reply",
155
+ bodyMarkdown: "root from 2019",
156
+ publishedAt: Date.UTC(2019, 0, 1) / 1000,
157
+ });
158
+ await services.posts.create({
159
+ format: "note",
160
+ bodyMarkdown: "reply from 2026",
161
+ replyToId: oldThread.id,
162
+ publishedAt: Date.UTC(2026, 5, 1) / 1000,
163
+ });
164
+ await services.posts.create({
165
+ format: "note",
166
+ title: "Plain newer post",
167
+ bodyMarkdown: "root from 2025",
168
+ publishedAt: Date.UTC(2025, 0, 1) / 1000,
169
+ });
170
+
171
+ const xml = await fetchFeed(app, "");
172
+
173
+ expect(xml.indexOf("Plain newer post")).toBeLessThan(
174
+ xml.indexOf("Old thread with a new reply"),
175
+ );
176
+ });
177
+
178
+ it("honors an explicit sort=updated, quiet replies included", async () => {
179
+ const { app, services } = setupApp();
180
+ const oldThread = await services.posts.create({
181
+ format: "note",
182
+ title: "Old thread with a new reply",
183
+ bodyMarkdown: "root from 2019",
184
+ publishedAt: Date.UTC(2019, 0, 1) / 1000,
185
+ });
186
+ await services.posts.create({
187
+ format: "note",
188
+ bodyMarkdown: "quiet addition",
189
+ replyToId: oldThread.id,
190
+ publishedAt: Date.UTC(2026, 5, 1) / 1000,
191
+ quietReply: true,
192
+ });
193
+ await services.posts.create({
194
+ format: "note",
195
+ title: "Plain newer post",
196
+ bodyMarkdown: "root from 2025",
197
+ publishedAt: Date.UTC(2025, 0, 1) / 1000,
198
+ });
199
+
200
+ const updated = await fetchFeed(app, "?sort=updated");
201
+ expect(updated.indexOf("Old thread with a new reply")).toBeLessThan(
202
+ updated.indexOf("Plain newer post"),
203
+ );
204
+
205
+ // Same URL param, same axis as the page — the default stays chronological.
206
+ const chronological = await fetchFeed(app, "");
207
+ expect(chronological.indexOf("Plain newer post")).toBeLessThan(
208
+ chronological.indexOf("Old thread with a new reply"),
209
+ );
210
+ });
211
+
212
+ it("scopes a year filter to the axis the feed is sorted on", async () => {
213
+ const { app, services } = setupApp();
214
+ const oldThread = await services.posts.create({
215
+ format: "note",
216
+ title: "Extended in 2026",
217
+ bodyMarkdown: "root from 2019",
218
+ publishedAt: Date.UTC(2019, 0, 1) / 1000,
219
+ });
220
+ await services.posts.create({
221
+ format: "note",
222
+ bodyMarkdown: "addition",
223
+ replyToId: oldThread.id,
224
+ publishedAt: Date.UTC(2026, 5, 1) / 1000,
225
+ });
226
+ await services.posts.create({
227
+ format: "note",
228
+ title: "Published in 2025",
229
+ bodyMarkdown: "root from 2025",
230
+ publishedAt: Date.UTC(2025, 0, 1) / 1000,
231
+ });
232
+
233
+ const byActivity = await fetchFeed(app, "?sort=updated&year=2026");
234
+ expect(byActivity).toContain("Extended in 2026");
235
+ expect(byActivity).not.toContain("Published in 2025");
236
+
237
+ const byPublication = await fetchFeed(app, "?year=2025");
238
+ expect(byPublication).toContain("Published in 2025");
239
+ expect(byPublication).not.toContain("Extended in 2026");
240
+ });
241
+
242
+ it("keeps the same order after an old thread gains a reply", async () => {
243
+ const { app, services } = setupApp();
244
+ const first = await services.posts.create({
245
+ format: "note",
246
+ title: "Alpha",
247
+ bodyMarkdown: "alpha root",
248
+ publishedAt: Date.UTC(2020, 0, 1) / 1000,
249
+ });
250
+ await services.posts.create({
251
+ format: "note",
252
+ title: "Beta",
253
+ bodyMarkdown: "beta root",
254
+ publishedAt: Date.UTC(2021, 0, 1) / 1000,
255
+ });
256
+
257
+ const before = await fetchFeed(app, "");
258
+ const orderBefore = before.indexOf("Beta") < before.indexOf("Alpha");
259
+
260
+ await services.posts.create({
261
+ format: "note",
262
+ bodyMarkdown: "late addition",
263
+ replyToId: first.id,
264
+ publishedAt: Date.UTC(2026, 0, 1) / 1000,
265
+ });
266
+
267
+ const after = await fetchFeed(app, "");
268
+ expect(after.indexOf("Beta") < after.indexOf("Alpha")).toBe(orderBefore);
269
+ });
270
+ });
271
+
131
272
  describe("archive page legacy param redirect", () => {
132
273
  it("redirects legacy boolean params to their single-word spelling", async () => {
133
274
  const { app } = setupApp();
@@ -222,3 +363,154 @@ describe("archive page ordering", () => {
222
363
  );
223
364
  });
224
365
  });
366
+
367
+ describe("archive page sort=updated", () => {
368
+ /**
369
+ * An old thread that recently gained a reply, plus a younger standalone
370
+ * thread. The two axes disagree about their order, which is the whole point
371
+ * of the toggle.
372
+ */
373
+ async function seedDivergingThreads(services: {
374
+ posts: {
375
+ create: (data: Record<string, unknown>) => Promise<{ id: string }>;
376
+ };
377
+ }) {
378
+ const olderThread = await services.posts.create({
379
+ format: "note",
380
+ title: "Growing thread",
381
+ bodyMarkdown: "root from 2019",
382
+ publishedAt: Date.UTC(2019, 2, 5) / 1000,
383
+ });
384
+ await services.posts.create({
385
+ format: "note",
386
+ bodyMarkdown: "reply from 2026",
387
+ replyToId: olderThread.id,
388
+ publishedAt: Date.UTC(2026, 7, 1) / 1000,
389
+ });
390
+ await services.posts.create({
391
+ format: "note",
392
+ title: "Untouched newer thread",
393
+ bodyMarkdown: "root from 2025",
394
+ publishedAt: Date.UTC(2025, 0, 1) / 1000,
395
+ });
396
+ }
397
+
398
+ it("puts recently extended threads first", async () => {
399
+ const { app, services } = setupApp();
400
+ await seedDivergingThreads(services);
401
+
402
+ const res = await app.request("/archive?sort=updated");
403
+ expect(res.status).toBe(200);
404
+ const html = await res.text();
405
+
406
+ expect(html.indexOf("Growing thread")).toBeLessThan(
407
+ html.indexOf("Untouched newer thread"),
408
+ );
409
+ });
410
+
411
+ it("buckets months on the same axis it sorts by", async () => {
412
+ const { app, services } = setupApp();
413
+ await seedDivergingThreads(services);
414
+
415
+ const byPublished = await (await app.request("/archive")).text();
416
+ expect(byPublished).toContain("March 2019");
417
+ expect(byPublished).not.toContain("August 2026");
418
+
419
+ const byActivity = await (
420
+ await app.request("/archive?sort=updated")
421
+ ).text();
422
+ expect(byActivity).toContain("August 2026");
423
+ expect(byActivity).not.toContain("March 2019");
424
+ });
425
+
426
+ it("scopes the year filter to the active axis", async () => {
427
+ const { app, services } = setupApp();
428
+ await seedDivergingThreads(services);
429
+
430
+ const activityYear = await (
431
+ await app.request("/archive?sort=updated&year=2026")
432
+ ).text();
433
+ expect(activityYear).toContain("Growing thread");
434
+ expect(activityYear).not.toContain("Untouched newer thread");
435
+
436
+ const publishedYear = await (
437
+ await app.request("/archive?year=2026")
438
+ ).text();
439
+ expect(publishedYear).not.toContain("Growing thread");
440
+ });
441
+
442
+ it("keeps the default axis unchanged without the param", async () => {
443
+ const { app, services } = setupApp();
444
+ await seedDivergingThreads(services);
445
+
446
+ const html = await (await app.request("/archive")).text();
447
+ expect(html.indexOf("Untouched newer thread")).toBeLessThan(
448
+ html.indexOf("Growing thread"),
449
+ );
450
+ });
451
+
452
+ it("distinguishes the updated view in the document title", async () => {
453
+ const { app, services } = setupApp();
454
+ await seedDivergingThreads(services);
455
+
456
+ const byPublished = await (await app.request("/archive")).text();
457
+ expect(byPublished).toContain("<title>Archive - ");
458
+ expect(byPublished).not.toContain("Recently updated");
459
+
460
+ const byActivity = await (
461
+ await app.request("/archive?sort=updated")
462
+ ).text();
463
+ expect(byActivity).toContain("<title>Archive - Recently updated - ");
464
+ });
465
+
466
+ it("ignores an unknown sort value", async () => {
467
+ const { app, services } = setupApp();
468
+ await seedDivergingThreads(services);
469
+
470
+ const html = await (await app.request("/archive?sort=nonsense")).text();
471
+ expect(html.indexOf("Untouched newer thread")).toBeLessThan(
472
+ html.indexOf("Growing thread"),
473
+ );
474
+ });
475
+
476
+ // The quiet-reply switch promises "won't move the thread to the top of
477
+ // latest". The archive is not Latest — it is the canonical all-posts view,
478
+ // and it already shows Hidden-from-Latest content. So its updated sort
479
+ // reports when the Thread actually changed.
480
+ it("surfaces quietly extended threads even though Latest does not", async () => {
481
+ const { app, services } = setupApp();
482
+ const quietThread = await services.posts.create({
483
+ format: "note",
484
+ title: "Quietly extended thread",
485
+ bodyMarkdown: "root from 2019",
486
+ publishedAt: Date.UTC(2019, 2, 5) / 1000,
487
+ });
488
+ await services.posts.create({
489
+ format: "note",
490
+ bodyMarkdown: "quiet addendum",
491
+ replyToId: quietThread.id,
492
+ publishedAt: Date.UTC(2026, 7, 1) / 1000,
493
+ quietReply: true,
494
+ });
495
+ await services.posts.create({
496
+ format: "note",
497
+ title: "Untouched newer thread",
498
+ bodyMarkdown: "root from 2025",
499
+ publishedAt: Date.UTC(2025, 0, 1) / 1000,
500
+ });
501
+
502
+ const html = await (await app.request("/archive?sort=updated")).text();
503
+ expect(html.indexOf("Quietly extended thread")).toBeLessThan(
504
+ html.indexOf("Untouched newer thread"),
505
+ );
506
+ expect(html).toContain("August 2026");
507
+
508
+ // Latest still honors the quiet flag.
509
+ const roots = await services.posts.list({
510
+ status: "published",
511
+ excludeReplies: true,
512
+ excludeLatestHidden: true,
513
+ });
514
+ expect(roots[0]?.title).toBe("Untouched newer thread");
515
+ });
516
+ });
@@ -39,12 +39,16 @@ describe("Featured Page - Data Logic", () => {
39
39
  bodyMarkdown: "Normal post",
40
40
  status: "published",
41
41
  });
42
- await postService.create({
42
+ // Featuring an unpublished post is refused outright, so the only way a
43
+ // draft carries the flag is by being unpublished after the fact — the
44
+ // case this filter still has to catch.
45
+ const unpublished = await postService.create({
43
46
  format: "note",
44
47
  bodyMarkdown: "Draft featured",
45
48
  featured: true,
46
- status: "draft",
49
+ status: "published",
47
50
  });
51
+ await postService.update(unpublished.id, { status: "draft" });
48
52
 
49
53
  const posts = await postService.list({
50
54
  featured: true,
@@ -51,11 +51,13 @@ describe("draft preview route", () => {
51
51
  expect(html).toContain("data-preview-status");
52
52
  expect(html).toContain("Draft preview");
53
53
  expect(html).toContain("This post isn’t published.");
54
+ expect(html).toContain("Edit draft");
55
+ expect(html).toContain(`/preview/${draft.slug}?edit=1`);
54
56
  expect(html).toContain('<meta name="robots" content="noindex, nofollow"');
55
57
  expect(html).not.toContain('rel="canonical"');
56
58
  expect(html).not.toContain("application/ld+json");
57
59
  expect(html).not.toContain("article:published_time");
58
- expect(html).not.toContain('id="compose-dialog"');
60
+ expect(html).toContain('id="compose-dialog"');
59
61
  });
60
62
 
61
63
  it("renders every draft in a saved thread", async () => {
@@ -21,6 +21,7 @@ import type {
21
21
  import type { AppVariables } from "../../types/app-context.js";
22
22
  import type {
23
23
  ArchiveFilters,
24
+ ArchiveSort,
24
25
  ArchiveView,
25
26
  ArchiveVisibility,
26
27
  } from "../../types/props.js";
@@ -66,6 +67,7 @@ interface ParsedArchiveParams {
66
67
  visibility?: ArchiveVisibility;
67
68
  visibilityAll: boolean;
68
69
  view?: ArchiveView;
70
+ sort: ArchiveSort;
69
71
  currentPage: number;
70
72
  }
71
73
 
@@ -144,6 +146,11 @@ function parseArchiveParams(
144
146
  ? viewParam
145
147
  : undefined;
146
148
 
149
+ // sort selects the time axis for ordering, month grouping, and the year
150
+ // filter alike — they must stay on the same column or the month headers
151
+ // stop agreeing with the order inside them.
152
+ const sort: ArchiveSort = q("sort") === "updated" ? "updated" : "published";
153
+
147
154
  // Page always comes from the actual request URL (pagination links use ?page=N)
148
155
  const pageParam = c.req.query("page");
149
156
  const currentPage = Math.max(1, parseInt(pageParam || "1", 10) || 1);
@@ -159,6 +166,7 @@ function parseArchiveParams(
159
166
  visibility,
160
167
  visibilityAll,
161
168
  view,
169
+ sort,
162
170
  currentPage,
163
171
  };
164
172
  }
@@ -187,12 +195,15 @@ function buildArchivePostFilters(
187
195
  : (params.visibility ?? undefined)
188
196
  : undefined;
189
197
 
190
- let publishedAfter: number | undefined;
191
- let publishedBefore: number | undefined;
192
- if (params.validYear) {
193
- publishedAfter = Date.UTC(params.validYear, 0, 1) / 1000;
194
- publishedBefore = Date.UTC(params.validYear + 1, 0, 1) / 1000;
195
- }
198
+ // The year filter follows the active axis, so every month bucket shown
199
+ // under `year=N` really belongs to that year.
200
+ const yearRange = params.validYear
201
+ ? {
202
+ after: Date.UTC(params.validYear, 0, 1) / 1000,
203
+ before: Date.UTC(params.validYear + 1, 0, 1) / 1000,
204
+ }
205
+ : undefined;
206
+ const sortsByActivity = params.sort === "updated";
196
207
 
197
208
  return {
198
209
  format: params.format,
@@ -206,20 +217,28 @@ function buildArchivePostFilters(
206
217
  ? { visibility: effectiveVisibility }
207
218
  : {}),
208
219
  collectionId,
209
- publishedAfter,
210
- publishedBefore,
220
+ ...(yearRange
221
+ ? sortsByActivity
222
+ ? { axisAfter: yearRange.after, axisBefore: yearRange.before }
223
+ : { publishedAfter: yearRange.after, publishedBefore: yearRange.before }
224
+ : {}),
211
225
  mediaKinds: params.mediaKinds,
212
226
  hasMedia: params.hasMedia,
213
227
  hasTitle: params.hasTitle,
214
228
  hasReplies: params.hasReplies,
215
- sortBy: "published",
229
+ // "thread_updated", not "activity": the archive is the canonical all-posts
230
+ // view, and the quiet-reply switch only promises not to move a Thread on
231
+ // Latest. Here the honest answer is when the Thread last changed.
232
+ sortBy: sortsByActivity ? "thread_updated" : "published",
216
233
  ignorePinnedSort: true,
217
234
  };
218
235
  }
219
236
 
220
237
  /**
221
238
  * Build a query string from parsed archive params (for feed self-URL and
222
- * archive page feed link). Omits page-only params (view, page).
239
+ * archive page feed link). Omits view and page — those shape the rendered
240
+ * page, not the result set — but carries `sort`, so the feed button on an
241
+ * updated-sorted page hands back the matching feed.
223
242
  */
224
243
  function buildArchiveFeedQuery(params: ParsedArchiveParams): string {
225
244
  const qs = new URLSearchParams();
@@ -237,6 +256,7 @@ function buildArchiveFeedQuery(params: ParsedArchiveParams): string {
237
256
  if (params.hasReplies !== undefined) {
238
257
  qs.set("replies", params.hasReplies ? "any" : "none");
239
258
  }
259
+ if (params.sort === "updated") qs.set("sort", "updated");
240
260
  const str = qs.toString();
241
261
  return str ? `?${str}` : "";
242
262
  }
@@ -338,6 +358,7 @@ export async function renderArchivePage(
338
358
  services.posts.getDistinctYears({
339
359
  status: "published",
340
360
  excludeReplies: true,
361
+ sortBy: filters.sortBy,
341
362
  }),
342
363
  services.collections.list(),
343
364
  ]);
@@ -365,8 +386,12 @@ export async function renderArchivePage(
365
386
 
366
387
  const grouped = new Map<string, PostWithMedia[]>();
367
388
  for (const post of posts) {
368
- const publishedAt = post.publishedAt ?? post.updatedAt;
369
- const key = formatYearMonth(publishedAt, appConfig.timeZone);
389
+ // Bucket on the same axis the query sorted by, so groups stay ordered.
390
+ const groupedAt =
391
+ params.sort === "updated"
392
+ ? post.threadUpdatedAt
393
+ : (post.publishedAt ?? post.updatedAt);
394
+ const key = formatYearMonth(groupedAt, appConfig.timeZone);
370
395
  if (!grouped.has(key)) {
371
396
  grouped.set(key, []);
372
397
  }
@@ -438,6 +463,7 @@ export async function renderArchivePage(
438
463
  hasReplies: params.hasReplies,
439
464
  visibility: effectiveVisibility,
440
465
  view: params.view,
466
+ sort: params.sort === "updated" ? "updated" : undefined,
441
467
  };
442
468
 
443
469
  const feedQuery = buildArchiveFeedQuery(params);
@@ -448,7 +474,12 @@ export async function renderArchivePage(
448
474
  }));
449
475
 
450
476
  return renderPublicPage(c, {
451
- title: buildPageTitle("Archive", navData.siteName),
477
+ // Distinguishes a bookmarked or shared ?sort=updated view in the tab bar.
478
+ title: buildPageTitle(
479
+ "Archive",
480
+ params.sort === "updated" ? "Recently updated" : undefined,
481
+ navData.siteName,
482
+ ),
452
483
  navData,
453
484
  content: (
454
485
  <ArchivePage
@@ -634,6 +665,17 @@ async function buildArchiveFeedData(
634
665
  // Feed mirrors the unauthenticated archive page: published + non-private,
635
666
  // including Hidden-from-Latest. /archive is the canonical "all posts" view,
636
667
  // so its feed must match.
668
+ //
669
+ // Ordered by publication by default, like the page it belongs to. Ordering
670
+ // by activity would make the feed's contents shift under a fixed
671
+ // `rssFeedLimit` — a new reply pulls an old Thread back into the window and
672
+ // pushes something else out — and it buys nothing in return, because readers
673
+ // key entries by id and will not re-surface one that merely moved.
674
+ // /latest/feed is the activity feed; this one is the chronological record.
675
+ //
676
+ // `?sort=updated` opts into the same axis the page uses, for a subscriber
677
+ // who wants that trade-off deliberately.
678
+ const sortsByActivity = params.sort === "updated";
637
679
  const filters: PostFilters = {
638
680
  format: params.format,
639
681
  status: "published",
@@ -645,14 +687,20 @@ async function buildArchiveFeedData(
645
687
  hasMedia: params.hasMedia,
646
688
  hasTitle: params.hasTitle,
647
689
  hasReplies: params.hasReplies,
690
+ sortBy: sortsByActivity ? "thread_updated" : "published",
648
691
  ignorePinnedSort: true,
692
+ // The year filter follows the active axis; the RSS delay always stays on
693
+ // publication, so a just-published post is never announced early.
649
694
  ...(params.validYear
650
- ? {
651
- publishedAfter: Date.UTC(params.validYear, 0, 1) / 1000,
652
- }
695
+ ? sortsByActivity
696
+ ? {
697
+ axisAfter: Date.UTC(params.validYear, 0, 1) / 1000,
698
+ axisBefore: yearPublishedBefore,
699
+ }
700
+ : { publishedAfter: Date.UTC(params.validYear, 0, 1) / 1000 }
653
701
  : {}),
654
702
  publishedBefore:
655
- yearPublishedBefore === undefined
703
+ yearPublishedBefore === undefined || sortsByActivity
656
704
  ? rssPublishedBefore
657
705
  : Math.min(yearPublishedBefore, rssPublishedBefore),
658
706
  limit: appConfig.rssFeedLimit,
@@ -260,6 +260,10 @@ async function renderPost(
260
260
  display.threadPostViews,
261
261
  c.var.appConfig.siteUrl,
262
262
  );
263
+ const draftEditHref = `${toPublicPath(
264
+ `/preview/${post.slug}`,
265
+ c.var.appConfig.sitePathPrefix,
266
+ )}?edit=1`;
263
267
 
264
268
  return renderPublicPage(c, {
265
269
  title: previewTitle
@@ -288,8 +292,7 @@ async function renderPost(
288
292
  navData,
289
293
  ...(options.isPreview
290
294
  ? {
291
- pageChrome: <DraftPreviewBar />,
292
- showComposeDialog: false,
295
+ pageChrome: <DraftPreviewBar editHref={draftEditHref} />,
293
296
  noindex: true,
294
297
  }
295
298
  : {}),
@@ -309,7 +309,10 @@ describe("CollectionService", () => {
309
309
  ]);
310
310
  });
311
311
 
312
- it("uses a later Root edit as the Thread's recent activity", async () => {
312
+ // The directory's "recent activity" uses the same definition as every
313
+ // other surface: the Thread gained a post. Editing one is not activity,
314
+ // or fixing a typo would reshuffle the whole collection directory.
315
+ it("does not treat a later Root edit as recent activity", async () => {
313
316
  vi.useFakeTimers();
314
317
  try {
315
318
  vi.setSystemTime(new Date("2024-01-01T00:00:00Z"));
@@ -326,6 +329,34 @@ describe("CollectionService", () => {
326
329
  vi.setSystemTime(new Date("2024-01-01T00:01:00Z"));
327
330
  await postService.update(root.id, { bodyMarkdown: "after" });
328
331
 
332
+ const directory = await collectionService.listDirectoryData();
333
+ expect(directory.collections[0]?.recentActivityAt).toBe(1704067200);
334
+ } finally {
335
+ vi.useRealTimers();
336
+ }
337
+ });
338
+
339
+ it("treats a new reply as recent activity", async () => {
340
+ vi.useFakeTimers();
341
+ try {
342
+ vi.setSystemTime(new Date("2024-01-01T00:00:00Z"));
343
+ const collection = await collectionService.create({
344
+ slug: "growing-thread",
345
+ title: "Growing Thread",
346
+ });
347
+ const root = await postService.create({
348
+ format: "note",
349
+ bodyMarkdown: "root",
350
+ });
351
+ await collectionService.addThread(collection.id, root.id);
352
+
353
+ vi.setSystemTime(new Date("2024-01-01T00:01:00Z"));
354
+ await postService.create({
355
+ format: "note",
356
+ bodyMarkdown: "reply",
357
+ replyToId: root.id,
358
+ });
359
+
329
360
  const directory = await collectionService.listDirectoryData();
330
361
  expect(directory.collections[0]?.recentActivityAt).toBe(1704067260);
331
362
  } finally {