@jant/core 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/bin/jant.js +188 -0
  2. package/drizzle.config.ts +10 -0
  3. package/lingui.config.ts +16 -0
  4. package/package.json +116 -0
  5. package/src/app.tsx +377 -0
  6. package/src/assets/datastar.min.js +8 -0
  7. package/src/auth.ts +38 -0
  8. package/src/client.ts +6 -0
  9. package/src/db/index.ts +14 -0
  10. package/src/db/migrations/0000_solid_moon_knight.sql +118 -0
  11. package/src/db/migrations/0001_add_search_fts.sql +40 -0
  12. package/src/db/migrations/0002_collection_path.sql +2 -0
  13. package/src/db/migrations/0003_collection_path_nullable.sql +21 -0
  14. package/src/db/migrations/0004_media_uuid.sql +35 -0
  15. package/src/db/migrations/meta/0000_snapshot.json +784 -0
  16. package/src/db/migrations/meta/_journal.json +41 -0
  17. package/src/db/schema.ts +159 -0
  18. package/src/i18n/EXAMPLES.md +235 -0
  19. package/src/i18n/README.md +296 -0
  20. package/src/i18n/Trans.tsx +31 -0
  21. package/src/i18n/context.tsx +101 -0
  22. package/src/i18n/detect.ts +100 -0
  23. package/src/i18n/i18n.ts +62 -0
  24. package/src/i18n/index.ts +65 -0
  25. package/src/i18n/locales/en.po +875 -0
  26. package/src/i18n/locales/en.ts +1 -0
  27. package/src/i18n/locales/zh-Hans.po +875 -0
  28. package/src/i18n/locales/zh-Hans.ts +1 -0
  29. package/src/i18n/locales/zh-Hant.po +875 -0
  30. package/src/i18n/locales/zh-Hant.ts +1 -0
  31. package/src/i18n/locales.ts +14 -0
  32. package/src/i18n/middleware.ts +59 -0
  33. package/src/index.ts +42 -0
  34. package/src/lib/assets.ts +47 -0
  35. package/src/lib/constants.ts +67 -0
  36. package/src/lib/image.ts +107 -0
  37. package/src/lib/index.ts +9 -0
  38. package/src/lib/markdown.ts +93 -0
  39. package/src/lib/schemas.ts +92 -0
  40. package/src/lib/sqid.ts +79 -0
  41. package/src/lib/sse.ts +152 -0
  42. package/src/lib/time.ts +117 -0
  43. package/src/lib/url.ts +107 -0
  44. package/src/middleware/auth.ts +59 -0
  45. package/src/routes/api/posts.ts +127 -0
  46. package/src/routes/api/search.ts +53 -0
  47. package/src/routes/api/upload.ts +240 -0
  48. package/src/routes/dash/collections.tsx +341 -0
  49. package/src/routes/dash/index.tsx +89 -0
  50. package/src/routes/dash/media.tsx +551 -0
  51. package/src/routes/dash/pages.tsx +245 -0
  52. package/src/routes/dash/posts.tsx +202 -0
  53. package/src/routes/dash/redirects.tsx +155 -0
  54. package/src/routes/dash/settings.tsx +93 -0
  55. package/src/routes/feed/rss.ts +119 -0
  56. package/src/routes/feed/sitemap.ts +75 -0
  57. package/src/routes/pages/archive.tsx +223 -0
  58. package/src/routes/pages/collection.tsx +79 -0
  59. package/src/routes/pages/home.tsx +93 -0
  60. package/src/routes/pages/page.tsx +64 -0
  61. package/src/routes/pages/post.tsx +81 -0
  62. package/src/routes/pages/search.tsx +162 -0
  63. package/src/services/collection.ts +180 -0
  64. package/src/services/index.ts +40 -0
  65. package/src/services/media.ts +97 -0
  66. package/src/services/post.ts +279 -0
  67. package/src/services/redirect.ts +74 -0
  68. package/src/services/search.ts +117 -0
  69. package/src/services/settings.ts +76 -0
  70. package/src/theme/components/ActionButtons.tsx +98 -0
  71. package/src/theme/components/CrudPageHeader.tsx +48 -0
  72. package/src/theme/components/DangerZone.tsx +77 -0
  73. package/src/theme/components/EmptyState.tsx +56 -0
  74. package/src/theme/components/ListItemRow.tsx +24 -0
  75. package/src/theme/components/PageForm.tsx +114 -0
  76. package/src/theme/components/Pagination.tsx +196 -0
  77. package/src/theme/components/PostForm.tsx +122 -0
  78. package/src/theme/components/PostList.tsx +68 -0
  79. package/src/theme/components/ThreadView.tsx +118 -0
  80. package/src/theme/components/TypeBadge.tsx +28 -0
  81. package/src/theme/components/VisibilityBadge.tsx +33 -0
  82. package/src/theme/components/index.ts +12 -0
  83. package/src/theme/index.ts +24 -0
  84. package/src/theme/layouts/BaseLayout.tsx +49 -0
  85. package/src/theme/layouts/DashLayout.tsx +108 -0
  86. package/src/theme/layouts/index.ts +2 -0
  87. package/src/theme/styles/main.css +52 -0
  88. package/src/types.ts +222 -0
  89. package/static/assets/datastar.min.js +7 -0
  90. package/static/assets/image-processor.js +234 -0
  91. package/tsconfig.json +16 -0
  92. package/vite.config.ts +82 -0
  93. package/wrangler.toml +21 -0
