@jant/core 0.3.48 → 0.3.50

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.
@@ -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.48-5b409c34fc5f0c4a";
3421
+ var CORE_VERSION = "0.3.50-947a76e8ff575c8d";
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.48-5b409c34fc5f0c4a";
3742
+ const faviconAssetVersion = resolvedFaviconVersion || "0.3.50-947a76e8ff575c8d";
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));
@@ -8811,7 +8811,7 @@ function getMediaPlaceholderDataUrl(blurhash, width, height) {
8811
8811
  return blurhashToDataUrl(blurhash, decodeSize.width, decodeSize.height);
8812
8812
  }
8813
8813
  function getSingleVisualWidth(ratio) {
8814
- return `min(100%, calc(24rem * ${ratio}))`;
8814
+ return `min(100%, calc(24rem * ${ratio}), var(--layout-content-width))`;
8815
8815
  }
8816
8816
  /**
8817
8817
  * Format-specific file icon. Each MIME type gets a visually distinct icon
@@ -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);
@@ -1,5 +1,5 @@
1
1
  import "./url-umUptr5z.js";
2
- import { t as createApp } from "./app-DdnIoX7y.js";
2
+ import { t as createApp } from "./app-C7CtIQM-.js";
3
3
  import "./export-ZBlfKSKm.js";
4
4
  import "./env-CgaH9Mut.js";
5
5
  import "./github-sync-bL1hnx3Q.js";
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-DdnIoX7y.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-C7CtIQM-.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-DdnIoX7y.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-C7CtIQM-.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-DU7dpJID.js")).createApp()
477
+ app: async () => app ?? (await import("./app-CIx9SSOi.js")).createApp()
478
478
  });
479
479
  const hostname = resolveHost(env);
480
480
  const port = resolvePort(env);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jant/core",
3
- "version": "0.3.48",
3
+ "version": "0.3.50",
4
4
  "description": "A modern, open-source microblogging platform built on Cloudflare Workers",
5
5
  "type": "module",
6
6
  "exports": {
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[],
@@ -64,7 +64,7 @@ export function getMediaPlaceholderDataUrl(
64
64
  }
65
65
 
66
66
  function getSingleVisualWidth(ratio: number): string {
67
- return `min(100%, calc(24rem * ${ratio}))`;
67
+ return `min(100%, calc(24rem * ${ratio}), var(--layout-content-width))`;
68
68
  }
69
69
 
70
70
  /**
@@ -50,7 +50,9 @@ describe("MediaGallery", () => {
50
50
  );
51
51
 
52
52
  expect(html).toContain("aspect-ratio:900/1600");
53
- expect(html).toMatch(/width:min\(100%, ?calc\(24rem ?\* ?0\.5625\)\)/);
53
+ expect(html).toMatch(
54
+ /width:min\(100%, ?calc\(24rem ?\* ?0\.5625\), ?var\(--layout-content-width\)\)/,
55
+ );
54
56
  expect(html).not.toContain("object-contain");
55
57
  });
56
58