@jant/core 0.3.47 → 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/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-3REcR-3U.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-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-3REcR-3U.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-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-B67XOEyo.js")).createApp()
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jant/core",
3
- "version": "0.3.47",
3
+ "version": "0.3.49",
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);
@@ -42,13 +42,15 @@ export function toApiAttachment(
42
42
  };
43
43
  }
44
44
 
45
- const previewUrl = getImageUrl(url, imageTransformUrl, {
46
- width: 1200,
47
- height: 768,
48
- quality: 80,
49
- format: "auto",
50
- fit: "scale-down",
51
- });
45
+ const previewUrl = media.mimeType.startsWith("image/")
46
+ ? getImageUrl(url, imageTransformUrl, {
47
+ width: 1200,
48
+ height: 768,
49
+ quality: 80,
50
+ format: "auto",
51
+ fit: "scale-down",
52
+ })
53
+ : url;
52
54
  const posterUrl = media.posterKey
53
55
  ? getMediaUrl(media.posterKey, publicUrl, sitePathPrefix)
54
56
  : null;
@@ -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[],
package/src/styles/ui.css CHANGED
@@ -6832,7 +6832,7 @@
6832
6832
  .compose-attachment-img {
6833
6833
  display: block;
6834
6834
  border-radius: var(--media-radius, 0.5rem);
6835
- max-height: 320px;
6835
+ max-height: min(320px, 28dvh);
6836
6836
  object-fit: contain;
6837
6837
  }
6838
6838
 
@@ -6869,7 +6869,7 @@
6869
6869
  /* Single image: constrain to container width */
6870
6870
  .compose-attachment:only-child .compose-attachment-img {
6871
6871
  max-width: 100%;
6872
- max-height: 384px;
6872
+ max-height: min(384px, 32dvh);
6873
6873
  }
6874
6874
 
6875
6875
  .compose-attachment:only-child .compose-attachment-preview-fallback {