@jant/core 0.6.6 → 0.6.8

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/uploads/cleanup.js +1 -0
  2. package/dist/{app-BJkOcMbZ.js → app-9P4rVCe2.js} +396 -117
  3. package/dist/app-DaxS_Cz-.js +6 -0
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/client-C6peCkkD.css +2 -0
  6. package/dist/client/_assets/{client-mBvc8KAT.js → client-CXnEhyyv.js} +2 -2
  7. package/dist/client/_assets/{client-auth-BlfwVtHz.js → client-auth-CSItbyU8.js} +360 -358
  8. package/dist/{env-CoSe-1y4.js → env-OHRKGcMj.js} +1 -1
  9. package/dist/{export-DLukCOO3.js → export-Be082J0n.js} +33 -8
  10. package/dist/{github-api-UD4u_7fa.js → github-api-BgSiE71w.js} +1 -1
  11. package/dist/{github-app-DeX6Td1O.js → github-app-BbklkFmU.js} +1 -1
  12. package/dist/{github-sync-BtHY2AST.js → github-sync-D1Cw8mOY.js} +3 -3
  13. package/dist/{github-sync-BeDecPen.js → github-sync-_kPWM4m9.js} +3 -3
  14. package/dist/index.js +5 -5
  15. package/dist/node.js +6 -6
  16. package/dist/{url-XF0GbKGO.js → url-BMYO-Zlt.js} +42 -2
  17. package/package.json +1 -1
  18. package/src/__tests__/bin/uploads-cleanup.test.ts +2 -0
  19. package/src/client/__tests__/compose-bridge.test.ts +105 -0
  20. package/src/client/__tests__/hydrate-partial.test.ts +27 -0
  21. package/src/client/__tests__/json.test.ts +94 -0
  22. package/src/client/__tests__/note-expand.test.ts +130 -0
  23. package/src/client/archive-nav.js +2 -1
  24. package/src/client/audio-player.ts +7 -3
  25. package/src/client/components/__tests__/compose-format-convert.test.ts +357 -0
  26. package/src/client/components/__tests__/jant-compose-dialog.test.ts +357 -0
  27. package/src/client/components/__tests__/jant-compose-editor.test.ts +2 -0
  28. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +2 -0
  29. package/src/client/components/compose-format-convert.ts +255 -0
  30. package/src/client/components/compose-types.ts +2 -0
  31. package/src/client/components/jant-collection-directory.ts +1 -0
  32. package/src/client/components/jant-collection-form.ts +1 -0
  33. package/src/client/components/jant-command-palette.ts +4 -0
  34. package/src/client/components/jant-compose-dialog.ts +106 -44
  35. package/src/client/components/jant-compose-editor.ts +65 -11
  36. package/src/client/components/jant-compose-fullscreen.ts +3 -0
  37. package/src/client/components/jant-nav-manager.ts +4 -0
  38. package/src/client/components/jant-post-menu.ts +3 -0
  39. package/src/client/components/jant-repo-picker.ts +3 -0
  40. package/src/client/components/jant-settings-general.ts +3 -0
  41. package/src/client/compose-bridge.ts +17 -0
  42. package/src/client/feed-video-player.ts +1 -1
  43. package/src/client/hydrate-partial.ts +25 -0
  44. package/src/client/json.ts +56 -2
  45. package/src/client/multipart-upload.ts +17 -7
  46. package/src/client/note-expand.ts +63 -0
  47. package/src/client/upload-session.ts +17 -9
  48. package/src/client.ts +1 -0
  49. package/src/i18n/locales/public/en.po +41 -0
  50. package/src/i18n/locales/public/en.ts +1 -1
  51. package/src/i18n/locales/public/zh-Hans.po +41 -0
  52. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  53. package/src/i18n/locales/public/zh-Hant.po +41 -0
  54. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  55. package/src/i18n/locales/settings/en.po +12 -12
  56. package/src/i18n/locales/settings/en.ts +1 -1
  57. package/src/i18n/locales/settings/zh-Hans.po +12 -12
  58. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  59. package/src/i18n/locales/settings/zh-Hant.po +12 -12
  60. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  61. package/src/lib/__tests__/markdown-to-tiptap.test.ts +1 -1
  62. package/src/lib/__tests__/markdown.test.ts +1 -1
  63. package/src/lib/__tests__/summary.test.ts +87 -0
  64. package/src/lib/__tests__/timeline.test.ts +48 -1
  65. package/src/lib/__tests__/tiptap-render.test.ts +4 -4
  66. package/src/lib/__tests__/url.test.ts +44 -0
  67. package/src/lib/__tests__/view.test.ts +168 -1
  68. package/src/lib/navigation.ts +1 -0
  69. package/src/lib/resolve-config.ts +2 -2
  70. package/src/lib/summary.ts +42 -3
  71. package/src/lib/tiptap-render.ts +6 -2
  72. package/src/lib/upload.ts +2 -2
  73. package/src/lib/url.ts +41 -0
  74. package/src/lib/view.ts +102 -40
  75. package/src/preset.css +7 -1
  76. package/src/routes/api/internal/__tests__/uploads.test.ts +68 -0
  77. package/src/routes/api/internal/sites.ts +77 -1
  78. package/src/routes/api/public/__tests__/archive.test.ts +66 -0
  79. package/src/routes/api/public/archive.ts +22 -6
  80. package/src/routes/api/telegram.ts +2 -1
  81. package/src/routes/dash/custom-urls.tsx +1 -1
  82. package/src/routes/dash/settings.tsx +8 -5
  83. package/src/routes/pages/__tests__/archive-params.test.ts +135 -0
  84. package/src/routes/pages/archive.tsx +116 -20
  85. package/src/routes/pages/collections.tsx +1 -0
  86. package/src/services/__tests__/media.test.ts +83 -0
  87. package/src/services/__tests__/post.test.ts +81 -0
  88. package/src/services/export-theme/assets/client-site.js +1 -1
  89. package/src/services/export-theme/styles/main.css +49 -15
  90. package/src/services/media.ts +31 -1
  91. package/src/services/post.ts +22 -2
  92. package/src/services/search.ts +4 -4
  93. package/src/services/site-admin.ts +121 -0
  94. package/src/services/upload-session.ts +18 -0
  95. package/src/styles/tokens.css +1 -1
  96. package/src/styles/ui.css +163 -34
  97. package/src/types/config.ts +1 -1
  98. package/src/types/props.ts +3 -0
  99. package/src/ui/compose/ComposeDialog.tsx +13 -0
  100. package/src/ui/dash/settings/AccountMenuContent.tsx +0 -39
  101. package/src/ui/dash/settings/SettingsDirectory.tsx +26 -1
  102. package/src/ui/dash/settings/SettingsRootContent.tsx +46 -1
  103. package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +55 -0
  104. package/src/ui/feed/NoteCard.tsx +54 -5
  105. package/src/ui/feed/__tests__/timeline-cards.test.ts +73 -0
  106. package/src/ui/pages/ArchivePage.tsx +89 -6
  107. package/src/ui/pages/CollectionsPage.tsx +7 -1
  108. package/src/ui/pages/__tests__/ArchivePage.test.tsx +37 -0
  109. package/src/ui/shared/CollectionDirectory.tsx +13 -3
  110. package/src/ui/shared/CollectionsManager.tsx +3 -0
  111. package/dist/app-CL2PC1Fl.js +0 -6
  112. package/dist/client/_assets/client-BMPMuwvV.css +0 -2
