@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.
- package/bin/commands/import-site.js +7 -0
- package/dist/app-C75Zc8dD.js +6 -0
- package/dist/{app-C-oi_t8W.js → app-C7_4oab9.js} +480 -196
- package/dist/client/.vite/manifest.json +8 -8
- package/dist/client/_assets/chunks/{url-pLre2DM_.js → url-CU9pEbk5.js} +1 -1
- package/dist/client/_assets/{client-DxY5BFe8.js → client-Bq0vre8Y.js} +3 -3
- package/dist/client/_assets/client-D3VZyG4L.css +2 -0
- package/dist/client/_assets/{client-auth-CakPESv9.js → client-auth-F2rb9WfF.js} +1015 -977
- package/dist/{env-OHRKGcMj.js → env-BPYViDJJ.js} +1 -1
- package/dist/{export-CsFx6F_M.js → export-Dl5KCiEs.js} +93 -7
- package/dist/{github-api-BgSiE71w.js → github-api-BNF35Lkx.js} +1 -1
- package/dist/{github-app-BbklkFmU.js → github-app-DdiD-bC4.js} +1 -1
- package/dist/{github-sync-ppWXN3jb.js → github-sync-Bqg62IvV.js} +3 -3
- package/dist/{github-sync-Cq1pzzOI.js → github-sync-HdK2EgvJ.js} +3 -3
- package/dist/index.js +5 -5
- package/dist/node.js +6 -6
- package/dist/{url-BMYO-Zlt.js → url-CbLAtlZe.js} +517 -12
- package/package.json +1 -1
- package/src/__tests__/mise-config.test.ts +22 -0
- package/src/client/__tests__/compose-bridge.test.ts +252 -2
- package/src/client/__tests__/compose-shortcuts.test.ts +20 -0
- package/src/client/__tests__/toast.test.ts +65 -1
- package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
- package/src/client/components/__tests__/jant-command-palette.test.ts +51 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +2015 -318
- package/src/client/components/__tests__/jant-compose-editor.test.ts +53 -62
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +35 -89
- package/src/client/components/compose-format-convert.ts +5 -1
- package/src/client/components/compose-types.ts +47 -3
- package/src/client/components/jant-command-palette.ts +12 -6
- package/src/client/components/jant-compose-dialog.ts +1464 -680
- package/src/client/components/jant-compose-editor.ts +225 -93
- package/src/client/components/jant-compose-fullscreen.ts +3 -5
- package/src/client/components/jant-post-menu.ts +91 -21
- package/src/client/compose-bridge.ts +143 -122
- package/src/client/compose-launch.ts +13 -0
- package/src/client/compose-shortcuts.ts +5 -1
- package/src/client/post-refresh.ts +135 -0
- package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
- package/src/client/tiptap/link-input-rules.ts +4 -4
- package/src/client/toast.ts +130 -38
- package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
- package/src/db/__tests__/thread-activity.test.ts +113 -0
- package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
- package/src/db/migrations/0031_many_ego.sql +3 -0
- package/src/db/migrations/meta/0031_snapshot.json +2562 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
- package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
- package/src/db/migrations/pg/meta/_journal.json +7 -0
- package/src/db/pg/schema.ts +18 -0
- package/src/db/schema.ts +20 -0
- package/src/db/thread-activity.ts +101 -0
- package/src/i18n/locales/public/en.po +89 -21
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +88 -20
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +88 -20
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/lib/__tests__/feed.test.ts +168 -0
- package/src/lib/__tests__/markdown.test.ts +16 -0
- package/src/lib/__tests__/slug.test.ts +18 -0
- package/src/lib/__tests__/translit.test.ts +87 -0
- package/src/lib/__tests__/url.test.ts +53 -0
- package/src/lib/feed.ts +113 -11
- package/src/lib/hugo-markdown.ts +5 -0
- package/src/lib/link-preview.ts +25 -0
- package/src/lib/markdown-manager.ts +6 -1
- package/src/lib/post-display.ts +42 -9
- package/src/lib/timeline.ts +39 -9
- package/src/lib/tiptap-render.ts +4 -2
- package/src/lib/translit.ts +288 -0
- package/src/lib/url.ts +123 -12
- package/src/lib/view.ts +28 -0
- package/src/routes/api/__tests__/palette.test.ts +44 -0
- package/src/routes/api/posts.ts +13 -9
- package/src/routes/api/public/__tests__/posts.test.ts +38 -0
- package/src/routes/api/public/posts.ts +7 -0
- package/src/routes/compose.tsx +6 -2
- package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
- package/src/routes/pages/__tests__/archive-params.test.ts +292 -0
- package/src/routes/pages/__tests__/featured.test.ts +6 -2
- package/src/routes/pages/__tests__/preview.test.ts +3 -1
- package/src/routes/pages/archive.tsx +65 -17
- package/src/routes/pages/page.tsx +5 -2
- package/src/services/__tests__/collection.test.ts +32 -1
- package/src/services/__tests__/post-timeline.test.ts +142 -11
- package/src/services/__tests__/post.test.ts +277 -0
- package/src/services/collection.ts +8 -10
- package/src/services/export-theme/assets/client-site.css +1 -1
- package/src/services/export.ts +3 -5
- package/src/services/path.ts +4 -1
- package/src/services/post.ts +297 -100
- package/src/services/search.ts +9 -0
- package/src/styles/components.css +0 -14
- package/src/styles/site-media.css +4 -4
- package/src/styles/tokens.css +62 -1
- package/src/styles/ui.css +1363 -1283
- package/src/types/entities.ts +5 -0
- package/src/types/props.ts +12 -0
- package/src/types/views.ts +6 -0
- package/src/ui/compose/ComposeDialog.tsx +17 -17
- package/src/ui/feed/LinkPreview.tsx +2 -7
- package/src/ui/feed/PostStatusBadges.tsx +42 -3
- package/src/ui/pages/ArchivePage.tsx +131 -24
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +63 -0
- package/src/ui/shared/DraftPreviewBar.tsx +25 -9
- package/src/ui/shared/PostFooter.tsx +35 -12
- package/src/ui/shared/custom-icons.ts +5 -0
- package/src/ui/shared/post-article-attributes.ts +6 -0
- package/dist/app-BxCOR3Uc.js +0 -6
- 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
|
-
|
|
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: "
|
|
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).
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
369
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|