@jant/core 0.3.48 → 0.3.49
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/dist/{app-DdnIoX7y.js → app-C8bKBHtv.js} +69 -2
- package/dist/{app-DU7dpJID.js → app-DxnM9H8F.js} +1 -1
- package/dist/index.js +1 -1
- package/dist/node.js +2 -2
- package/package.json +1 -1
- package/src/app.tsx +2 -0
- package/src/routes/api/public/__tests__/archive.test.ts +261 -0
- package/src/routes/api/public/archive.ts +116 -0
- package/src/routes/api/public/posts.ts +5 -5
|
@@ -3418,7 +3418,7 @@ function normalizeThemeColorForMeta(color) {
|
|
|
3418
3418
|
* internal paths (e.g. `/_assets/client-HASH.js`) embedded by the Worker build
|
|
3419
3419
|
* from the Vite client manifest. Used only in production (IS_VITE_DEV=false).
|
|
3420
3420
|
*/ var IS_VITE_DEV = typeof __JANT_DEV__ !== "undefined" && __JANT_DEV__ === true;
|
|
3421
|
-
var CORE_VERSION = "0.3.
|
|
3421
|
+
var CORE_VERSION = "0.3.49-c59b5d15c4832deb";
|
|
3422
3422
|
var CLIENT_JS_FILE = "/_assets/client-dSfWfMe9.js";
|
|
3423
3423
|
var CLIENT_AUTH_JS_FILE = "/_assets/client-auth-Ce5WEAVS.js";
|
|
3424
3424
|
var CLIENT_CSS_FILE = "/_assets/client-BoUn7xBo.css";
|
|
@@ -3739,7 +3739,7 @@ var IconSprite = () => {
|
|
|
3739
3739
|
const cjkSerifFont = appConfig?.cjkSerifFont ?? "off";
|
|
3740
3740
|
const cjkStylesheetPath = cjkSerifFont === "zh-Hans" ? IS_VITE_DEV ? assetPath("/src/style-cjk.css") : toPublicAssetPath(CLIENT_CJK_CSS_FILE, assetBasePath) : cjkSerifFont === "zh-Hant" ? IS_VITE_DEV ? assetPath("/src/style-cjk-tc.css") : toPublicAssetPath(CLIENT_CJK_TC_CSS_FILE, assetBasePath) : cjkSerifFont === "ja" ? IS_VITE_DEV ? assetPath("/src/style-cjk-jp.css") : toPublicAssetPath(CLIENT_CJK_JP_CSS_FILE, assetBasePath) : cjkSerifFont === "ko" ? IS_VITE_DEV ? assetPath("/src/style-cjk-kr.css") : toPublicAssetPath(CLIENT_CJK_KR_CSS_FILE, assetBasePath) : null;
|
|
3741
3741
|
const clientScriptPath = IS_VITE_DEV ? resolvedClientBundle === "full" ? assetPath("/src/client-auth.ts") : assetPath("/src/client.ts") : toPublicAssetPath(resolvedClientBundle === "full" ? CLIENT_AUTH_JS_FILE : CLIENT_JS_FILE, assetBasePath);
|
|
3742
|
-
const faviconAssetVersion = resolvedFaviconVersion || "0.3.
|
|
3742
|
+
const faviconAssetVersion = resolvedFaviconVersion || "0.3.49-c59b5d15c4832deb";
|
|
3743
3743
|
const resolvedFaviconHref = faviconHref ?? (faviconAssetVersion ? toPublicPath(`/favicon.ico?v=${faviconAssetVersion}`, sitePathPrefix) : toPublicPath("/favicon.ico", sitePathPrefix));
|
|
3744
3744
|
const resolvedAppleTouchHref = appleTouchHref ?? (faviconAssetVersion ? toPublicPath(`/apple-touch-icon.png?v=${faviconAssetVersion}`, sitePathPrefix) : toPublicPath("/apple-touch-icon.png", sitePathPrefix));
|
|
3745
3745
|
const socialImageHref = resolvedSocialImagePath && (isFullUrl(resolvedSocialImagePath) || resolvedSocialImagePath.startsWith("//") ? resolvedSocialImagePath : toAbsoluteSiteUrl(resolvedSocialImagePath, appConfig?.siteUrl || "", sitePathPrefix));
|
|
@@ -25344,6 +25344,72 @@ publicPostsApiRoutes.get("/:slug", async (c) => {
|
|
|
25344
25344
|
return c.json(toPublicPost(post, mediaList, postCollections, c.var.appConfig, { content }));
|
|
25345
25345
|
});
|
|
25346
25346
|
//#endregion
|
|
25347
|
+
//#region src/routes/api/public/archive.ts
|
|
25348
|
+
var publicArchiveApiRoutes = new Hono();
|
|
25349
|
+
var MediaKindSchema = z.enum(MEDIA_KINDS);
|
|
25350
|
+
var INVALID_MEDIA_KIND_MESSAGE = "Invalid media kind. Allowed: " + MEDIA_KINDS.join(", ");
|
|
25351
|
+
var BoolFlagSchema = z.enum(["0", "1"]).transform((value) => value === "1");
|
|
25352
|
+
var ListPublicArchiveQuerySchema = z.object({
|
|
25353
|
+
format: FormatSchema.optional(),
|
|
25354
|
+
collection: z.string().optional(),
|
|
25355
|
+
year: z.coerce.number().int().min(1971).optional(),
|
|
25356
|
+
media: z.string().optional().transform((value, ctx) => {
|
|
25357
|
+
if (!value) return void 0;
|
|
25358
|
+
const parts = value.split(",").map((part) => part.trim()).filter((part) => part.length > 0);
|
|
25359
|
+
if (parts.length === 0) return void 0;
|
|
25360
|
+
const result = z.array(MediaKindSchema).safeParse(parts);
|
|
25361
|
+
if (!result.success) {
|
|
25362
|
+
ctx.addIssue({
|
|
25363
|
+
code: z.ZodIssueCode.custom,
|
|
25364
|
+
message: INVALID_MEDIA_KIND_MESSAGE
|
|
25365
|
+
});
|
|
25366
|
+
return z.NEVER;
|
|
25367
|
+
}
|
|
25368
|
+
return result.data;
|
|
25369
|
+
}),
|
|
25370
|
+
hasMedia: BoolFlagSchema.optional(),
|
|
25371
|
+
hasTitle: BoolFlagSchema.optional(),
|
|
25372
|
+
cursor: z.string().optional(),
|
|
25373
|
+
limit: z.coerce.number().int().min(1).max(100).optional().default(20),
|
|
25374
|
+
content: z.enum(["markdown"]).optional()
|
|
25375
|
+
});
|
|
25376
|
+
publicArchiveApiRoutes.get("/", async (c) => {
|
|
25377
|
+
const { format, collection, year, media, hasMedia, hasTitle, cursor, limit, content } = parseValidated(ListPublicArchiveQuerySchema, c.req.query());
|
|
25378
|
+
let collectionIds;
|
|
25379
|
+
if (collection) {
|
|
25380
|
+
const slugExpression = collection.replace(/,/g, "+");
|
|
25381
|
+
const selection = await c.var.services.collections.resolveSelection(slugExpression);
|
|
25382
|
+
if (!selection || selection.collections.length === 0) return c.json({
|
|
25383
|
+
posts: [],
|
|
25384
|
+
nextCursor: null
|
|
25385
|
+
});
|
|
25386
|
+
collectionIds = selection.collections.map((col) => col.id);
|
|
25387
|
+
}
|
|
25388
|
+
const publishedAfter = year !== void 0 ? Date.UTC(year, 0, 1) / 1e3 : void 0;
|
|
25389
|
+
const publishedBefore = year !== void 0 ? Date.UTC(year + 1, 0, 1) / 1e3 : void 0;
|
|
25390
|
+
const posts = await c.var.services.posts.list({
|
|
25391
|
+
format,
|
|
25392
|
+
collectionIds,
|
|
25393
|
+
status: "published",
|
|
25394
|
+
cursor: cursor ?? void 0,
|
|
25395
|
+
limit,
|
|
25396
|
+
excludePrivate: true,
|
|
25397
|
+
excludeLatestHidden: false,
|
|
25398
|
+
excludeReplies: true,
|
|
25399
|
+
publishedAfter,
|
|
25400
|
+
publishedBefore,
|
|
25401
|
+
mediaKinds: media,
|
|
25402
|
+
hasMedia,
|
|
25403
|
+
hasTitle
|
|
25404
|
+
});
|
|
25405
|
+
const postIds = posts.map((post) => post.id);
|
|
25406
|
+
const [mediaMap, collectionsMap] = await Promise.all([c.var.services.media.getByPostIds(postIds), c.var.services.collections.getCollectionsByPostIds(postIds)]);
|
|
25407
|
+
return c.json({
|
|
25408
|
+
posts: posts.map((post) => toPublicPost(post, mediaMap.get(post.id) ?? [], collectionsMap.get(post.id) ?? [], c.var.appConfig, { content })),
|
|
25409
|
+
nextCursor: posts.length === limit ? posts[posts.length - 1]?.id ?? null : null
|
|
25410
|
+
});
|
|
25411
|
+
});
|
|
25412
|
+
//#endregion
|
|
25347
25413
|
//#region src/routes/compose.tsx
|
|
25348
25414
|
/**
|
|
25349
25415
|
* Compose Route
|
|
@@ -31993,6 +32059,7 @@ async function servePublicStorage(c) {
|
|
|
31993
32059
|
app.use("*", withConfig());
|
|
31994
32060
|
app.use("*", i18nMiddleware());
|
|
31995
32061
|
app.route("/api/public/posts", publicPostsApiRoutes);
|
|
32062
|
+
app.route("/api/public/archive", publicArchiveApiRoutes);
|
|
31996
32063
|
app.route("/api/posts", postsApiRoutes);
|
|
31997
32064
|
app.route("/api/nav-items", navItemsApiRoutes);
|
|
31998
32065
|
app.route("/api/collections", collectionsApiRoutes);
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { _ as url_exports } from "./url-umUptr5z.js";
|
|
2
|
-
import { A as NAV_ITEM_TYPES, C as toPostView, D as MAX_MEDIA_ATTACHMENTS, E as FORMATS, M as STATUSES, N as TEXT_ATTACHMENT_CONTENT_FORMATS, O as MAX_PINNED_POSTS, S as toNavItemViews, T as toSearchResultView, _ as createMediaContext, b as toMediaView, f as defaultFeedRenderer, j as SORT_ORDERS, k as MEDIA_KINDS, t as createApp, v as toArchiveGroups, w as toPostViews, x as toNavItemView, y as toArchiveGroupsWithMedia } from "./app-
|
|
2
|
+
import { A as NAV_ITEM_TYPES, C as toPostView, D as MAX_MEDIA_ATTACHMENTS, E as FORMATS, M as STATUSES, N as TEXT_ATTACHMENT_CONTENT_FORMATS, O as MAX_PINNED_POSTS, S as toNavItemViews, T as toSearchResultView, _ as createMediaContext, b as toMediaView, f as defaultFeedRenderer, j as SORT_ORDERS, k as MEDIA_KINDS, t as createApp, v as toArchiveGroups, w as toPostViews, x as toNavItemView, y as toArchiveGroupsWithMedia } from "./app-C8bKBHtv.js";
|
|
3
3
|
import { T as time_exports, a as markdown_exports } from "./export-ZBlfKSKm.js";
|
|
4
4
|
import "./env-CgaH9Mut.js";
|
|
5
5
|
import "./github-sync-bL1hnx3Q.js";
|
package/dist/node.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import "./url-umUptr5z.js";
|
|
2
|
-
import { F as BUILTIN_COLOR_THEMES, I as getPublicAssetBasePath, L as isAssetPath, P as buildThemeStyle, a as resolveDatabaseDialect, c as resolveConfig, d as getFontThemeCssVariables, g as schema_exports, h as createNodeDatabase, i as createSiteService, l as BUILTIN_FONT_THEMES, m as sqliteSchemaBundle, n as createNodeCliRuntime, o as getHostBasedStartupConfigurationIssues, p as pgSchemaBundle, r as createNodeRequestRuntime, s as createStorageDriver, t as createApp, u as getCjkSerifCssVariables } from "./app-
|
|
2
|
+
import { F as BUILTIN_COLOR_THEMES, I as getPublicAssetBasePath, L as isAssetPath, P as buildThemeStyle, a as resolveDatabaseDialect, c as resolveConfig, d as getFontThemeCssVariables, g as schema_exports, h as createNodeDatabase, i as createSiteService, l as BUILTIN_FONT_THEMES, m as sqliteSchemaBundle, n as createNodeCliRuntime, o as getHostBasedStartupConfigurationIssues, p as pgSchemaBundle, r as createNodeRequestRuntime, s as createStorageDriver, t as createApp, u as getCjkSerifCssVariables } from "./app-C8bKBHtv.js";
|
|
3
3
|
import { t as createExportService } from "./export-ZBlfKSKm.js";
|
|
4
4
|
import { b as getSiteResolutionMode, i as getConfiguredSingleSitePathPrefix, l as getEnvString, r as getConfiguredSingleSiteOrigin, x as shouldTrustProxy, y as getPort } from "./env-CgaH9Mut.js";
|
|
5
5
|
import "./github-sync-bL1hnx3Q.js";
|
|
@@ -474,7 +474,7 @@ async function createNodeRequestHandler(options) {
|
|
|
474
474
|
async function start(env = process.env, app) {
|
|
475
475
|
const handler = await createNodeRequestHandler({
|
|
476
476
|
env,
|
|
477
|
-
app: async () => app ?? (await import("./app-
|
|
477
|
+
app: async () => app ?? (await import("./app-DxnM9H8F.js")).createApp()
|
|
478
478
|
});
|
|
479
479
|
const hostname = resolveHost(env);
|
|
480
480
|
const port = resolvePort(env);
|
package/package.json
CHANGED
package/src/app.tsx
CHANGED
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
import { internalTextAttachmentsRoutes } from "./routes/api/internal/text-attachments.js";
|
|
56
56
|
import { internalSearchReindexRoutes } from "./routes/api/internal/search-reindex.js";
|
|
57
57
|
import { internalUploadsRoutes } from "./routes/api/internal/uploads.js";
|
|
58
|
+
import { publicArchiveApiRoutes } from "./routes/api/public/archive.js";
|
|
58
59
|
import { publicPostsApiRoutes } from "./routes/api/public/posts.js";
|
|
59
60
|
// Routes - Compose
|
|
60
61
|
import { composeRoutes } from "./routes/compose.js";
|
|
@@ -504,6 +505,7 @@ export function createApp(): App {
|
|
|
504
505
|
|
|
505
506
|
// API Routes
|
|
506
507
|
app.route("/api/public/posts", publicPostsApiRoutes);
|
|
508
|
+
app.route("/api/public/archive", publicArchiveApiRoutes);
|
|
507
509
|
app.route("/api/posts", postsApiRoutes);
|
|
508
510
|
app.route("/api/nav-items", navItemsApiRoutes);
|
|
509
511
|
app.route("/api/collections", collectionsApiRoutes);
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createTestApp } from "../../../../__tests__/helpers/app.js";
|
|
3
|
+
import { publicArchiveApiRoutes } from "../archive.js";
|
|
4
|
+
|
|
5
|
+
describe("Public Archive API Routes", () => {
|
|
6
|
+
describe("GET /api/public/archive", () => {
|
|
7
|
+
it("includes latest_hidden posts but excludes private and drafts", async () => {
|
|
8
|
+
const { app, services } = createTestApp({ authenticated: false });
|
|
9
|
+
app.route("/api/public/archive", publicArchiveApiRoutes);
|
|
10
|
+
|
|
11
|
+
const publicRoot = await services.posts.create({
|
|
12
|
+
format: "note",
|
|
13
|
+
title: "Public root",
|
|
14
|
+
bodyMarkdown: "visible",
|
|
15
|
+
});
|
|
16
|
+
const hiddenRoot = await services.posts.create({
|
|
17
|
+
format: "note",
|
|
18
|
+
title: "Latest hidden root",
|
|
19
|
+
bodyMarkdown: "hidden from latest",
|
|
20
|
+
visibility: "latest_hidden",
|
|
21
|
+
});
|
|
22
|
+
await services.posts.create({
|
|
23
|
+
format: "note",
|
|
24
|
+
title: "Private root",
|
|
25
|
+
bodyMarkdown: "private",
|
|
26
|
+
visibility: "private",
|
|
27
|
+
});
|
|
28
|
+
await services.posts.create({
|
|
29
|
+
format: "note",
|
|
30
|
+
title: "Draft root",
|
|
31
|
+
bodyMarkdown: "draft",
|
|
32
|
+
status: "draft",
|
|
33
|
+
});
|
|
34
|
+
await services.posts.create({
|
|
35
|
+
format: "note",
|
|
36
|
+
bodyMarkdown: "reply",
|
|
37
|
+
replyToId: publicRoot.id,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const res = await app.request("/api/public/archive");
|
|
41
|
+
expect(res.status).toBe(200);
|
|
42
|
+
|
|
43
|
+
const body = await res.json();
|
|
44
|
+
expect(body.nextCursor).toBeNull();
|
|
45
|
+
expect(body.posts).toHaveLength(2);
|
|
46
|
+
const ids = body.posts.map((p: { id: string }) => p.id);
|
|
47
|
+
expect(ids).toContain(publicRoot.id);
|
|
48
|
+
expect(ids).toContain(hiddenRoot.id);
|
|
49
|
+
const hidden = body.posts.find(
|
|
50
|
+
(p: { id: string }) => p.id === hiddenRoot.id,
|
|
51
|
+
);
|
|
52
|
+
expect(hidden.visibility).toBe("latest_hidden");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("supports format and limit filters with cursor", async () => {
|
|
56
|
+
const { app, services } = createTestApp({ authenticated: false });
|
|
57
|
+
app.route("/api/public/archive", publicArchiveApiRoutes);
|
|
58
|
+
|
|
59
|
+
await services.posts.create({
|
|
60
|
+
format: "note",
|
|
61
|
+
title: "Note one",
|
|
62
|
+
bodyMarkdown: "first",
|
|
63
|
+
});
|
|
64
|
+
await services.posts.create({
|
|
65
|
+
format: "note",
|
|
66
|
+
title: "Note two",
|
|
67
|
+
bodyMarkdown: "second",
|
|
68
|
+
visibility: "latest_hidden",
|
|
69
|
+
});
|
|
70
|
+
await services.posts.create({
|
|
71
|
+
format: "link",
|
|
72
|
+
title: "Example",
|
|
73
|
+
url: "https://example.com",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const res = await app.request("/api/public/archive?format=note&limit=1");
|
|
77
|
+
expect(res.status).toBe(200);
|
|
78
|
+
const body = await res.json();
|
|
79
|
+
expect(body.posts).toHaveLength(1);
|
|
80
|
+
expect(body.posts[0].format).toBe("note");
|
|
81
|
+
expect(body.nextCursor).toBeTruthy();
|
|
82
|
+
|
|
83
|
+
const next = await app.request(
|
|
84
|
+
`/api/public/archive?format=note&limit=1&cursor=${body.nextCursor}`,
|
|
85
|
+
);
|
|
86
|
+
const nextBody = await next.json();
|
|
87
|
+
expect(nextBody.posts).toHaveLength(1);
|
|
88
|
+
expect(nextBody.posts[0].id).not.toBe(body.posts[0].id);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("filters by year using publishedAt", async () => {
|
|
92
|
+
const { app, services } = createTestApp({ authenticated: false });
|
|
93
|
+
app.route("/api/public/archive", publicArchiveApiRoutes);
|
|
94
|
+
|
|
95
|
+
const inYear = await services.posts.create({
|
|
96
|
+
format: "note",
|
|
97
|
+
title: "From 2024",
|
|
98
|
+
bodyMarkdown: "in",
|
|
99
|
+
publishedAt: Math.floor(Date.UTC(2024, 5, 1) / 1000),
|
|
100
|
+
});
|
|
101
|
+
await services.posts.create({
|
|
102
|
+
format: "note",
|
|
103
|
+
title: "From 2023",
|
|
104
|
+
bodyMarkdown: "out",
|
|
105
|
+
publishedAt: Math.floor(Date.UTC(2023, 5, 1) / 1000),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const res = await app.request("/api/public/archive?year=2024");
|
|
109
|
+
expect(res.status).toBe(200);
|
|
110
|
+
const body = await res.json();
|
|
111
|
+
expect(body.posts).toHaveLength(1);
|
|
112
|
+
expect(body.posts[0].id).toBe(inYear.id);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("filters by hasTitle and hasMedia", async () => {
|
|
116
|
+
const { app, services } = createTestApp({ authenticated: false });
|
|
117
|
+
app.route("/api/public/archive", publicArchiveApiRoutes);
|
|
118
|
+
|
|
119
|
+
const titled = await services.posts.create({
|
|
120
|
+
format: "note",
|
|
121
|
+
title: "Has title",
|
|
122
|
+
bodyMarkdown: "body",
|
|
123
|
+
});
|
|
124
|
+
const untitled = await services.posts.create({
|
|
125
|
+
format: "note",
|
|
126
|
+
bodyMarkdown: "body without title",
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const withTitle = await app.request("/api/public/archive?hasTitle=1");
|
|
130
|
+
const withTitleBody = await withTitle.json();
|
|
131
|
+
expect(withTitleBody.posts.map((p: { id: string }) => p.id)).toEqual([
|
|
132
|
+
titled.id,
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
const noTitle = await app.request("/api/public/archive?hasTitle=0");
|
|
136
|
+
const noTitleBody = await noTitle.json();
|
|
137
|
+
expect(noTitleBody.posts.map((p: { id: string }) => p.id)).toEqual([
|
|
138
|
+
untitled.id,
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
const noMedia = await app.request("/api/public/archive?hasMedia=0");
|
|
142
|
+
const noMediaBody = await noMedia.json();
|
|
143
|
+
expect(noMediaBody.posts).toHaveLength(2);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("filters by media kind", async () => {
|
|
147
|
+
const { app, services } = createTestApp({ authenticated: false });
|
|
148
|
+
app.route("/api/public/archive", publicArchiveApiRoutes);
|
|
149
|
+
|
|
150
|
+
const withImage = await services.posts.create({
|
|
151
|
+
format: "note",
|
|
152
|
+
title: "Image post",
|
|
153
|
+
bodyMarkdown: "image body",
|
|
154
|
+
});
|
|
155
|
+
const image = await services.media.create({
|
|
156
|
+
filename: "pic.jpg",
|
|
157
|
+
originalName: "pic.jpg",
|
|
158
|
+
mimeType: "image/jpeg",
|
|
159
|
+
size: 1024,
|
|
160
|
+
storageKey: "media/pic.jpg",
|
|
161
|
+
});
|
|
162
|
+
await services.media.attachToPost(withImage.id, [image.id]);
|
|
163
|
+
|
|
164
|
+
await services.posts.create({
|
|
165
|
+
format: "note",
|
|
166
|
+
title: "Plain post",
|
|
167
|
+
bodyMarkdown: "no media",
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const res = await app.request("/api/public/archive?media=image");
|
|
171
|
+
const body = await res.json();
|
|
172
|
+
expect(body.posts).toHaveLength(1);
|
|
173
|
+
expect(body.posts[0].id).toBe(withImage.id);
|
|
174
|
+
|
|
175
|
+
const hasMediaRes = await app.request("/api/public/archive?hasMedia=1");
|
|
176
|
+
const hasMediaBody = await hasMediaRes.json();
|
|
177
|
+
expect(hasMediaBody.posts).toHaveLength(1);
|
|
178
|
+
expect(hasMediaBody.posts[0].id).toBe(withImage.id);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("filters by collection (single and aggregate)", async () => {
|
|
182
|
+
const { app, services } = createTestApp({ authenticated: false });
|
|
183
|
+
app.route("/api/public/archive", publicArchiveApiRoutes);
|
|
184
|
+
|
|
185
|
+
const tech = await services.collections.create({
|
|
186
|
+
slug: "tech",
|
|
187
|
+
title: "Tech",
|
|
188
|
+
});
|
|
189
|
+
const art = await services.collections.create({
|
|
190
|
+
slug: "art",
|
|
191
|
+
title: "Art",
|
|
192
|
+
});
|
|
193
|
+
const techPost = await services.posts.create({
|
|
194
|
+
format: "note",
|
|
195
|
+
title: "Tech",
|
|
196
|
+
bodyMarkdown: "t",
|
|
197
|
+
collectionIds: [tech.id],
|
|
198
|
+
});
|
|
199
|
+
const artPost = await services.posts.create({
|
|
200
|
+
format: "note",
|
|
201
|
+
title: "Art",
|
|
202
|
+
bodyMarkdown: "a",
|
|
203
|
+
collectionIds: [art.id],
|
|
204
|
+
visibility: "latest_hidden",
|
|
205
|
+
});
|
|
206
|
+
await services.posts.create({
|
|
207
|
+
format: "note",
|
|
208
|
+
title: "Other",
|
|
209
|
+
bodyMarkdown: "o",
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const single = await app.request("/api/public/archive?collection=tech");
|
|
213
|
+
const singleBody = await single.json();
|
|
214
|
+
expect(singleBody.posts.map((p: { id: string }) => p.id)).toEqual([
|
|
215
|
+
techPost.id,
|
|
216
|
+
]);
|
|
217
|
+
|
|
218
|
+
const aggregate = await app.request(
|
|
219
|
+
"/api/public/archive?collection=tech,art",
|
|
220
|
+
);
|
|
221
|
+
const aggregateBody = await aggregate.json();
|
|
222
|
+
const aggregateIds = aggregateBody.posts.map((p: { id: string }) => p.id);
|
|
223
|
+
expect(aggregateIds).toContain(techPost.id);
|
|
224
|
+
expect(aggregateIds).toContain(artPost.id);
|
|
225
|
+
expect(aggregateBody.posts).toHaveLength(2);
|
|
226
|
+
|
|
227
|
+
const unknown = await app.request(
|
|
228
|
+
"/api/public/archive?collection=nonexistent",
|
|
229
|
+
);
|
|
230
|
+
const unknownBody = await unknown.json();
|
|
231
|
+
expect(unknownBody.posts).toEqual([]);
|
|
232
|
+
expect(unknownBody.nextCursor).toBeNull();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("returns markdown instead of rendered fields when content=markdown", async () => {
|
|
236
|
+
const { app, services } = createTestApp({ authenticated: false });
|
|
237
|
+
app.route("/api/public/archive", publicArchiveApiRoutes);
|
|
238
|
+
|
|
239
|
+
await services.posts.create({
|
|
240
|
+
format: "note",
|
|
241
|
+
title: "Markdown post",
|
|
242
|
+
bodyMarkdown: "# Hello\n\nBody",
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const res = await app.request("/api/public/archive?content=markdown");
|
|
246
|
+
const body = await res.json();
|
|
247
|
+
expect(body.posts).toHaveLength(1);
|
|
248
|
+
expect(body.posts[0].bodyMarkdown).toBe("# Hello\n\nBody");
|
|
249
|
+
expect(body.posts[0]).not.toHaveProperty("bodyHtml");
|
|
250
|
+
expect(body.posts[0]).not.toHaveProperty("bodyText");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("rejects invalid media kind", async () => {
|
|
254
|
+
const { app } = createTestApp({ authenticated: false });
|
|
255
|
+
app.route("/api/public/archive", publicArchiveApiRoutes);
|
|
256
|
+
|
|
257
|
+
const res = await app.request("/api/public/archive?media=invalid");
|
|
258
|
+
expect(res.status).toBe(400);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { Bindings } from "../../../types.js";
|
|
4
|
+
import type { AppVariables } from "../../../types/app-context.js";
|
|
5
|
+
import { MEDIA_KINDS } from "../../../types.js";
|
|
6
|
+
import { FormatSchema, parseValidated } from "../../../lib/schemas.js";
|
|
7
|
+
import { toPublicPost } from "./posts.js";
|
|
8
|
+
|
|
9
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
10
|
+
|
|
11
|
+
export const publicArchiveApiRoutes = new Hono<Env>();
|
|
12
|
+
|
|
13
|
+
const MediaKindSchema = z.enum(MEDIA_KINDS);
|
|
14
|
+
const MEDIA_KIND_LIST = MEDIA_KINDS.join(", ");
|
|
15
|
+
const INVALID_MEDIA_KIND_MESSAGE =
|
|
16
|
+
"Invalid media kind. Allowed: " + MEDIA_KIND_LIST;
|
|
17
|
+
|
|
18
|
+
const BoolFlagSchema = z.enum(["0", "1"]).transform((value) => value === "1");
|
|
19
|
+
|
|
20
|
+
const ListPublicArchiveQuerySchema = z.object({
|
|
21
|
+
format: FormatSchema.optional(),
|
|
22
|
+
collection: z.string().optional(),
|
|
23
|
+
year: z.coerce.number().int().min(1971).optional(),
|
|
24
|
+
media: z
|
|
25
|
+
.string()
|
|
26
|
+
.optional()
|
|
27
|
+
.transform((value, ctx) => {
|
|
28
|
+
if (!value) return undefined;
|
|
29
|
+
const parts = value
|
|
30
|
+
.split(",")
|
|
31
|
+
.map((part) => part.trim())
|
|
32
|
+
.filter((part) => part.length > 0);
|
|
33
|
+
if (parts.length === 0) return undefined;
|
|
34
|
+
const result = z.array(MediaKindSchema).safeParse(parts);
|
|
35
|
+
if (!result.success) {
|
|
36
|
+
ctx.addIssue({
|
|
37
|
+
code: z.ZodIssueCode.custom,
|
|
38
|
+
message: INVALID_MEDIA_KIND_MESSAGE,
|
|
39
|
+
});
|
|
40
|
+
return z.NEVER;
|
|
41
|
+
}
|
|
42
|
+
return result.data;
|
|
43
|
+
}),
|
|
44
|
+
hasMedia: BoolFlagSchema.optional(),
|
|
45
|
+
hasTitle: BoolFlagSchema.optional(),
|
|
46
|
+
cursor: z.string().optional(),
|
|
47
|
+
limit: z.coerce.number().int().min(1).max(100).optional().default(20),
|
|
48
|
+
content: z.enum(["markdown"]).optional(),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
publicArchiveApiRoutes.get("/", async (c) => {
|
|
52
|
+
const {
|
|
53
|
+
format,
|
|
54
|
+
collection,
|
|
55
|
+
year,
|
|
56
|
+
media,
|
|
57
|
+
hasMedia,
|
|
58
|
+
hasTitle,
|
|
59
|
+
cursor,
|
|
60
|
+
limit,
|
|
61
|
+
content,
|
|
62
|
+
} = parseValidated(ListPublicArchiveQuerySchema, c.req.query());
|
|
63
|
+
|
|
64
|
+
let collectionIds: string[] | undefined;
|
|
65
|
+
if (collection) {
|
|
66
|
+
// Accept both "tech,art" and "tech+art", matching the page URL convention.
|
|
67
|
+
const slugExpression = collection.replace(/,/g, "+");
|
|
68
|
+
const selection =
|
|
69
|
+
await c.var.services.collections.resolveSelection(slugExpression);
|
|
70
|
+
if (!selection || selection.collections.length === 0) {
|
|
71
|
+
return c.json({ posts: [], nextCursor: null });
|
|
72
|
+
}
|
|
73
|
+
collectionIds = selection.collections.map((col) => col.id);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const publishedAfter =
|
|
77
|
+
year !== undefined ? Date.UTC(year, 0, 1) / 1000 : undefined;
|
|
78
|
+
const publishedBefore =
|
|
79
|
+
year !== undefined ? Date.UTC(year + 1, 0, 1) / 1000 : undefined;
|
|
80
|
+
|
|
81
|
+
const posts = await c.var.services.posts.list({
|
|
82
|
+
format,
|
|
83
|
+
collectionIds,
|
|
84
|
+
status: "published",
|
|
85
|
+
cursor: cursor ?? undefined,
|
|
86
|
+
limit,
|
|
87
|
+
excludePrivate: true,
|
|
88
|
+
excludeLatestHidden: false,
|
|
89
|
+
excludeReplies: true,
|
|
90
|
+
publishedAfter,
|
|
91
|
+
publishedBefore,
|
|
92
|
+
mediaKinds: media,
|
|
93
|
+
hasMedia,
|
|
94
|
+
hasTitle,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const postIds = posts.map((post) => post.id);
|
|
98
|
+
const [mediaMap, collectionsMap] = await Promise.all([
|
|
99
|
+
c.var.services.media.getByPostIds(postIds),
|
|
100
|
+
c.var.services.collections.getCollectionsByPostIds(postIds),
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
return c.json({
|
|
104
|
+
posts: posts.map((post) =>
|
|
105
|
+
toPublicPost(
|
|
106
|
+
post,
|
|
107
|
+
mediaMap.get(post.id) ?? [],
|
|
108
|
+
collectionsMap.get(post.id) ?? [],
|
|
109
|
+
c.var.appConfig,
|
|
110
|
+
{ content },
|
|
111
|
+
),
|
|
112
|
+
),
|
|
113
|
+
nextCursor:
|
|
114
|
+
posts.length === limit ? (posts[posts.length - 1]?.id ?? null) : null,
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -39,7 +39,7 @@ const PublicPostContentQuerySchema = z.object({
|
|
|
39
39
|
content: z.enum(["markdown"]).optional(),
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
-
type PublicPostBaseResponse = {
|
|
42
|
+
export type PublicPostBaseResponse = {
|
|
43
43
|
id: string;
|
|
44
44
|
format: Post["format"];
|
|
45
45
|
status: "published";
|
|
@@ -73,16 +73,16 @@ type PublicPostBaseResponse = {
|
|
|
73
73
|
}[];
|
|
74
74
|
};
|
|
75
75
|
|
|
76
|
-
type PublicPostRenderedResponse = PublicPostBaseResponse & {
|
|
76
|
+
export type PublicPostRenderedResponse = PublicPostBaseResponse & {
|
|
77
77
|
bodyHtml: string | null;
|
|
78
78
|
bodyText: string | null;
|
|
79
79
|
};
|
|
80
80
|
|
|
81
|
-
type PublicPostMarkdownResponse = PublicPostBaseResponse & {
|
|
81
|
+
export type PublicPostMarkdownResponse = PublicPostBaseResponse & {
|
|
82
82
|
bodyMarkdown: string | null;
|
|
83
83
|
};
|
|
84
84
|
|
|
85
|
-
type PublicPostResponse =
|
|
85
|
+
export type PublicPostResponse =
|
|
86
86
|
| PublicPostRenderedResponse
|
|
87
87
|
| PublicPostMarkdownResponse;
|
|
88
88
|
|
|
@@ -94,7 +94,7 @@ function isPublicDetailVisible(post: Post | null): post is Post {
|
|
|
94
94
|
);
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
function toPublicPost(
|
|
97
|
+
export function toPublicPost(
|
|
98
98
|
post: Post,
|
|
99
99
|
mediaList: Media[],
|
|
100
100
|
postCollections: Collection[],
|