@@ -3,7 +3,10 @@ import { z } from "zod";
3
3
  import { requireInternalAdminApi } from "../../../middleware/auth.js";
4
4
  import { ConflictError } from "../../../lib/errors.js";
5
5
  import { parseValidated } from "../../../lib/schemas.js";
6
- import { getSiteResolutionMode } from "../../../lib/env.js";
6
+ import {
7
+ getConfiguredStorageDriver,
8
+ getSiteResolutionMode,
9
+ } from "../../../lib/env.js";
7
10
  import type { Bindings } from "../../../types.js";
8
11
  import type { AppVariables } from "../../../types/app-context.js";
9
12
 
@@ -46,6 +49,10 @@ const SitePostCountsSchema = z.object({
46
49
  siteIds: z.array(z.string().trim().min(1)).max(200),
47
50
  });
48
51
 
52
+ const CleanupSiteUploadsSchema = z.object({
53
+ limit: z.number().int().positive().max(500).optional(),
54
+ });
55
+
49
56
  const ManagedSiteDomainSchema = z.object({
50
57
  host: z
51
58
  .string()
@@ -60,6 +67,20 @@ const ManagedSiteDomainSchema = z.object({
60
67
  makePrimary: z.boolean().optional(),
61
68
  });
62
69
 
70
+ const RenameManagedSiteSchema = z.object({
71
+ key: ManagedSiteKeySchema,
72
+ primaryHost: z
73
+ .string()
74
+ .trim()
75
+ .toLowerCase()
76
+ .min(1)
77
+ .max(255)
78
+ .regex(
79
+ /^(?=.{1,255}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/,
80
+ "Primary host must be a valid hostname.",
81
+ ),
82
+ });
83
+
63
84
  export const internalSitesRoutes = new Hono<Env>();
64
85
 
65
86
  function assertHostBasedMode(env: Bindings) {
@@ -142,6 +163,42 @@ internalSitesRoutes.get(
142
163
  },
143
164
  );
144
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.
171
+ internalSitesRoutes.post(
172
+ "/:siteId/uploads/cleanup",
173
+ requireInternalAdminApi(),
174
+ async (c) => {
175
+ assertHostBasedMode(c.env);
176
+
177
+ const storage = c.var.storage;
178
+ if (!storage) {
179
+ return c.json(
180
+ { error: "File storage isn't set up. Check your server config." },
181
+ 500,
182
+ );
183
+ }
184
+
185
+ const contentType = c.req.header("Content-Type") || "";
186
+ const rawBody = contentType.includes("application/json")
187
+ ? await c.req.json().catch(() => ({}))
188
+ : {};
189
+ const body = parseValidated(CleanupSiteUploadsSchema, rawBody);
190
+
191
+ const services = c.var.servicesForSite(c.req.param("siteId"));
192
+ const result = await services.uploads.cleanupExpired({
193
+ storage,
194
+ storageDriver: getConfiguredStorageDriver(c.env),
195
+ limit: body.limit,
196
+ });
197
+
198
+ return c.json(result);
199
+ },
200
+ );
201
+
145
202
  internalSitesRoutes.get(
146
203
  "/:siteId/export",
147
204
  requireInternalAdminApi(),
@@ -198,6 +255,25 @@ internalSitesRoutes.post(
198
255
  },
199
256
  );
200
257
 
258
+ internalSitesRoutes.post(
259
+ "/:siteId/rename",
260
+ requireInternalAdminApi(),
261
+ async (c) => {
262
+ assertHostBasedMode(c.env);
263
+ const body = parseValidated(RenameManagedSiteSchema, await c.req.json());
264
+ const result = await c.var.services.siteAdmin.renameManagedSite(
265
+ c.req.param("siteId"),
266
+ body,
267
+ );
268
+
269
+ return c.json({
270
+ primaryHost: result.domain.host,
271
+ siteId: result.site.id,
272
+ status: result.site.status,
273
+ });
274
+ },
275
+ );
276
+
201
277
  internalSitesRoutes.get(
202
278
  "/:siteId/domains",
203
279
  requireInternalAdminApi(),
@@ -143,6 +143,72 @@ describe("Public Archive API Routes", () => {
143
143
  expect(noMediaBody.posts).toHaveLength(2);
144
144
  });
145
145
 
146
+ it("filters by title and media presence words", async () => {
147
+ const { app, services } = createTestApp({ authenticated: false });
148
+ app.route("/api/public/archive", publicArchiveApiRoutes);
149
+
150
+ const titled = await services.posts.create({
151
+ format: "note",
152
+ title: "Has title",
153
+ bodyMarkdown: "body",
154
+ });
155
+ const untitled = await services.posts.create({
156
+ format: "note",
157
+ bodyMarkdown: "body without title",
158
+ });
159
+
160
+ const withTitle = await app.request("/api/public/archive?title=any");
161
+ const withTitleBody = await withTitle.json();
162
+ expect(withTitleBody.posts.map((p: { id: string }) => p.id)).toEqual([
163
+ titled.id,
164
+ ]);
165
+
166
+ const noTitle = await app.request("/api/public/archive?title=none");
167
+ const noTitleBody = await noTitle.json();
168
+ expect(noTitleBody.posts.map((p: { id: string }) => p.id)).toEqual([
169
+ untitled.id,
170
+ ]);
171
+
172
+ const noMedia = await app.request("/api/public/archive?media=none");
173
+ const noMediaBody = await noMedia.json();
174
+ expect(noMediaBody.posts).toHaveLength(2);
175
+
176
+ const anyMedia = await app.request("/api/public/archive?media=any");
177
+ const anyMediaBody = await anyMedia.json();
178
+ expect(anyMediaBody.posts).toHaveLength(0);
179
+ });
180
+
181
+ it("filters threads and single posts via replies", async () => {
182
+ const { app, services } = createTestApp({ authenticated: false });
183
+ app.route("/api/public/archive", publicArchiveApiRoutes);
184
+
185
+ const threadRoot = await services.posts.create({
186
+ format: "note",
187
+ bodyMarkdown: "thread root",
188
+ });
189
+ await services.posts.create({
190
+ format: "note",
191
+ bodyMarkdown: "reply",
192
+ replyToId: threadRoot.id,
193
+ });
194
+ const standalone = await services.posts.create({
195
+ format: "note",
196
+ bodyMarkdown: "standalone",
197
+ });
198
+
199
+ const threads = await app.request("/api/public/archive?replies=any");
200
+ const threadsBody = await threads.json();
201
+ expect(threadsBody.posts.map((p: { id: string }) => p.id)).toEqual([
202
+ threadRoot.id,
203
+ ]);
204
+
205
+ const singles = await app.request("/api/public/archive?replies=none");
206
+ const singlesBody = await singles.json();
207
+ expect(singlesBody.posts.map((p: { id: string }) => p.id)).toEqual([
208
+ standalone.id,
209
+ ]);
210
+ });
211
+
146
212
  it("filters by media kind", async () => {
147
213
  const { app, services } = createTestApp({ authenticated: false });
148
214
  app.route("/api/public/archive", publicArchiveApiRoutes);
@@ -12,20 +12,26 @@ export const publicArchiveApiRoutes = new Hono<Env>();
12
12
 
13
13
  const MediaKindSchema = z.enum(MEDIA_KINDS);
14
14
  const MEDIA_KIND_LIST = MEDIA_KINDS.join(", ");
15
- const INVALID_MEDIA_KIND_MESSAGE =
16
- "Invalid media kind. Allowed: " + MEDIA_KIND_LIST;
15
+ const INVALID_MEDIA_MESSAGE =
16
+ "Invalid media value. Allowed: any, none, or kinds: " + MEDIA_KIND_LIST;
17
17
 
18
18
  const BoolFlagSchema = z.enum(["0", "1"]).transform((value) => value === "1");
19
+ const PresenceSchema = z
20
+ .enum(["any", "none"])
21
+ .transform((value) => value === "any");
19
22
 
20
23
  const ListPublicArchiveQuerySchema = z.object({
21
24
  format: FormatSchema.optional(),
22
25
  collection: z.string().optional(),
23
26
  year: z.coerce.number().int().min(1971).optional(),
27
+ // Kinds list (image,video,...) or presence words: any = posts with any
28
+ // attachment, none = posts without attachments.
24
29
  media: z
25
30
  .string()
26
31
  .optional()
27
32
  .transform((value, ctx) => {
28
33
  if (!value) return undefined;
34
+ if (value === "any" || value === "none") return value;
29
35
  const parts = value
30
36
  .split(",")
31
37
  .map((part) => part.trim())
@@ -35,12 +41,15 @@ const ListPublicArchiveQuerySchema = z.object({
35
41
  if (!result.success) {
36
42
  ctx.addIssue({
37
43
  code: z.ZodIssueCode.custom,
38
- message: INVALID_MEDIA_KIND_MESSAGE,
44
+ message: INVALID_MEDIA_MESSAGE,
39
45
  });
40
46
  return z.NEVER;
41
47
  }
42
48
  return result.data;
43
49
  }),
50
+ title: PresenceSchema.optional(),
51
+ replies: PresenceSchema.optional(),
52
+ // Deprecated: use media=any|none and title=any|none instead.
44
53
  hasMedia: BoolFlagSchema.optional(),
45
54
  hasTitle: BoolFlagSchema.optional(),
46
55
  cursor: z.string().optional(),
@@ -54,6 +63,8 @@ publicArchiveApiRoutes.get("/", async (c) => {
54
63
  collection,
55
64
  year,
56
65
  media,
66
+ title,
67
+ replies,
57
68
  hasMedia,
58
69
  hasTitle,
59
70
  cursor,
@@ -61,6 +72,10 @@ publicArchiveApiRoutes.get("/", async (c) => {
61
72
  content,
62
73
  } = parseValidated(ListPublicArchiveQuerySchema, c.req.query());
63
74
 
75
+ const mediaKinds = Array.isArray(media) ? media : undefined;
76
+ const mediaPresence =
77
+ media === "any" ? true : media === "none" ? false : undefined;
78
+
64
79
  let collectionIds: string[] | undefined;
65
80
  if (collection) {
66
81
  // Accept both "tech,art" and "tech+art", matching the page URL convention.
@@ -89,9 +104,10 @@ publicArchiveApiRoutes.get("/", async (c) => {
89
104
  excludeReplies: true,
90
105
  publishedAfter,
91
106
  publishedBefore,
92
- mediaKinds: media,
93
- hasMedia,
94
- hasTitle,
107
+ mediaKinds,
108
+ hasMedia: mediaPresence ?? hasMedia,
109
+ hasTitle: title ?? hasTitle,
110
+ hasReplies: replies,
95
111
  });
96
112
 
97
113
  const postIds = posts.map((post) => post.id);
@@ -66,7 +66,8 @@ function uploadConfigFromEnv(env: Bindings): {
66
66
  maxFileSizeMB: number;
67
67
  } {
68
68
  const maxFileSizeMB =
69
- parseInt(getEnvString(env, "UPLOAD_MAX_FILE_SIZE_MB") ?? "500", 10) || 500;
69
+ parseInt(getEnvString(env, "UPLOAD_MAX_FILE_SIZE_MB") ?? "1024", 10) ||
70
+ 1024;
70
71
  return {
71
72
  storageDriver: getConfiguredStorageDriver(env),
72
73
  maxFileSizeMB,
@@ -507,7 +507,7 @@ function NewCustomUrlContent({
507
507
  type="text"
508
508
  data-bind="archiveQuery"
509
509
  class="input"
510
- placeholder="format=note&hasTitle=0&visibility=public&view=list"
510
+ placeholder="format=note&title=none&visibility=public&view=list"
511
511
  />
512
512
  <p class="text-xs text-muted-foreground mt-1">
513
513
  {i18n._(
@@ -57,6 +57,7 @@ import {
57
57
  getHostedControlPlaneAccountUrl,
58
58
  getHostedControlPlaneProviderLabel,
59
59
  getHostedControlPlaneSiteDeleteUrl,
60
+ getHostedControlPlaneSiteSettingsUrl,
60
61
  } from "../../lib/hosted-signin.js";
61
62
  import { syncHostedControlPlaneSiteAvatar } from "../../lib/hosted-control-plane-sync.js";
62
63
  import {
@@ -265,6 +266,11 @@ function demoRestrictionResponse(c: Context<Env>, message: string): Response {
265
266
 
266
267
  settingsRoutes.get("/", async (c) => {
267
268
  const navData = await getNavigationData(c);
269
+ const hostedControlPlaneSiteSettingsUrl =
270
+ getHostedControlPlaneSiteSettingsUrl(c.env, c.var.currentSite.id);
271
+ const hostedControlPlaneProviderLabel = getHostedControlPlaneProviderLabel(
272
+ c.env,
273
+ );
268
274
 
269
275
  return renderPublicPage(c, {
270
276
  title: buildPageTitle("Settings", navData.siteName),
@@ -274,6 +280,8 @@ settingsRoutes.get("/", async (c) => {
274
280
  <SettingsRootContent
275
281
  sitePathPrefix={c.var.appConfig.sitePathPrefix}
276
282
  demoMode={c.var.appConfig.demoMode}
283
+ hostedControlPlaneSiteSettingsUrl={hostedControlPlaneSiteSettingsUrl}
284
+ hostedControlPlaneProviderLabel={hostedControlPlaneProviderLabel}
277
285
  />
278
286
  </div>
279
287
  ),
@@ -990,10 +998,6 @@ settingsRoutes.get("/account", async (c) => {
990
998
  const hostedControlPlaneProviderLabel = getHostedControlPlaneProviderLabel(
991
999
  c.env,
992
1000
  );
993
- const hostedControlPlaneSiteDeleteUrl = getHostedControlPlaneSiteDeleteUrl(
994
- c.env,
995
- c.var.currentSite.id,
996
- );
997
1001
 
998
1002
  return renderPublicPage(c, {
999
1003
  title: buildPageTitle("Account", navData.siteName),
@@ -1010,7 +1014,6 @@ settingsRoutes.get("/account", async (c) => {
1010
1014
  demoMode={c.var.appConfig.demoMode}
1011
1015
  hostedControlPlaneAccountUrl={hostedControlPlaneAccountUrl}
1012
1016
  hostedControlPlaneProviderLabel={hostedControlPlaneProviderLabel}
1013
- hostedControlPlaneSiteDeleteUrl={hostedControlPlaneSiteDeleteUrl}
1014
1017
  />
1015
1018
  </>
1016
1019
  ),
@@ -0,0 +1,135 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createTestApp } from "../../../__tests__/helpers/app.js";
3
+ import { archiveRoutes } from "../archive.js";
4
+
5
+ /**
6
+ * Integration coverage for archive filter param handling.
7
+ *
8
+ * The page and the feed share parseArchiveParams. These tests pin down:
9
+ * - the new single-word params (title/replies/media=any|none),
10
+ * - the legacy hasTitle/hasReplies/hasMedia=1/0 fallback on the feed,
11
+ * which keeps old subscriptions and stored custom archive URLs working,
12
+ * - the 308 canonical redirect on the page route for legacy spellings.
13
+ */
14
+
15
+ async function fetchFeed(
16
+ app: { request: (path: string) => Promise<Response> },
17
+ query: string,
18
+ ): Promise<string> {
19
+ const res = await app.request(`/archive/feed${query}`);
20
+ expect(res.status).toBe(200);
21
+ return res.text();
22
+ }
23
+
24
+ function setupApp() {
25
+ const { app, services } = createTestApp({ authenticated: false });
26
+ app.route("/archive", archiveRoutes);
27
+ return { app, services };
28
+ }
29
+
30
+ describe("archive feed filter params", () => {
31
+ it("filters by title with the new param and the legacy fallback", async () => {
32
+ const { app, services } = setupApp();
33
+ await services.posts.create({
34
+ format: "note",
35
+ title: "Titled post",
36
+ bodyMarkdown: "body with heading",
37
+ });
38
+ await services.posts.create({
39
+ format: "note",
40
+ bodyMarkdown: "body without heading",
41
+ });
42
+
43
+ const fresh = await fetchFeed(app, "?title=none");
44
+ expect(fresh).toContain("body without heading");
45
+ expect(fresh).not.toContain("body with heading");
46
+
47
+ const legacy = await fetchFeed(app, "?hasTitle=0");
48
+ expect(legacy).toContain("body without heading");
49
+ expect(legacy).not.toContain("body with heading");
50
+ });
51
+
52
+ it("filters by replies with the new param and the legacy fallback", async () => {
53
+ const { app, services } = setupApp();
54
+ const root = await services.posts.create({
55
+ format: "note",
56
+ bodyMarkdown: "thread root body",
57
+ });
58
+ await services.posts.create({
59
+ format: "note",
60
+ bodyMarkdown: "reply body",
61
+ replyToId: root.id,
62
+ });
63
+ await services.posts.create({
64
+ format: "note",
65
+ bodyMarkdown: "standalone body",
66
+ });
67
+
68
+ const threads = await fetchFeed(app, "?replies=any");
69
+ expect(threads).toContain("thread root body");
70
+ expect(threads).not.toContain("standalone body");
71
+
72
+ const singles = await fetchFeed(app, "?replies=none");
73
+ expect(singles).toContain("standalone body");
74
+ expect(singles).not.toContain("thread root body");
75
+
76
+ const legacySingles = await fetchFeed(app, "?hasReplies=0");
77
+ expect(legacySingles).toContain("standalone body");
78
+ expect(legacySingles).not.toContain("thread root body");
79
+ });
80
+
81
+ it("treats media=none as the legacy hasMedia=0", async () => {
82
+ const { app, services } = setupApp();
83
+ await services.posts.create({
84
+ format: "note",
85
+ bodyMarkdown: "text only body",
86
+ });
87
+
88
+ const fresh = await fetchFeed(app, "?media=none");
89
+ expect(fresh).toContain("text only body");
90
+
91
+ const legacy = await fetchFeed(app, "?hasMedia=0");
92
+ expect(legacy).toContain("text only body");
93
+
94
+ const withMedia = await fetchFeed(app, "?media=any");
95
+ expect(withMedia).not.toContain("text only body");
96
+ });
97
+ });
98
+
99
+ describe("archive page legacy param redirect", () => {
100
+ it("redirects legacy boolean params to their single-word spelling", async () => {
101
+ const { app } = setupApp();
102
+
103
+ const res = await app.request("/archive?hasTitle=0");
104
+ expect(res.status).toBe(308);
105
+ expect(res.headers.get("location")).toBe("/archive?title=none");
106
+ });
107
+
108
+ it("preserves other params and rewrites only legacy ones", async () => {
109
+ const { app } = setupApp();
110
+
111
+ const res = await app.request(
112
+ "/archive?format=note&hasReplies=1&utm_source=newsletter",
113
+ );
114
+ expect(res.status).toBe(308);
115
+ expect(res.headers.get("location")).toBe(
116
+ "/archive?format=note&utm_source=newsletter&replies=any",
117
+ );
118
+ });
119
+
120
+ it("redirects visibility=latest_hidden to the hidden alias", async () => {
121
+ const { app } = setupApp();
122
+
123
+ const res = await app.request("/archive?visibility=latest_hidden");
124
+ expect(res.status).toBe(308);
125
+ expect(res.headers.get("location")).toBe("/archive?visibility=hidden");
126
+ });
127
+
128
+ it("drops a legacy param without overriding an explicit new one", async () => {
129
+ const { app } = setupApp();
130
+
131
+ const res = await app.request("/archive?title=any&hasTitle=0");
132
+ expect(res.status).toBe(308);
133
+ expect(res.headers.get("location")).toBe("/archive?title=any");
134
+ });
135
+ });
@@ -57,6 +57,7 @@ interface ParsedArchiveParams {
57
57
  mediaKinds?: MediaKind[];
58
58
  hasMedia?: boolean;
59
59
  hasTitle?: boolean;
60
+ hasReplies?: boolean;
60
61
  visibility?: ArchiveVisibility;
61
62
  visibilityAll: boolean;
62
63
  view?: ArchiveView;
@@ -87,25 +88,45 @@ function parseArchiveParams(
87
88
 
88
89
  const collectionSlug = q("collection") || undefined;
89
90
 
90
- const mediaParam = q("media") || undefined;
91
- const mediaKinds = mediaParam
92
- ? (mediaParam
93
- .split(",")
94
- .filter((m): m is MediaKind =>
95
- (MEDIA_KINDS as readonly string[]).includes(m),
96
- ) as MediaKind[])
97
- : undefined;
91
+ // Presence filters use single-word params with any/none values
92
+ // (media=any|none|<kinds>, title=any|none, replies=any|none). The legacy
93
+ // hasMedia/hasTitle/hasReplies=1/0 params are still accepted so old
94
+ // bookmarks, feed subscriptions, and stored custom archive URLs keep
95
+ // working; new URLs are always generated in the new style.
96
+ const parsePresence = (
97
+ param: string | undefined,
98
+ legacy: string | undefined,
99
+ ): boolean | undefined => {
100
+ if (param === "any") return true;
101
+ if (param === "none") return false;
102
+ if (legacy === "1") return true;
103
+ if (legacy === "0") return false;
104
+ return undefined;
105
+ };
98
106
 
99
- const hasMediaParam = q("hasMedia");
100
- const hasMedia =
101
- hasMediaParam === "1" ? true : hasMediaParam === "0" ? false : undefined;
107
+ const mediaParam = q("media") || undefined;
108
+ const mediaIsPresence = mediaParam === "any" || mediaParam === "none";
109
+ const mediaKinds =
110
+ mediaParam && !mediaIsPresence
111
+ ? (mediaParam
112
+ .split(",")
113
+ .filter((m): m is MediaKind =>
114
+ (MEDIA_KINDS as readonly string[]).includes(m),
115
+ ) as MediaKind[])
116
+ : undefined;
117
+ const hasMedia = parsePresence(
118
+ mediaIsPresence ? mediaParam : undefined,
119
+ q("hasMedia"),
120
+ );
102
121
 
103
- const hasTitleParam = q("hasTitle");
104
- const hasTitle =
105
- hasTitleParam === "1" ? true : hasTitleParam === "0" ? false : undefined;
122
+ const hasTitle = parsePresence(q("title"), q("hasTitle"));
123
+ const hasReplies = parsePresence(q("replies"), q("hasReplies"));
106
124
 
107
125
  const VALID_VISIBILITIES = ["public", "latest_hidden", "private", "featured"];
108
- const visibilityParam = q("visibility");
126
+ const rawVisibilityParam = q("visibility");
127
+ // "hidden" is the URL spelling of the internal latest_hidden value
128
+ const visibilityParam =
129
+ rawVisibilityParam === "hidden" ? "latest_hidden" : rawVisibilityParam;
109
130
  const visibilityAll = visibilityParam === "all";
110
131
  const visibility =
111
132
  visibilityParam && VALID_VISIBILITIES.includes(visibilityParam)
@@ -129,6 +150,7 @@ function parseArchiveParams(
129
150
  mediaKinds: mediaKinds && mediaKinds.length > 0 ? mediaKinds : undefined,
130
151
  hasMedia,
131
152
  hasTitle,
153
+ hasReplies,
132
154
  visibility,
133
155
  visibilityAll,
134
156
  view,
@@ -184,6 +206,7 @@ function buildArchivePostFilters(
184
206
  mediaKinds: params.mediaKinds,
185
207
  hasMedia: params.hasMedia,
186
208
  hasTitle: params.hasTitle,
209
+ hasReplies: params.hasReplies,
187
210
  };
188
211
  }
189
212
 
@@ -198,12 +221,14 @@ function buildArchiveFeedQuery(params: ParsedArchiveParams): string {
198
221
  if (params.collectionSlug) qs.set("collection", params.collectionSlug);
199
222
  if (params.mediaKinds && params.mediaKinds.length > 0) {
200
223
  qs.set("media", params.mediaKinds.join(","));
201
- }
202
- if (params.hasMedia !== undefined) {
203
- qs.set("hasMedia", params.hasMedia ? "1" : "0");
224
+ } else if (params.hasMedia !== undefined) {
225
+ qs.set("media", params.hasMedia ? "any" : "none");
204
226
  }
205
227
  if (params.hasTitle !== undefined) {
206
- qs.set("hasTitle", params.hasTitle ? "1" : "0");
228
+ qs.set("title", params.hasTitle ? "any" : "none");
229
+ }
230
+ if (params.hasReplies !== undefined) {
231
+ qs.set("replies", params.hasReplies ? "any" : "none");
207
232
  }
208
233
  const str = qs.toString();
209
234
  return str ? `?${str}` : "";
@@ -211,6 +236,49 @@ function buildArchiveFeedQuery(params: ParsedArchiveParams): string {
211
236
 
212
237
  export const archiveRoutes = new Hono<Env>();
213
238
 
239
+ /**
240
+ * Build a canonical redirect target when a request uses legacy archive
241
+ * param spellings (hasMedia/hasTitle/hasReplies=1/0, visibility=latest_hidden).
242
+ *
243
+ * Only legacy params are rewritten; everything else (including unknown
244
+ * params) is preserved. Returns null when the URL is already canonical.
245
+ * Applies to the /archive page only — feeds and the public API accept
246
+ * legacy spellings silently, and custom archive URLs (path_registry
247
+ * query overrides) never reach this path.
248
+ *
249
+ * @param c - Hono context
250
+ * @returns Canonical path + query to redirect to, or null
251
+ */
252
+ function legacyArchiveParamsRedirect(c: Context<Env>): string | null {
253
+ const url = new URL(c.req.url);
254
+ const params = url.searchParams;
255
+ let changed = false;
256
+
257
+ const rewrites = [
258
+ ["hasMedia", "media"],
259
+ ["hasTitle", "title"],
260
+ ["hasReplies", "replies"],
261
+ ] as const;
262
+ for (const [legacy, name] of rewrites) {
263
+ const value = params.get(legacy);
264
+ if (value === null) continue;
265
+ if (!params.has(name) && (value === "1" || value === "0")) {
266
+ params.set(name, value === "1" ? "any" : "none");
267
+ }
268
+ params.delete(legacy);
269
+ changed = true;
270
+ }
271
+
272
+ if (params.get("visibility") === "latest_hidden") {
273
+ params.set("visibility", "hidden");
274
+ changed = true;
275
+ }
276
+
277
+ if (!changed) return null;
278
+ const qs = params.toString();
279
+ return `${url.pathname}${qs ? `?${qs}` : ""}`;
280
+ }
281
+
214
282
  // =============================================================================
215
283
  // Archive page — shared rendering
216
284
  // =============================================================================
@@ -360,6 +428,7 @@ export async function renderArchivePage(
360
428
  mediaKinds: params.mediaKinds,
361
429
  hasMedia: params.hasMedia,
362
430
  hasTitle: params.hasTitle,
431
+ hasReplies: params.hasReplies,
363
432
  visibility: effectiveVisibility,
364
433
  view: params.view,
365
434
  };
@@ -397,7 +466,11 @@ export async function renderArchivePage(
397
466
  // Archive page route
398
467
  // =============================================================================
399
468
 
400
- archiveRoutes.get("/", (c) => renderArchivePage(c));
469
+ archiveRoutes.get("/", (c) => {
470
+ const canonical = legacyArchiveParamsRedirect(c);
471
+ if (canonical) return c.redirect(canonical, 308);
472
+ return renderArchivePage(c);
473
+ });
401
474
 
402
475
  // =============================================================================
403
476
  // Archive feed
@@ -492,6 +565,28 @@ function buildArchiveFeedTitle(
492
565
  );
493
566
  }
494
567
 
568
+ if (params.hasReplies === true) {
569
+ parts.push(
570
+ i18n._(
571
+ msg({
572
+ message: "threads",
573
+ comment:
574
+ "@context: Archive feed title segment for hasReplies=1 filter",
575
+ }),
576
+ ),
577
+ );
578
+ } else if (params.hasReplies === false) {
579
+ parts.push(
580
+ i18n._(
581
+ msg({
582
+ message: "single posts",
583
+ comment:
584
+ "@context: Archive feed title segment for hasReplies=0 filter",
585
+ }),
586
+ ),
587
+ );
588
+ }
589
+
495
590
  if (params.validYear) {
496
591
  parts.push(String(params.validYear));
497
592
  }
@@ -534,6 +629,7 @@ async function buildArchiveFeedData(
534
629
  mediaKinds: params.mediaKinds,
535
630
  hasMedia: params.hasMedia,
536
631
  hasTitle: params.hasTitle,
632
+ hasReplies: params.hasReplies,
537
633
  ...(params.validYear
538
634
  ? {
539
635
  publishedAfter: Date.UTC(params.validYear, 0, 1) / 1000,
@@ -71,6 +71,7 @@ collectionsPageRoutes.get("/", async (c) => {
71
71
  items={directoryData.items}
72
72
  isAuthenticated={navData.isAuthenticated ?? false}
73
73
  sitePathPrefix={navData.sitePathPrefix}
74
+ siteOrigin={c.var.appConfig.siteOrigin}
74
75
  />
75
76
  ),
76
77
  });