@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
@@ -0,0 +1 @@
1
+ /*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"+7Wr2a\":[\"編輯: \",[\"title\"]],\"+MACwa\":[\"尚未有任何收藏。\"],\"+owNNn\":[\"文章\"],\"+zy2Nq\":[\"類型\"],\"/0D1Xp\":[\"編輯收藏集\"],\"/Rj5P4\":[\"您的姓名\"],\"0JkyS7\":[\"建立您的第一個頁面\"],\"0a6MpL\":[\"新重定向\"],\"1CU1Td\":[\"網址安全識別碼(小寫、數字、連字符)\"],\"1DBGsz\":[\"筆記\"],\"2N0qpv\":[\"文章標題...\"],\"2q/Q7x\":[\"可見性\"],\"2rJGtU\":[\"頁面標題...\"],\"3Yvsaz\":[\"302(臨時)\"],\"4/SFQS\":[\"查看網站\"],\"40TVQj\":[\"自訂路徑(選填)\"],\"4KzVT6\":[\"刪除頁面\"],\"4b3oEV\":[\"內容\"],\"4mDPGp\":[\"此頁面的 URL 路徑。使用小寫字母、數字和連字符。\"],\"6WdDG7\":[\"頁面\"],\"7G4SBz\":[\"頁面內容(支持Markdown)...\"],\"7Mk+/h\":[\"更新收藏集\"],\"7Q1KKN\":[\"來源路徑\"],\"7nGhhM\":[\"你在想什麼?\"],\"7p5kLi\":[\"儀表板\"],\"8ZsakT\":[\"密碼\"],\"90Luob\":[[\"count\"],\" 條回覆\"],\"A1taO8\":[\"搜尋\"],\"AyHO4m\":[\"這個收藏是關於什麼的?\"],\"B373X+\":[\"編輯文章\"],\"B495Gs\":[\"檔案館\"],\"BjF0Jv\":[\"僅限小寫字母、數字和連字符\"],\"D9Oea+\":[\"永久鏈接\"],\"DHhJ7s\":[\"上一頁\"],\"DoJzLz\":[\"收藏夾\"],\"E80cJw\":[\"刪除此媒體將永久從存儲中移除它。\"],\"EEYbdt\":[\"發佈\"],\"EGwzOK\":[\"完成設置\"],\"EkH9pt\":[\"更新\"],\"FGrimz\":[\"新帖子\"],\"FkMol5\":[\"精選\"],\"Fxf4jq\":[\"描述(可選)\"],\"GA5A5H\":[\"刪除收藏夾\"],\"GorKul\":[\"歡迎來到 Jant\"],\"GrZ6fH\":[\"新頁面\"],\"HfyyXl\":[\"我的部落格\"],\"HiETwV\":[\"安靜(正常)\"],\"Hzi9AA\":[\"未找到任何帖子。\"],\"I6gXOa\":[\"路徑\"],\"IagCbF\":[\"網址\"],\"J4FNfC\":[\"此集合中沒有帖子。\"],\"JIBC/T\":[\"支援的格式:JPEG、PNG、GIF、WebP、SVG。最大大小:10MB。\"],\"JiP4aa\":[\"已發佈的頁面可以通過其路徑訪問。草稿不可見。\"],\"K9NcLu\":[\"使用此 URL 將媒體嵌入到您的帖子中。\"],\"KiJn9B\":[\"備註\"],\"L85WcV\":[\"縮略名\"],\"LkvLQe\":[\"尚未有頁面。\"],\"M8kJqa\":[\"草稿\"],\"M9xgHy\":[\"重定向\"],\"MHrjPM\":[\"標題\"],\"MZbQHL\":[\"未找到結果。\"],\"Mhf/H/\":[\"建立重定向\"],\"MqghUt\":[\"搜尋帖子...\"],\"N40H+G\":[\"所有\"],\"O3oNi5\":[\"電子郵件\"],\"OCNZaU\":[\"重定向來源的路徑\"],\"ODiSoW\":[\"尚未有帖子。\"],\"ONWvwQ\":[\"上傳\"],\"Pbm2/N\":[\"創建收藏夾\"],\"RDjuBN\":[\"設置\"],\"Rj01Fz\":[\"連結\"],\"RwGhWy\":[\"包含 \",[\"count\"],\" 則帖子的主題\"],\"SJmfuf\":[\"網站名稱\"],\"ST+lN2\":[\"尚未上傳任何媒體。\"],\"Tt5T6+\":[\"文章\"],\"TxE+Mj\":[\"1 條回覆\"],\"Tz0i8g\":[\"設定\"],\"U5v6Gh\":[\"編輯頁面\"],\"UDMjsP\":[\"快速操作\"],\"UGT5vp\":[\"保存設定\"],\"VUSy8D\":[\"搜尋失敗。請再試一次。\"],\"WDcQq9\":[\"不公開\"],\"Weq9zb\":[\"一般設定\"],\"WmZ/rP\":[\"到路徑\"],\"Y+7JGK\":[\"創建頁面\"],\"ZQKLI1\":[\"危險區域\"],\"ZhhOwV\":[\"引用\"],\"an5hVd\":[\"圖片\"],\"b+/jO6\":[\"301(永久)\"],\"bHYIks\":[\"登出\"],\"biOepV\":[\"← 返回首頁\"],\"cnGeoo\":[\"刪除\"],\"dEgA5A\":[\"取消\"],\"e6Jr7Q\":[\"← 返回收藏夾\"],\"ePK91l\":[\"編輯\"],\"eWLklq\":[\"引用\"],\"eneWvv\":[\"草稿\"],\"f6e0Ry\":[\"文章\"],\"fG7BxZ\":[\"透過 API 上傳圖片:POST /api/upload,並使用文件表單字段。\"],\"fttd2R\":[\"我的收藏\"],\"hG89Ed\":[\"圖片\"],\"hXzOVo\":[\"下一頁\"],\"he3ygx\":[\"複製\"],\"iH8pgl\":[\"返回\"],\"ig4hg2\":[\"讓我們設置您的網站。\"],\"jpctdh\":[\"查看\"],\"mTOYla\":[\"查看所有文章 →\"],\"n1ekoW\":[\"登入\"],\"oYPBa0\":[\"更新頁面\"],\"pRhYH2\":[\"收藏中的帖子 (\",[\"count\"],\")\"],\"qMyM2u\":[\"來源網址(選填)\"],\"r1MpXi\":[\"安靜\"],\"rdUucN\":[\"預覽\"],\"rzNUSl\":[\"包含 1 則貼文的主題\"],\"sGajR7\":[\"線程開始\"],\"t/YqKh\":[\"移除\"],\"tfrt7B\":[\"未配置任何重定向。\"],\"tiq7kl\":[\"頁面 \",[\"page\"]],\"u2f7vd\":[\"網站描述\"],\"u3wRF+\":[\"已發佈\"],\"u6Hp4N\":[\"Markdown\"],\"uAQUqI\":[\"狀態\"],\"vXIe7J\":[\"語言\"],\"vzU4k9\":[\"新收藏集\"],\"wEF6Ix\":[\"目的地路徑或 URL\"],\"wK4OTM\":[\"標題(選填)\"],\"wM5UXj\":[\"刪除媒體\"],\"wRR604\":[\"頁面\"],\"wja8aL\":[\"無標題\"],\"x0mzE0\":[\"創建你的第一篇帖子\"],\"x4RuFo\":[\"回到首頁\"],\"xYilR2\":[\"媒體\"],\"y28hnO\":[\"文章\"],\"yQ2kGp\":[\"載入更多\"],\"yzF66j\":[\"連結\"],\"z8ajIE\":[\"找到 1 個結果\"],\"zH6KqE\":[\"找到 \",[\"count\"],\" 個結果\"]}")as Messages;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Centralized locale configuration
3
+ */
4
+
5
+ export const locales = ["en", "zh-Hans", "zh-Hant"] as const;
6
+ export type Locale = (typeof locales)[number];
7
+ export const baseLocale: Locale = "en";
8
+
9
+ /**
10
+ * Check if a value is a valid locale
11
+ */
12
+ export function isLocale(value: unknown): value is Locale {
13
+ return typeof value === "string" && locales.includes(value as Locale);
14
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * i18n Hono Middleware
3
+ */
4
+
5
+ import type { MiddlewareHandler } from "hono";
6
+ import type { I18n } from "@lingui/core";
7
+ import { detectLanguage } from "./detect.js";
8
+ import { createI18n, isLocale, type Locale } from "./i18n.js";
9
+ import type { Services } from "../services/index.js";
10
+
11
+ declare module "hono" {
12
+ interface ContextVariableMap {
13
+ lang: Locale;
14
+ i18n: I18n;
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Hono middleware for internationalization.
20
+ * Creates a per-request i18n instance to avoid race conditions in concurrent environments.
21
+ *
22
+ * Language detection priority:
23
+ * 1. Cookie (user preference)
24
+ * 2. Database SITE_LANGUAGE setting (site default)
25
+ * 3. Accept-Language header
26
+ * 4. Default locale (en)
27
+ */
28
+ export function i18nMiddleware(): MiddlewareHandler {
29
+ return async (c, next) => {
30
+ // First try cookie and Accept-Language header
31
+ let lang = detectLanguage(c);
32
+
33
+ // If no cookie is set, check database SITE_LANGUAGE setting
34
+ const cookies = c.req.raw.headers.get("Cookie") ?? "";
35
+ const hasCookie = cookies.includes("lang=");
36
+
37
+ if (!hasCookie) {
38
+ // Check database setting
39
+ const services = c.get("services") as Services | undefined;
40
+ if (services) {
41
+ try {
42
+ const siteLang = await services.settings.get("SITE_LANGUAGE");
43
+ if (siteLang && isLocale(siteLang)) {
44
+ lang = siteLang;
45
+ }
46
+ } catch {
47
+ // Ignore errors, fall back to detected language
48
+ }
49
+ }
50
+ }
51
+
52
+ // Create a new i18n instance for this request to avoid race conditions
53
+ const i18n = createI18n(lang);
54
+
55
+ c.set("lang", lang);
56
+ c.set("i18n", i18n);
57
+ await next();
58
+ };
59
+ }
package/src/index.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Jant - A microblog system
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+
7
+ import { createApp as _createApp } from "./app.js";
8
+
9
+ // Main app factory
10
+ export { createApp } from "./app.js";
11
+ export type { App, AppVariables } from "./app.js";
12
+
13
+ // Types (excluding component props to avoid conflicts with theme exports)
14
+ export type {
15
+ PostType,
16
+ Visibility,
17
+ Bindings,
18
+ Post,
19
+ Media,
20
+ Collection,
21
+ PostCollection,
22
+ Redirect,
23
+ Setting,
24
+ CreatePost,
25
+ UpdatePost,
26
+ JantConfig,
27
+ JantTheme,
28
+ SiteConfig,
29
+ FeatureConfig,
30
+ ThemeComponents,
31
+ } from "./types.js";
32
+
33
+ export { POST_TYPES, VISIBILITY_LEVELS } from "./types.js";
34
+
35
+ // Utilities (for theme authors)
36
+ export * as time from "./lib/time.js";
37
+ export * as sqid from "./lib/sqid.js";
38
+ export * as url from "./lib/url.js";
39
+ export * as markdown from "./lib/markdown.js";
40
+
41
+ // Default export for running core directly (e.g., for development)
42
+ export default _createApp();
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Asset paths for SSR
3
+ *
4
+ * Development: Uses source paths served by Vite dev server
5
+ * Production: Uses paths that get patched at build time with actual hashes
6
+ */
7
+
8
+ interface Assets {
9
+ styles: string;
10
+ client: string;
11
+ datastar: string;
12
+ imageProcessor: string;
13
+ }
14
+
15
+ // Development paths
16
+ const DEV_ASSETS: Assets = {
17
+ styles: "/node_modules/@jant/core/src/theme/styles/main.css",
18
+ client: "/node_modules/@jant/core/src/client.ts",
19
+ datastar: "/node_modules/@jant/core/static/assets/datastar.min.js",
20
+ imageProcessor: "/node_modules/@jant/core/static/assets/image-processor.js",
21
+ };
22
+
23
+ // Production paths - these unique placeholders get replaced at build time
24
+ // Format: __JANT_ASSET_<NAME>__ to avoid accidental matches
25
+ const PROD_ASSETS: Assets = {
26
+ styles: "__JANT_ASSET_STYLES__",
27
+ client: "__JANT_ASSET_CLIENT__",
28
+ datastar: "__JANT_ASSET_DATASTAR__",
29
+ imageProcessor: "__JANT_ASSET_IMAGE_PROCESSOR__",
30
+ };
31
+
32
+ /**
33
+ * Get assets based on environment
34
+ */
35
+ export function getAssets(): Assets {
36
+ try {
37
+ // import.meta.env is injected by Vite
38
+ if (import.meta.env?.DEV) return DEV_ASSETS;
39
+ } catch {
40
+ // import.meta.env may not exist in all environments
41
+ }
42
+
43
+ return PROD_ASSETS;
44
+ }
45
+
46
+ // For static imports
47
+ export const ASSETS = PROD_ASSETS;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Application Constants
3
+ */
4
+
5
+ /**
6
+ * Reserved URL paths that cannot be used for pages
7
+ */
8
+ export const RESERVED_PATHS = [
9
+ "featured",
10
+ "signin",
11
+ "signout",
12
+ "setup",
13
+ "dash",
14
+ "api",
15
+ "feed",
16
+ "search",
17
+ "archive",
18
+ "notes",
19
+ "articles",
20
+ "links",
21
+ "quotes",
22
+ "media",
23
+ "pages",
24
+ "p",
25
+ "c",
26
+ "static",
27
+ "assets",
28
+ "health",
29
+ ] as const;
30
+
31
+ export type ReservedPath = (typeof RESERVED_PATHS)[number];
32
+
33
+ /**
34
+ * Check if a path is reserved
35
+ */
36
+ export function isReservedPath(path: string): boolean {
37
+ const firstSegment = path.split("/")[0]?.toLowerCase();
38
+ return RESERVED_PATHS.includes(firstSegment as ReservedPath);
39
+ }
40
+
41
+ /**
42
+ * Default pagination size
43
+ */
44
+ export const DEFAULT_PAGE_SIZE = 100;
45
+
46
+ /**
47
+ * Settings keys (match environment variable naming)
48
+ */
49
+ export const SETTINGS_KEYS = {
50
+ ONBOARDING_STATUS: "ONBOARDING_STATUS",
51
+ SITE_NAME: "SITE_NAME",
52
+ SITE_DESCRIPTION: "SITE_DESCRIPTION",
53
+ SITE_LANGUAGE: "SITE_LANGUAGE",
54
+ THEME: "THEME",
55
+ } as const;
56
+
57
+ export type SettingsKey = (typeof SETTINGS_KEYS)[keyof typeof SETTINGS_KEYS];
58
+
59
+ /**
60
+ * Onboarding status values
61
+ */
62
+ export const ONBOARDING_STATUS = {
63
+ PENDING: "pending",
64
+ COMPLETED: "completed",
65
+ } as const;
66
+
67
+ export type OnboardingStatus = (typeof ONBOARDING_STATUS)[keyof typeof ONBOARDING_STATUS];
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Image URL utilities
3
+ *
4
+ * Provides helpers for generating image URLs with optional transformations.
5
+ */
6
+
7
+ /**
8
+ * Options for image transformations
9
+ */
10
+ export interface ImageOptions {
11
+ /** Target width in pixels */
12
+ width?: number;
13
+ /** Target height in pixels */
14
+ height?: number;
15
+ /** Quality (1-100) */
16
+ quality?: number;
17
+ /** Output format */
18
+ format?: "webp" | "avif" | "auto";
19
+ /** Fit mode for resizing */
20
+ fit?: "cover" | "contain" | "scale-down";
21
+ }
22
+
23
+ /**
24
+ * Generates an image URL with optional transformations.
25
+ *
26
+ * If `transformUrl` is provided and options are specified, returns a transformed image URL.
27
+ * Otherwise, returns the original URL unchanged.
28
+ *
29
+ * Compatible with:
30
+ * - Cloudflare Image Transformations (`/cdn-cgi/image/...`)
31
+ * - imgproxy
32
+ * - Cloudinary
33
+ * - Any service with similar URL-based transformation API
34
+ *
35
+ * @param originalUrl - The original image URL
36
+ * @param transformUrl - The base URL for transformations (e.g., `https://example.com/cdn-cgi/image`)
37
+ * @param options - Transformation options (width, height, quality, format, fit)
38
+ * @returns The transformed URL or original URL if transformations are not configured
39
+ *
40
+ * @example
41
+ * ```ts
42
+ * // Without transform URL - returns original
43
+ * getImageUrl("/media/abc123", undefined, { width: 200 });
44
+ * // Returns: "/media/abc123"
45
+ *
46
+ * // With transform URL - returns transformed
47
+ * getImageUrl("/media/abc123", "https://example.com/cdn-cgi/image", { width: 200, quality: 80 });
48
+ * // Returns: "https://example.com/cdn-cgi/image/width=200,quality=80/https://example.com/media/abc123"
49
+ * ```
50
+ */
51
+ export function getImageUrl(
52
+ originalUrl: string,
53
+ transformUrl?: string,
54
+ options?: ImageOptions
55
+ ): string {
56
+ if (!transformUrl || !options || Object.keys(options).length === 0) {
57
+ return originalUrl;
58
+ }
59
+
60
+ const params: string[] = [];
61
+ if (options.width) params.push(`width=${options.width}`);
62
+ if (options.height) params.push(`height=${options.height}`);
63
+ if (options.quality) params.push(`quality=${options.quality}`);
64
+ if (options.format) params.push(`format=${options.format}`);
65
+ if (options.fit) params.push(`fit=${options.fit}`);
66
+
67
+ if (params.length === 0) {
68
+ return originalUrl;
69
+ }
70
+
71
+ return `${transformUrl}/${params.join(",")}/${originalUrl}`;
72
+ }
73
+
74
+ /**
75
+ * Generates a media URL using UUIDv7-based paths.
76
+ *
77
+ * Returns a public URL for a media file. If `r2PublicUrl` is set, uses that directly
78
+ * with the r2Key. Otherwise, generates a `/media/{id}.{ext}` URL.
79
+ *
80
+ * @param mediaId - The UUIDv7 database ID of the media
81
+ * @param r2Key - The R2 storage key (used to extract extension)
82
+ * @param r2PublicUrl - Optional R2 public URL for direct CDN access
83
+ * @returns The public URL for the media file
84
+ *
85
+ * @example
86
+ * ```ts
87
+ * // Without R2 public URL - uses UUID with extension
88
+ * getMediaUrl("01902a9f-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "uploads/file.webp");
89
+ * // Returns: "/media/01902a9f-1a2b-7c3d-8e4f-5a6b7c8d9e0f.webp"
90
+ *
91
+ * // With R2 public URL - uses direct CDN
92
+ * getMediaUrl("01902a9f-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "uploads/file.webp", "https://cdn.example.com");
93
+ * // Returns: "https://cdn.example.com/uploads/file.webp"
94
+ * ```
95
+ */
96
+ export function getMediaUrl(
97
+ mediaId: string,
98
+ r2Key: string,
99
+ r2PublicUrl?: string
100
+ ): string {
101
+ if (r2PublicUrl) {
102
+ return `${r2PublicUrl}/${r2Key}`;
103
+ }
104
+ // Extract extension from r2Key
105
+ const ext = r2Key.split(".").pop() || "bin";
106
+ return `/media/${mediaId}.${ext}`;
107
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Utility Functions
3
+ */
4
+
5
+ export * from "./constants.js";
6
+ export * as sqid from "./sqid.js";
7
+ export * as time from "./time.js";
8
+ export * as url from "./url.js";
9
+ export * as markdown from "./markdown.js";
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Markdown Rendering
3
+ *
4
+ * Uses marked with minimal configuration
5
+ */
6
+
7
+ import { marked } from "marked";
8
+
9
+ // Configure marked for security and simplicity
10
+ marked.setOptions({
11
+ gfm: true,
12
+ breaks: true,
13
+ });
14
+
15
+ /**
16
+ * Renders Markdown content to HTML using the marked library.
17
+ *
18
+ * Configured with GitHub Flavored Markdown (GFM) support and line breaks enabled.
19
+ * Uses synchronous parsing for simplicity and consistency in server-side rendering.
20
+ *
21
+ * @param markdown - The Markdown string to convert to HTML
22
+ * @returns The rendered HTML string
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * const html = render("# Hello\n\nThis is **bold** text.");
27
+ * // Returns: "<h1>Hello</h1>\n<p>This is <strong>bold</strong> text.</p>"
28
+ * ```
29
+ */
30
+ export function render(markdown: string): string {
31
+ return marked.parse(markdown, { async: false }) as string;
32
+ }
33
+
34
+ /**
35
+ * Converts Markdown to plain text by stripping all formatting syntax.
36
+ *
37
+ * Removes Markdown syntax including headers, bold, italic, links, images, code blocks,
38
+ * blockquotes, lists, and converts newlines to spaces. Useful for generating text excerpts,
39
+ * meta descriptions, or search indexes.
40
+ *
41
+ * @param markdown - The Markdown string to convert to plain text
42
+ * @returns The plain text string with all Markdown syntax removed
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * const plain = toPlainText("## Hello\n\nThis is **bold** and [a link](url).");
47
+ * // Returns: "Hello This is bold and a link."
48
+ * ```
49
+ */
50
+ export function toPlainText(markdown: string): string {
51
+ return markdown
52
+ .replace(/#{1,6}\s+/g, "") // Remove headers
53
+ .replace(/\*\*(.+?)\*\*/g, "$1") // Bold
54
+ .replace(/\*(.+?)\*/g, "$1") // Italic
55
+ .replace(/\[(.+?)\]\(.+?\)/g, "$1") // Links
56
+ .replace(/!\[.*?\]\(.+?\)/g, "") // Images
57
+ .replace(/`{1,3}[^`]*`{1,3}/g, "") // Code
58
+ .replace(/>\s+/g, "") // Blockquotes
59
+ .replace(/[-*+]\s+/g, "") // Lists
60
+ .replace(/\n+/g, " ") // Newlines
61
+ .trim();
62
+ }
63
+
64
+ /**
65
+ * Extracts a title from Markdown content by taking the first sentence or line.
66
+ *
67
+ * Converts Markdown to plain text first, then takes the first sentence (split by `.!?`)
68
+ * or truncates to the specified maximum length. Useful for generating automatic titles
69
+ * from post content when no explicit title is provided.
70
+ *
71
+ * @param markdown - The Markdown string to extract a title from
72
+ * @param maxLength - Maximum length of the extracted title (default: 120)
73
+ * @returns The extracted title string, with "..." appended if truncated
74
+ *
75
+ * @example
76
+ * ```ts
77
+ * const title = extractTitle("This is the first sentence. And another one.", 50);
78
+ * // Returns: "This is the first sentence"
79
+ *
80
+ * const title = extractTitle("A very long sentence that exceeds the maximum length...", 30);
81
+ * // Returns: "A very long sentence that ex..."
82
+ * ```
83
+ */
84
+ export function extractTitle(markdown: string, maxLength = 120): string {
85
+ const plain = toPlainText(markdown);
86
+ const firstLine = plain.split(/[.!?]/)[0] ?? plain;
87
+
88
+ if (firstLine.length <= maxLength) {
89
+ return firstLine;
90
+ }
91
+
92
+ return plain.slice(0, maxLength).trim() + "...";
93
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Shared Zod schemas for validation
3
+ *
4
+ * These schemas ensure type-safe validation of user input
5
+ * from forms, API requests, and other external sources.
6
+ *
7
+ * IMPORTANT: Types are defined in types.ts as the single source of truth.
8
+ * This file only defines Zod validation schemas based on those types.
9
+ */
10
+
11
+ import { z } from "zod";
12
+ import { POST_TYPES, VISIBILITY_LEVELS } from "../types.js";
13
+
14
+ /**
15
+ * Post type enum schema
16
+ * Based on POST_TYPES from types.ts
17
+ */
18
+ export const PostTypeSchema = z.enum(POST_TYPES);
19
+
20
+ /**
21
+ * Visibility enum schema
22
+ * Based on VISIBILITY_LEVELS from types.ts
23
+ */
24
+ export const VisibilitySchema = z.enum(VISIBILITY_LEVELS);
25
+
26
+ /**
27
+ * Redirect type enum schema
28
+ * Form input validation for redirect type (stored as number in DB)
29
+ */
30
+ export const RedirectTypeSchema = z.enum(["301", "302"]);
31
+
32
+ /**
33
+ * API request body schema for creating a post
34
+ */
35
+ export const CreatePostSchema = z.object({
36
+ type: PostTypeSchema,
37
+ title: z.string().optional(),
38
+ content: z.string(),
39
+ visibility: VisibilitySchema,
40
+ sourceUrl: z.string().url().optional().or(z.literal("")),
41
+ sourceName: z.string().optional(),
42
+ path: z.string().regex(/^[a-z0-9-]*$/).optional().or(z.literal("")),
43
+ replyToId: z.string().optional(), // Sqid format
44
+ publishedAt: z.number().int().positive().optional(),
45
+ });
46
+
47
+ /**
48
+ * API request body schema for updating a post
49
+ */
50
+ export const UpdatePostSchema = CreatePostSchema.partial();
51
+
52
+ /**
53
+ * Form data helper: safely parse a FormData value with a schema
54
+ *
55
+ * @example
56
+ * ```ts
57
+ * const type = parseFormData(formData, "type", PostTypeSchema);
58
+ * // type is PostType, throws if invalid
59
+ * ```
60
+ */
61
+ export function parseFormData<T>(
62
+ formData: FormData,
63
+ key: string,
64
+ schema: z.ZodSchema<T>
65
+ ): T {
66
+ const value = formData.get(key);
67
+ if (value === null) {
68
+ throw new Error(`Missing required field: ${key}`);
69
+ }
70
+ return schema.parse(value);
71
+ }
72
+
73
+ /**
74
+ * Form data helper: safely parse optional FormData value with a schema
75
+ *
76
+ * @example
77
+ * ```ts
78
+ * const slug = parseFormDataOptional(formData, "slug", z.string());
79
+ * // slug is string | undefined
80
+ * ```
81
+ */
82
+ export function parseFormDataOptional<T>(
83
+ formData: FormData,
84
+ key: string,
85
+ schema: z.ZodSchema<T>
86
+ ): T | undefined {
87
+ const value = formData.get(key);
88
+ if (value === null || value === "") {
89
+ return undefined;
90
+ }
91
+ return schema.parse(value);
92
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Sqids - Short unique IDs for URLs
3
+ *
4
+ * Encodes numeric IDs to short strings like "jR3k"
5
+ */
6
+
7
+ import Sqids from "sqids";
8
+
9
+ const sqids = new Sqids({
10
+ minLength: 4,
11
+ });
12
+
13
+ /**
14
+ * Encodes a numeric database ID to a short, URL-friendly string.
15
+ *
16
+ * Uses the Sqids library to generate short unique identifiers with a minimum length of 4 characters.
17
+ * These are used in URLs (e.g., `/p/jR3k`) to obscure sequential integer IDs while maintaining
18
+ * uniqueness and reversibility.
19
+ *
20
+ * @param id - The numeric database ID to encode
21
+ * @returns A short string representation of the ID (minimum 4 characters)
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * const sqid = encode(123);
26
+ * // Returns: "jR3k" (or similar short string)
27
+ * ```
28
+ */
29
+ export function encode(id: number): string {
30
+ return sqids.encode([id]);
31
+ }
32
+
33
+ /**
34
+ * Decodes a sqid string back to the original numeric database ID.
35
+ *
36
+ * Attempts to decode a sqid string generated by the `encode()` function. Returns the original
37
+ * numeric ID if valid, or `null` if the string is not a valid sqid. This is used to extract
38
+ * database IDs from URL parameters.
39
+ *
40
+ * @param str - The sqid string to decode
41
+ * @returns The original numeric ID if valid, or `null` if decoding fails
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * const id = decode("jR3k");
46
+ * // Returns: 123
47
+ *
48
+ * const invalid = decode("invalid");
49
+ * // Returns: null
50
+ * ```
51
+ */
52
+ export function decode(str: string): number | null {
53
+ try {
54
+ const ids = sqids.decode(str);
55
+ return ids[0] ?? null;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Checks if a string is a valid sqid that can be decoded.
63
+ *
64
+ * Validates whether a string can be successfully decoded to a numeric ID.
65
+ * Useful for route validation and input sanitization.
66
+ *
67
+ * @param str - The string to validate
68
+ * @returns `true` if the string is a valid sqid, `false` otherwise
69
+ *
70
+ * @example
71
+ * ```ts
72
+ * if (isValidSqid("jR3k")) {
73
+ * // Process the valid sqid
74
+ * }
75
+ * ```
76
+ */
77
+ export function isValidSqid(str: string): boolean {
78
+ return decode(str) !== null;
79
+ }