@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.
- package/bin/commands/uploads/cleanup.js +1 -0
- package/dist/{app-BJkOcMbZ.js → app-9P4rVCe2.js} +396 -117
- package/dist/app-DaxS_Cz-.js +6 -0
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-C6peCkkD.css +2 -0
- package/dist/client/_assets/{client-mBvc8KAT.js → client-CXnEhyyv.js} +2 -2
- package/dist/client/_assets/{client-auth-BlfwVtHz.js → client-auth-CSItbyU8.js} +360 -358
- package/dist/{env-CoSe-1y4.js → env-OHRKGcMj.js} +1 -1
- package/dist/{export-DLukCOO3.js → export-Be082J0n.js} +33 -8
- package/dist/{github-api-UD4u_7fa.js → github-api-BgSiE71w.js} +1 -1
- package/dist/{github-app-DeX6Td1O.js → github-app-BbklkFmU.js} +1 -1
- package/dist/{github-sync-BtHY2AST.js → github-sync-D1Cw8mOY.js} +3 -3
- package/dist/{github-sync-BeDecPen.js → github-sync-_kPWM4m9.js} +3 -3
- package/dist/index.js +5 -5
- package/dist/node.js +6 -6
- package/dist/{url-XF0GbKGO.js → url-BMYO-Zlt.js} +42 -2
- package/package.json +1 -1
- package/src/__tests__/bin/uploads-cleanup.test.ts +2 -0
- package/src/client/__tests__/compose-bridge.test.ts +105 -0
- package/src/client/__tests__/hydrate-partial.test.ts +27 -0
- package/src/client/__tests__/json.test.ts +94 -0
- package/src/client/__tests__/note-expand.test.ts +130 -0
- package/src/client/archive-nav.js +2 -1
- package/src/client/audio-player.ts +7 -3
- package/src/client/components/__tests__/compose-format-convert.test.ts +357 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +357 -0
- package/src/client/components/__tests__/jant-compose-editor.test.ts +2 -0
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +2 -0
- package/src/client/components/compose-format-convert.ts +255 -0
- package/src/client/components/compose-types.ts +2 -0
- package/src/client/components/jant-collection-directory.ts +1 -0
- package/src/client/components/jant-collection-form.ts +1 -0
- package/src/client/components/jant-command-palette.ts +4 -0
- package/src/client/components/jant-compose-dialog.ts +106 -44
- package/src/client/components/jant-compose-editor.ts +65 -11
- package/src/client/components/jant-compose-fullscreen.ts +3 -0
- package/src/client/components/jant-nav-manager.ts +4 -0
- package/src/client/components/jant-post-menu.ts +3 -0
- package/src/client/components/jant-repo-picker.ts +3 -0
- package/src/client/components/jant-settings-general.ts +3 -0
- package/src/client/compose-bridge.ts +17 -0
- package/src/client/feed-video-player.ts +1 -1
- package/src/client/hydrate-partial.ts +25 -0
- package/src/client/json.ts +56 -2
- package/src/client/multipart-upload.ts +17 -7
- package/src/client/note-expand.ts +63 -0
- package/src/client/upload-session.ts +17 -9
- package/src/client.ts +1 -0
- package/src/i18n/locales/public/en.po +41 -0
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +41 -0
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +41 -0
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +12 -12
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +12 -12
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +12 -12
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +1 -1
- package/src/lib/__tests__/markdown.test.ts +1 -1
- package/src/lib/__tests__/summary.test.ts +87 -0
- package/src/lib/__tests__/timeline.test.ts +48 -1
- package/src/lib/__tests__/tiptap-render.test.ts +4 -4
- package/src/lib/__tests__/url.test.ts +44 -0
- package/src/lib/__tests__/view.test.ts +168 -1
- package/src/lib/navigation.ts +1 -0
- package/src/lib/resolve-config.ts +2 -2
- package/src/lib/summary.ts +42 -3
- package/src/lib/tiptap-render.ts +6 -2
- package/src/lib/upload.ts +2 -2
- package/src/lib/url.ts +41 -0
- package/src/lib/view.ts +102 -40
- package/src/preset.css +7 -1
- package/src/routes/api/internal/__tests__/uploads.test.ts +68 -0
- package/src/routes/api/internal/sites.ts +77 -1
- package/src/routes/api/public/__tests__/archive.test.ts +66 -0
- package/src/routes/api/public/archive.ts +22 -6
- package/src/routes/api/telegram.ts +2 -1
- package/src/routes/dash/custom-urls.tsx +1 -1
- package/src/routes/dash/settings.tsx +8 -5
- package/src/routes/pages/__tests__/archive-params.test.ts +135 -0
- package/src/routes/pages/archive.tsx +116 -20
- package/src/routes/pages/collections.tsx +1 -0
- package/src/services/__tests__/media.test.ts +83 -0
- package/src/services/__tests__/post.test.ts +81 -0
- package/src/services/export-theme/assets/client-site.js +1 -1
- package/src/services/export-theme/styles/main.css +49 -15
- package/src/services/media.ts +31 -1
- package/src/services/post.ts +22 -2
- package/src/services/search.ts +4 -4
- package/src/services/site-admin.ts +121 -0
- package/src/services/upload-session.ts +18 -0
- package/src/styles/tokens.css +1 -1
- package/src/styles/ui.css +163 -34
- package/src/types/config.ts +1 -1
- package/src/types/props.ts +3 -0
- package/src/ui/compose/ComposeDialog.tsx +13 -0
- package/src/ui/dash/settings/AccountMenuContent.tsx +0 -39
- package/src/ui/dash/settings/SettingsDirectory.tsx +26 -1
- package/src/ui/dash/settings/SettingsRootContent.tsx +46 -1
- package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +55 -0
- package/src/ui/feed/NoteCard.tsx +54 -5
- package/src/ui/feed/__tests__/timeline-cards.test.ts +73 -0
- package/src/ui/pages/ArchivePage.tsx +89 -6
- package/src/ui/pages/CollectionsPage.tsx +7 -1
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +37 -0
- package/src/ui/shared/CollectionDirectory.tsx +13 -3
- package/src/ui/shared/CollectionsManager.tsx +3 -0
- package/dist/app-CL2PC1Fl.js +0 -6
- 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 {
|
|
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
|
|
16
|
-
"Invalid media
|
|
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:
|
|
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
|
|
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") ?? "
|
|
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&
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
100
|
-
const
|
|
101
|
-
|
|
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
|
|
104
|
-
const
|
|
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
|
|
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
|
-
|
|
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("
|
|
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) =>
|
|
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,
|