package/src/lib/sse.ts ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Server-Sent Events (SSE) utilities for Datastar
3
+ *
4
+ * Provides helpers for streaming SSE responses that Datastar can consume.
5
+ * Datastar uses SSE for real-time UI updates without page reloads.
6
+ *
7
+ * @see https://data-star.dev/
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * app.post("/api/example", (c) => {
12
+ * return sse(c, async (stream) => {
13
+ * await stream.patchSignals({ loading: false });
14
+ * await stream.patchElements("#result", "<div>Done!</div>");
15
+ * });
16
+ * });
17
+ * ```
18
+ */
19
+
20
+ import type { Context } from "hono";
21
+
22
+ /**
23
+ * Patch modes for DOM updates
24
+ */
25
+ export type PatchMode = "morph" | "inner" | "outer" | "append" | "prepend" | "remove";
26
+
27
+ /**
28
+ * SSE stream writer for Datastar events
29
+ */
30
+ export interface SSEStream {
31
+ /**
32
+ * Update reactive signals on the client
33
+ *
34
+ * @param signals - Object containing signal values to update
35
+ *
36
+ * @example
37
+ * ```ts
38
+ * await stream.patchSignals({ count: 42, loading: false });
39
+ * ```
40
+ */
41
+ patchSignals(signals: Record<string, unknown>): Promise<void>;
42
+
43
+ /**
44
+ * Update DOM elements
45
+ *
46
+ * @param html - HTML content (must include element with id for targeting)
47
+ * @param options - Optional mode and selector
48
+ *
49
+ * @example
50
+ * ```ts
51
+ * // Replace element with matching id (default: morph)
52
+ * await stream.patchElements('<div id="content">New content</div>');
53
+ *
54
+ * // Append to a container
55
+ * await stream.patchElements('<div>New item</div>', {
56
+ * mode: 'append',
57
+ * selector: '#list'
58
+ * });
59
+ * ```
60
+ */
61
+ patchElements(
62
+ html: string,
63
+ options?: { mode?: PatchMode; selector?: string }
64
+ ): Promise<void>;
65
+
66
+ /**
67
+ * Execute JavaScript on the client
68
+ *
69
+ * @param script - JavaScript code to execute
70
+ *
71
+ * @example
72
+ * ```ts
73
+ * await stream.executeScript('console.log("Hello from server")');
74
+ * ```
75
+ */
76
+ executeScript(script: string): Promise<void>;
77
+ }
78
+
79
+ /**
80
+ * Create an SSE response for Datastar
81
+ *
82
+ * @param c - Hono context
83
+ * @param handler - Async function that writes to the SSE stream
84
+ * @returns Response with SSE content-type
85
+ *
86
+ * @example
87
+ * ```ts
88
+ * app.post("/api/upload", (c) => {
89
+ * return sse(c, async (stream) => {
90
+ * // Process upload...
91
+ * await stream.patchSignals({ uploading: false });
92
+ * await stream.patchElements('<div id="new-item">...</div>', {
93
+ * mode: 'append',
94
+ * selector: '#items'
95
+ * });
96
+ * });
97
+ * });
98
+ * ```
99
+ */
100
+ export function sse(
101
+ c: Context,
102
+ handler: (stream: SSEStream) => Promise<void>
103
+ ): Response {
104
+ const encoder = new TextEncoder();
105
+
106
+ const stream = new ReadableStream({
107
+ async start(controller) {
108
+ const write = (data: string) => {
109
+ controller.enqueue(encoder.encode(data));
110
+ };
111
+
112
+ const sseStream: SSEStream = {
113
+ async patchSignals(signals) {
114
+ write(`event: datastar-patch-signals\n`);
115
+ write(`data: signals ${JSON.stringify(signals)}\n\n`);
116
+ },
117
+
118
+ async patchElements(html, options = {}) {
119
+ write(`event: datastar-patch-elements\n`);
120
+ if (options.mode) {
121
+ write(`data: mode ${options.mode}\n`);
122
+ }
123
+ if (options.selector) {
124
+ write(`data: selector ${options.selector}\n`);
125
+ }
126
+ // Escape newlines in HTML for SSE format
127
+ const escapedHtml = html.replace(/\n/g, "\ndata: ");
128
+ write(`data: elements ${escapedHtml}\n\n`);
129
+ },
130
+
131
+ async executeScript(script) {
132
+ write(`event: datastar-execute-script\n`);
133
+ write(`data: script ${script}\n\n`);
134
+ },
135
+ };
136
+
137
+ try {
138
+ await handler(sseStream);
139
+ } finally {
140
+ controller.close();
141
+ }
142
+ },
143
+ });
144
+
145
+ return new Response(stream, {
146
+ headers: {
147
+ "Content-Type": "text/event-stream",
148
+ "Cache-Control": "no-cache",
149
+ Connection: "keep-alive",
150
+ },
151
+ });
152
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Time Utilities
3
+ */
4
+
5
+ /**
6
+ * Gets the current Unix timestamp in seconds.
7
+ *
8
+ * Returns the number of seconds since the Unix epoch (January 1, 1970 00:00:00 UTC).
9
+ * This is the standard time format used throughout the application for consistency
10
+ * and database storage.
11
+ *
12
+ * @returns Current Unix timestamp in seconds (not milliseconds)
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * const timestamp = now();
17
+ * // Returns: 1706745600 (example value for Feb 1, 2024)
18
+ * ```
19
+ */
20
+ export function now(): number {
21
+ return Math.floor(Date.now() / 1000);
22
+ }
23
+
24
+ /**
25
+ * One month in seconds
26
+ */
27
+ const ONE_MONTH = 30 * 24 * 60 * 60;
28
+
29
+ /**
30
+ * Checks if a Unix timestamp is within the last 30 days.
31
+ *
32
+ * Compares the given timestamp to the current time to determine if it falls within
33
+ * the last month (defined as 30 days). Useful for highlighting recent posts or
34
+ * filtering time-sensitive content.
35
+ *
36
+ * @param timestamp - Unix timestamp in seconds to check
37
+ * @returns `true` if the timestamp is within the last 30 days, `false` otherwise
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * const recentPost = 1706745600; // Recent timestamp
42
+ * if (isWithinMonth(recentPost)) {
43
+ * // Show "new" badge
44
+ * }
45
+ * ```
46
+ */
47
+ export function isWithinMonth(timestamp: number): boolean {
48
+ return now() - timestamp < ONE_MONTH;
49
+ }
50
+
51
+ /**
52
+ * Converts a Unix timestamp to an ISO 8601 date-time string.
53
+ *
54
+ * Formats a Unix timestamp (in seconds) as an ISO 8601 string suitable for HTML
55
+ * `datetime` attributes and API responses. The output includes full date, time,
56
+ * and timezone information in UTC.
57
+ *
58
+ * @param timestamp - Unix timestamp in seconds to convert
59
+ * @returns ISO 8601 formatted string (e.g., "2024-02-01T12:00:00.000Z")
60
+ *
61
+ * @example
62
+ * ```ts
63
+ * const isoDate = toISOString(1706745600);
64
+ * // Returns: "2024-02-01T00:00:00.000Z"
65
+ * ```
66
+ */
67
+ export function toISOString(timestamp: number): string {
68
+ return new Date(timestamp * 1000).toISOString();
69
+ }
70
+
71
+ /**
72
+ * Formats a Unix timestamp as a human-readable date string.
73
+ *
74
+ * Converts a Unix timestamp (in seconds) to a localized date string in the format
75
+ * "MMM DD, YYYY" (e.g., "Jan 15, 2024"). Always uses UTC timezone to ensure
76
+ * consistent display regardless of server or client location.
77
+ *
78
+ * @param timestamp - Unix timestamp in seconds to format
79
+ * @returns Formatted date string in "MMM DD, YYYY" format
80
+ *
81
+ * @example
82
+ * ```ts
83
+ * const readable = formatDate(1706745600);
84
+ * // Returns: "Feb 1, 2024"
85
+ * ```
86
+ */
87
+ export function formatDate(timestamp: number): string {
88
+ return new Date(timestamp * 1000).toLocaleDateString("en-US", {
89
+ year: "numeric",
90
+ month: "short",
91
+ day: "numeric",
92
+ timeZone: "UTC",
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Formats a Unix timestamp as a year-month string for grouping.
98
+ *
99
+ * Converts a Unix timestamp (in seconds) to a "YYYY-MM" format string, useful for
100
+ * grouping posts by month in archives or creating month-based URLs. Always uses
101
+ * UTC timezone for consistency.
102
+ *
103
+ * @param timestamp - Unix timestamp in seconds to format
104
+ * @returns Year-month string in "YYYY-MM" format
105
+ *
106
+ * @example
107
+ * ```ts
108
+ * const yearMonth = formatYearMonth(1706745600);
109
+ * // Returns: "2024-02"
110
+ * ```
111
+ */
112
+ export function formatYearMonth(timestamp: number): string {
113
+ const date = new Date(timestamp * 1000);
114
+ const year = date.getUTCFullYear();
115
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
116
+ return `${year}-${month}`;
117
+ }
package/src/lib/url.ts ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * URL Utilities
3
+ */
4
+
5
+ /**
6
+ * Extracts the hostname (domain) from a URL string.
7
+ *
8
+ * Parses a full URL and returns just the hostname portion (e.g., "example.com" from
9
+ * "https://example.com/path"). Returns `null` if the URL is malformed or cannot be parsed.
10
+ *
11
+ * @param url - The full URL string to extract the domain from
12
+ * @returns The hostname/domain if valid, or `null` if parsing fails
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * const domain = extractDomain("https://www.example.com/path");
17
+ * // Returns: "www.example.com"
18
+ *
19
+ * const invalid = extractDomain("not-a-url");
20
+ * // Returns: null
21
+ * ```
22
+ */
23
+ export function extractDomain(url: string): string | null {
24
+ try {
25
+ const parsed = new URL(url);
26
+ return parsed.hostname;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Normalizes a path by removing slashes and converting to lowercase.
34
+ *
35
+ * Trims whitespace, converts to lowercase, removes leading and trailing slashes,
36
+ * and collapses multiple consecutive slashes into single slashes. Used to create
37
+ * consistent path representations for routing and storage.
38
+ *
39
+ * @param path - The path string to normalize
40
+ * @returns The normalized path string
41
+ *
42
+ * @example
43
+ * ```ts
44
+ * const normalized = normalizePath(" /About/Contact// ");
45
+ * // Returns: "about/contact"
46
+ * ```
47
+ */
48
+ export function normalizePath(path: string): string {
49
+ return path
50
+ .trim()
51
+ .toLowerCase()
52
+ .replace(/^\/+|\/+$/g, "")
53
+ .replace(/\/+/g, "/");
54
+ }
55
+
56
+ /**
57
+ * Checks if a string is a full URL with HTTP or HTTPS protocol.
58
+ *
59
+ * Validates whether a string starts with "http://" or "https://", indicating it's
60
+ * a full URL rather than a relative path. Useful for distinguishing between internal
61
+ * paths and external URLs.
62
+ *
63
+ * @param str - The string to check
64
+ * @returns `true` if the string starts with http:// or https://, `false` otherwise
65
+ *
66
+ * @example
67
+ * ```ts
68
+ * isFullUrl("https://example.com"); // Returns: true
69
+ * isFullUrl("/about"); // Returns: false
70
+ * isFullUrl("example.com"); // Returns: false
71
+ * ```
72
+ */
73
+ export function isFullUrl(str: string): boolean {
74
+ return str.startsWith("http://") || str.startsWith("https://");
75
+ }
76
+
77
+ /**
78
+ * Converts text to a URL-friendly slug.
79
+ *
80
+ * Transforms text into a lowercase, hyphen-separated slug by:
81
+ * - Converting to lowercase
82
+ * - Removing special characters (keeping only word characters, spaces, and hyphens)
83
+ * - Replacing whitespace and underscores with hyphens
84
+ * - Removing leading and trailing hyphens
85
+ *
86
+ * Used for generating clean URLs from titles and names.
87
+ *
88
+ * @param text - The text to convert to a slug
89
+ * @returns The slugified string
90
+ *
91
+ * @example
92
+ * ```ts
93
+ * const slug = slugify("Hello World! This is a Test.");
94
+ * // Returns: "hello-world-this-is-a-test"
95
+ *
96
+ * const slug = slugify(" Multiple Spaces ");
97
+ * // Returns: "multiple-spaces"
98
+ * ```
99
+ */
100
+ export function slugify(text: string): string {
101
+ return text
102
+ .toLowerCase()
103
+ .trim()
104
+ .replace(/[^\w\s-]/g, "")
105
+ .replace(/[\s_-]+/g, "-")
106
+ .replace(/^-+|-+$/g, "");
107
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Authentication Middleware
3
+ *
4
+ * Protects routes by requiring authentication
5
+ */
6
+
7
+ import type { MiddlewareHandler } from "hono";
8
+ import type { Bindings } from "../types.js";
9
+ import type { AppVariables } from "../app.js";
10
+
11
+ type Env = { Bindings: Bindings; Variables: AppVariables };
12
+
13
+ /**
14
+ * Middleware that requires authentication.
15
+ * Redirects to signin page if not authenticated.
16
+ */
17
+ export function requireAuth(redirectTo = "/signin"): MiddlewareHandler<Env> {
18
+ return async (c, next) => {
19
+ if (!c.var.auth) {
20
+ return c.redirect(redirectTo);
21
+ }
22
+
23
+ try {
24
+ const session = await c.var.auth.api.getSession({ headers: c.req.raw.headers });
25
+
26
+ if (!session?.user) {
27
+ return c.redirect(redirectTo);
28
+ }
29
+
30
+ await next();
31
+ } catch {
32
+ return c.redirect(redirectTo);
33
+ }
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Middleware for API routes that requires authentication.
39
+ * Returns 401 if not authenticated.
40
+ */
41
+ export function requireAuthApi(): MiddlewareHandler<Env> {
42
+ return async (c, next) => {
43
+ if (!c.var.auth) {
44
+ return c.json({ error: "Authentication not configured" }, 500);
45
+ }
46
+
47
+ try {
48
+ const session = await c.var.auth.api.getSession({ headers: c.req.raw.headers });
49
+
50
+ if (!session?.user) {
51
+ return c.json({ error: "Unauthorized" }, 401);
52
+ }
53
+
54
+ await next();
55
+ } catch {
56
+ return c.json({ error: "Unauthorized" }, 401);
57
+ }
58
+ };
59
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Posts API Routes
3
+ */
4
+
5
+ import { Hono } from "hono";
6
+ import type { Bindings, PostType, Visibility } from "../../types.js";
7
+ import type { AppVariables } from "../../app.js";
8
+ import * as sqid from "../../lib/sqid.js";
9
+ import { CreatePostSchema, UpdatePostSchema } from "../../lib/schemas.js";
10
+ import { requireAuthApi } from "../../middleware/auth.js";
11
+
12
+ type Env = { Bindings: Bindings; Variables: AppVariables };
13
+
14
+ export const postsApiRoutes = new Hono<Env>();
15
+
16
+ // List posts
17
+ postsApiRoutes.get("/", async (c) => {
18
+ const type = c.req.query("type") as PostType | undefined;
19
+ const visibility = c.req.query("visibility") as Visibility | undefined;
20
+ const cursor = c.req.query("cursor");
21
+ const limit = parseInt(c.req.query("limit") ?? "100", 10);
22
+
23
+ const posts = await c.var.services.posts.list({
24
+ type,
25
+ visibility: visibility ? [visibility] : ["featured", "quiet"],
26
+ cursor: cursor ? sqid.decode(cursor) ?? undefined : undefined,
27
+ limit,
28
+ });
29
+
30
+ return c.json({
31
+ posts: posts.map((p) => ({
32
+ ...p,
33
+ sqid: sqid.encode(p.id),
34
+ })),
35
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Array length check guarantees element exists
36
+ nextCursor: posts.length === limit ? sqid.encode(posts[posts.length - 1]!.id) : null,
37
+ });
38
+ });
39
+
40
+ // Get single post
41
+ postsApiRoutes.get("/:id", async (c) => {
42
+ const id = sqid.decode(c.req.param("id"));
43
+ if (!id) return c.json({ error: "Invalid ID" }, 400);
44
+
45
+ const post = await c.var.services.posts.getById(id);
46
+ if (!post) return c.json({ error: "Not found" }, 404);
47
+
48
+ return c.json({ ...post, sqid: sqid.encode(post.id) });
49
+ });
50
+
51
+ // Create post (requires auth)
52
+ postsApiRoutes.post("/", requireAuthApi(), async (c) => {
53
+
54
+ const rawBody = await c.req.json();
55
+
56
+ // Validate request body
57
+ const parseResult = CreatePostSchema.safeParse(rawBody);
58
+ if (!parseResult.success) {
59
+ return c.json(
60
+ { error: "Validation failed", details: parseResult.error.flatten() },
61
+ 400
62
+ );
63
+ }
64
+
65
+ const body = parseResult.data;
66
+
67
+ const post = await c.var.services.posts.create({
68
+ type: body.type,
69
+ title: body.title,
70
+ content: body.content,
71
+ visibility: body.visibility,
72
+ sourceUrl: body.sourceUrl || undefined,
73
+ sourceName: body.sourceName,
74
+ path: body.path || undefined,
75
+ replyToId: body.replyToId ? sqid.decode(body.replyToId) ?? undefined : undefined,
76
+ publishedAt: body.publishedAt,
77
+ });
78
+
79
+ return c.json({ ...post, sqid: sqid.encode(post.id) }, 201);
80
+ });
81
+
82
+ // Update post (requires auth)
83
+ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
84
+
85
+ const id = sqid.decode(c.req.param("id"));
86
+ if (!id) return c.json({ error: "Invalid ID" }, 400);
87
+
88
+ const rawBody = await c.req.json();
89
+
90
+ // Validate request body
91
+ const parseResult = UpdatePostSchema.safeParse(rawBody);
92
+ if (!parseResult.success) {
93
+ return c.json(
94
+ { error: "Validation failed", details: parseResult.error.flatten() },
95
+ 400
96
+ );
97
+ }
98
+
99
+ const body = parseResult.data;
100
+
101
+ const post = await c.var.services.posts.update(id, {
102
+ type: body.type,
103
+ title: body.title,
104
+ content: body.content,
105
+ visibility: body.visibility,
106
+ sourceUrl: body.sourceUrl,
107
+ sourceName: body.sourceName,
108
+ path: body.path,
109
+ publishedAt: body.publishedAt,
110
+ });
111
+
112
+ if (!post) return c.json({ error: "Not found" }, 404);
113
+
114
+ return c.json({ ...post, sqid: sqid.encode(post.id) });
115
+ });
116
+
117
+ // Delete post (requires auth)
118
+ postsApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
119
+
120
+ const id = sqid.decode(c.req.param("id"));
121
+ if (!id) return c.json({ error: "Invalid ID" }, 400);
122
+
123
+ const success = await c.var.services.posts.delete(id);
124
+ if (!success) return c.json({ error: "Not found" }, 404);
125
+
126
+ return c.json({ success: true });
127
+ });
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Search API Routes
3
+ */
4
+
5
+ import { Hono } from "hono";
6
+ import type { Bindings } from "../../types.js";
7
+ import type { AppVariables } from "../../app.js";
8
+ import * as sqid from "../../lib/sqid.js";
9
+
10
+ type Env = { Bindings: Bindings; Variables: AppVariables };
11
+
12
+ export const searchApiRoutes = new Hono<Env>();
13
+
14
+ // Search posts
15
+ searchApiRoutes.get("/", async (c) => {
16
+ const query = c.req.query("q");
17
+
18
+ if (!query || query.trim().length === 0) {
19
+ return c.json({ error: "Query parameter 'q' is required" }, 400);
20
+ }
21
+
22
+ if (query.length > 200) {
23
+ return c.json({ error: "Query too long" }, 400);
24
+ }
25
+
26
+ const limitParam = c.req.query("limit");
27
+ const limit = limitParam ? Math.min(parseInt(limitParam, 10) || 20, 50) : 20;
28
+
29
+ try {
30
+ const results = await c.var.services.search.search(query, {
31
+ limit,
32
+ visibility: ["featured", "quiet"],
33
+ });
34
+
35
+ return c.json({
36
+ query,
37
+ results: results.map((r) => ({
38
+ id: sqid.encode(r.post.id),
39
+ type: r.post.type,
40
+ title: r.post.title,
41
+ path: r.post.path,
42
+ snippet: r.snippet,
43
+ publishedAt: r.post.publishedAt,
44
+ url: `/p/${sqid.encode(r.post.id)}`,
45
+ })),
46
+ count: results.length,
47
+ });
48
+ } catch (err) {
49
+ // eslint-disable-next-line no-console -- Error logging is intentional
50
+ console.error("Search error:", err);
51
+ return c.json({ error: "Search failed" }, 500);
52
+ }
53
+ });