@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,119 @@
1
+ /**
2
+ * RSS Feed 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
+ import * as time from "../../lib/time.js";
10
+
11
+ type Env = { Bindings: Bindings; Variables: AppVariables };
12
+
13
+ export const rssRoutes = new Hono<Env>();
14
+
15
+ // RSS 2.0 Feed - main feed at /feed
16
+ rssRoutes.get("/", async (c) => {
17
+ const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
18
+ const siteDescription = (await c.var.services.settings.get("SITE_DESCRIPTION")) ?? "";
19
+ const siteUrl = c.env.SITE_URL;
20
+
21
+ const posts = await c.var.services.posts.list({
22
+ visibility: ["featured", "quiet"],
23
+ limit: 50,
24
+ });
25
+
26
+ const items = posts
27
+ .map((post) => {
28
+ const link = `${siteUrl}/p/${sqid.encode(post.id)}`;
29
+ const title = post.title || `Post #${post.id}`;
30
+ const pubDate = new Date(post.publishedAt * 1000).toUTCString();
31
+
32
+ return `
33
+ <item>
34
+ <title><![CDATA[${escapeXml(title)}]]></title>
35
+ <link>${link}</link>
36
+ <guid isPermaLink="true">${link}</guid>
37
+ <pubDate>${pubDate}</pubDate>
38
+ <description><![CDATA[${post.contentHtml || ""}]]></description>
39
+ </item>`;
40
+ })
41
+ .join("");
42
+
43
+ const rss = `<?xml version="1.0" encoding="UTF-8"?>
44
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
45
+ <channel>
46
+ <title>${escapeXml(siteName)}</title>
47
+ <link>${siteUrl}</link>
48
+ <description>${escapeXml(siteDescription)}</description>
49
+ <language>en</language>
50
+ <atom:link href="${siteUrl}/feed" rel="self" type="application/rss+xml"/>
51
+ ${items}
52
+ </channel>
53
+ </rss>`;
54
+
55
+ return new Response(rss, {
56
+ headers: {
57
+ "Content-Type": "application/rss+xml; charset=utf-8",
58
+ },
59
+ });
60
+ });
61
+
62
+ // Atom Feed
63
+ rssRoutes.get("/atom.xml", async (c) => {
64
+ const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
65
+ const siteDescription = (await c.var.services.settings.get("SITE_DESCRIPTION")) ?? "";
66
+ const siteUrl = c.env.SITE_URL;
67
+
68
+ const posts = await c.var.services.posts.list({
69
+ visibility: ["featured", "quiet"],
70
+ limit: 50,
71
+ });
72
+
73
+ const entries = posts
74
+ .map((post) => {
75
+ const link = `${siteUrl}/p/${sqid.encode(post.id)}`;
76
+ const title = post.title || `Post #${post.id}`;
77
+ const updated = time.toISOString(post.updatedAt);
78
+ const published = time.toISOString(post.publishedAt);
79
+
80
+ return `
81
+ <entry>
82
+ <title>${escapeXml(title)}</title>
83
+ <link href="${link}" rel="alternate"/>
84
+ <id>${link}</id>
85
+ <published>${published}</published>
86
+ <updated>${updated}</updated>
87
+ <content type="html"><![CDATA[${post.contentHtml || ""}]]></content>
88
+ </entry>`;
89
+ })
90
+ .join("");
91
+
92
+ const now = time.toISOString(time.now());
93
+
94
+ const atom = `<?xml version="1.0" encoding="UTF-8"?>
95
+ <feed xmlns="http://www.w3.org/2005/Atom">
96
+ <title>${escapeXml(siteName)}</title>
97
+ <subtitle>${escapeXml(siteDescription)}</subtitle>
98
+ <link href="${siteUrl}" rel="alternate"/>
99
+ <link href="${siteUrl}/feed/atom.xml" rel="self"/>
100
+ <id>${siteUrl}/</id>
101
+ <updated>${now}</updated>
102
+ ${entries}
103
+ </feed>`;
104
+
105
+ return new Response(atom, {
106
+ headers: {
107
+ "Content-Type": "application/atom+xml; charset=utf-8",
108
+ },
109
+ });
110
+ });
111
+
112
+ function escapeXml(str: string): string {
113
+ return str
114
+ .replace(/&/g, "&amp;")
115
+ .replace(/</g, "&lt;")
116
+ .replace(/>/g, "&gt;")
117
+ .replace(/"/g, "&quot;")
118
+ .replace(/'/g, "&apos;");
119
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Sitemap 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
+ import * as time from "../../lib/time.js";
10
+
11
+ type Env = { Bindings: Bindings; Variables: AppVariables };
12
+
13
+ export const sitemapRoutes = new Hono<Env>();
14
+
15
+ // XML Sitemap
16
+ sitemapRoutes.get("/sitemap.xml", async (c) => {
17
+ const siteUrl = c.env.SITE_URL;
18
+
19
+ const posts = await c.var.services.posts.list({
20
+ visibility: ["featured", "quiet"],
21
+ limit: 1000,
22
+ });
23
+
24
+ const urls = posts
25
+ .map((post) => {
26
+ const loc = `${siteUrl}/p/${sqid.encode(post.id)}`;
27
+ const lastmod = time.toISOString(post.updatedAt).split("T")[0];
28
+ const priority = post.visibility === "featured" ? "0.8" : "0.6";
29
+
30
+ return `
31
+ <url>
32
+ <loc>${loc}</loc>
33
+ <lastmod>${lastmod}</lastmod>
34
+ <priority>${priority}</priority>
35
+ </url>`;
36
+ })
37
+ .join("");
38
+
39
+ // Add homepage
40
+ const homepageUrl = `
41
+ <url>
42
+ <loc>${siteUrl}/</loc>
43
+ <priority>1.0</priority>
44
+ <changefreq>daily</changefreq>
45
+ </url>`;
46
+
47
+ const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
48
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
49
+ ${homepageUrl}
50
+ ${urls}
51
+ </urlset>`;
52
+
53
+ return new Response(sitemap, {
54
+ headers: {
55
+ "Content-Type": "application/xml; charset=utf-8",
56
+ },
57
+ });
58
+ });
59
+
60
+ // robots.txt
61
+ sitemapRoutes.get("/robots.txt", (c) => {
62
+ const siteUrl = c.env.SITE_URL;
63
+
64
+ const robots = `User-agent: *
65
+ Allow: /
66
+
67
+ Sitemap: ${siteUrl}/sitemap.xml
68
+ `;
69
+
70
+ return new Response(robots, {
71
+ headers: {
72
+ "Content-Type": "text/plain; charset=utf-8",
73
+ },
74
+ });
75
+ });
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Archive Page Route
3
+ *
4
+ * Shows all posts, optionally filtered by type
5
+ */
6
+
7
+ import { Hono } from "hono";
8
+ import { useLingui } from "../../i18n/index.js";
9
+ import type { Bindings, Post, PostType } from "../../types.js";
10
+ import type { AppVariables } from "../../app.js";
11
+ import { BaseLayout } from "../../theme/layouts/index.js";
12
+ import { Pagination } from "../../theme/components/index.js";
13
+ import { POST_TYPES } from "../../types.js";
14
+ import * as sqid from "../../lib/sqid.js";
15
+ import * as time from "../../lib/time.js";
16
+
17
+ type Env = { Bindings: Bindings; Variables: AppVariables };
18
+
19
+ const PAGE_SIZE = 50;
20
+
21
+ export const archiveRoutes = new Hono<Env>();
22
+
23
+ function getTypeLabel(type: string): string {
24
+ const { t } = useLingui();
25
+ const labels: Record<string, string> = {
26
+ note: t({ message: "Note", comment: "@context: Post type label - note" }),
27
+ article: t({ message: "Article", comment: "@context: Post type label - article" }),
28
+ link: t({ message: "Link", comment: "@context: Post type label - link" }),
29
+ quote: t({ message: "Quote", comment: "@context: Post type label - quote" }),
30
+ image: t({ message: "Image", comment: "@context: Post type label - image" }),
31
+ page: t({ message: "Page", comment: "@context: Post type label - page" }),
32
+ };
33
+ return labels[type] ?? type;
34
+ }
35
+
36
+ function getTypeLabelPlural(type: string): string {
37
+ const { t } = useLingui();
38
+ const labels: Record<string, string> = {
39
+ note: t({ message: "Notes", comment: "@context: Post type label plural - notes" }),
40
+ article: t({ message: "Articles", comment: "@context: Post type label plural - articles" }),
41
+ link: t({ message: "Links", comment: "@context: Post type label plural - links" }),
42
+ quote: t({ message: "Quotes", comment: "@context: Post type label plural - quotes" }),
43
+ image: t({ message: "Images", comment: "@context: Post type label plural - images" }),
44
+ page: t({ message: "Pages", comment: "@context: Post type label plural - pages" }),
45
+ };
46
+ return labels[type] ?? `${type}s`;
47
+ }
48
+
49
+ function formatYearMonth(yearMonth: string): string {
50
+ const [year, month] = yearMonth.split("-");
51
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- yearMonth format YYYY-MM guarantees both year and month exist
52
+ const date = new Date(parseInt(year!, 10), parseInt(month!, 10) - 1);
53
+ return date.toLocaleDateString("en-US", { year: "numeric", month: "long" });
54
+ }
55
+
56
+ function ArchiveContent({
57
+ displayPosts,
58
+ hasMore,
59
+ nextCursor,
60
+ type,
61
+ grouped,
62
+ replyCounts,
63
+ }: {
64
+ displayPosts: Post[];
65
+ hasMore: boolean;
66
+ nextCursor?: number;
67
+ type?: string;
68
+ grouped: Map<string, Post[]>;
69
+ replyCounts: Map<number, number>;
70
+ }) {
71
+ const { t } = useLingui();
72
+ const title = type
73
+ ? getTypeLabelPlural(type)
74
+ : t({ message: "Archive", comment: "@context: Archive page title" });
75
+
76
+ return (
77
+ <div class="container py-8">
78
+ <header class="mb-8">
79
+ <h1 class="text-2xl font-semibold">{title}</h1>
80
+
81
+ {/* Type filter */}
82
+ <nav class="flex flex-wrap gap-2 mt-4">
83
+ <a
84
+ href="/archive"
85
+ class={`badge ${!type ? "badge-primary" : "badge-outline"}`}
86
+ >
87
+ {t({ message: "All", comment: "@context: Archive filter - all types" })}
88
+ </a>
89
+ {POST_TYPES.filter((t) => t !== "page").map((typeKey) => (
90
+ <a
91
+ key={typeKey}
92
+ href={`/archive?type=${typeKey}`}
93
+ class={`badge ${type === typeKey ? "badge-primary" : "badge-outline"}`}
94
+ >
95
+ {getTypeLabelPlural(typeKey)}
96
+ </a>
97
+ ))}
98
+ </nav>
99
+ </header>
100
+
101
+ <main>
102
+ {displayPosts.length === 0 ? (
103
+ <p class="text-muted-foreground">
104
+ {t({ message: "No posts found.", comment: "@context: Archive empty state" })}
105
+ </p>
106
+ ) : (
107
+ Array.from(grouped.entries()).map(([yearMonth, monthPosts]) => (
108
+ <section key={yearMonth} class="mb-8">
109
+ <h2 class="text-lg font-medium mb-4 text-muted-foreground">
110
+ {formatYearMonth(yearMonth)}
111
+ </h2>
112
+ <div class="flex flex-col gap-3">
113
+ {monthPosts.map((post) => {
114
+ const replyCount = replyCounts.get(post.id);
115
+ return (
116
+ <article key={post.id} class="flex items-baseline gap-4">
117
+ <time
118
+ class="text-sm text-muted-foreground w-12 shrink-0"
119
+ datetime={time.toISOString(post.publishedAt)}
120
+ >
121
+ {new Date(post.publishedAt * 1000).getDate()}
122
+ </time>
123
+ <div class="flex-1 min-w-0">
124
+ <a
125
+ href={`/p/${sqid.encode(post.id)}`}
126
+ class="hover:underline"
127
+ >
128
+ {post.title || post.content?.slice(0, 80) || `Post #${post.id}`}
129
+ </a>
130
+ {!type && (
131
+ <span class="ml-2 badge-outline text-xs">{getTypeLabel(post.type)}</span>
132
+ )}
133
+ {replyCount && replyCount > 0 && (
134
+ <span class="ml-2 text-xs text-muted-foreground">
135
+ ({replyCount === 1
136
+ ? t({ message: "1 reply", comment: "@context: Archive post reply indicator - single" })
137
+ : t({ message: "{count} replies", comment: "@context: Archive post reply indicator - plural", values: { count: String(replyCount) } })})
138
+ </span>
139
+ )}
140
+ </div>
141
+ </article>
142
+ );
143
+ })}
144
+ </div>
145
+ </section>
146
+ ))
147
+ )}
148
+ </main>
149
+
150
+ {/* Pagination */}
151
+ <Pagination
152
+ baseUrl={type ? `/archive?type=${type}` : "/archive"}
153
+ hasMore={hasMore}
154
+ nextCursor={nextCursor}
155
+ />
156
+
157
+ <nav class="mt-4">
158
+ <a href="/" class="text-sm hover:underline">
159
+ ← {t({ message: "Back to home", comment: "@context: Navigation link back to home page" })}
160
+ </a>
161
+ </nav>
162
+ </div>
163
+ );
164
+ }
165
+
166
+ // Archive page - all posts
167
+ archiveRoutes.get("/", async (c) => {
168
+ const typeParam = c.req.query("type") as PostType | undefined;
169
+ const type = typeParam && POST_TYPES.includes(typeParam) ? typeParam : undefined;
170
+
171
+ // Parse cursor
172
+ const cursorParam = c.req.query("cursor");
173
+ const cursor = cursorParam ? parseInt(cursorParam, 10) : undefined;
174
+
175
+ const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
176
+
177
+ // Fetch one extra to check for more
178
+ const posts = await c.var.services.posts.list({
179
+ type,
180
+ visibility: ["featured", "quiet"],
181
+ excludeReplies: true,
182
+ cursor,
183
+ limit: PAGE_SIZE + 1,
184
+ });
185
+
186
+ const hasMore = posts.length > PAGE_SIZE;
187
+ const displayPosts = hasMore ? posts.slice(0, PAGE_SIZE) : posts;
188
+
189
+ // Get reply counts for thread indicators
190
+ const postIds = displayPosts.map((p) => p.id);
191
+ const replyCounts = await c.var.services.posts.getReplyCounts(postIds);
192
+
193
+ // Get next cursor
194
+ const nextCursor = hasMore && displayPosts.length > 0
195
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Length check above guarantees element exists
196
+ ? displayPosts[displayPosts.length - 1]!.id
197
+ : undefined;
198
+
199
+ // Group posts by year-month
200
+ const grouped = new Map<string, typeof displayPosts>();
201
+ for (const post of displayPosts) {
202
+ const date = new Date(post.publishedAt * 1000);
203
+ const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
204
+ if (!grouped.has(key)) {
205
+ grouped.set(key, []);
206
+ }
207
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Map.set() above guarantees key exists
208
+ grouped.get(key)!.push(post);
209
+ }
210
+
211
+ return c.html(
212
+ <BaseLayout title={`Archive - ${siteName}`} c={c}>
213
+ <ArchiveContent
214
+ displayPosts={displayPosts}
215
+ hasMore={hasMore}
216
+ nextCursor={nextCursor}
217
+ type={type}
218
+ grouped={grouped}
219
+ replyCounts={replyCounts}
220
+ />
221
+ </BaseLayout>
222
+ );
223
+ });
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Collection Page Route
3
+ */
4
+
5
+ import { Hono } from "hono";
6
+ import { useLingui } from "../../i18n/index.js";
7
+ import type { Bindings, Collection, Post } from "../../types.js";
8
+ import type { AppVariables } from "../../app.js";
9
+ import { BaseLayout } from "../../theme/layouts/index.js";
10
+ import * as sqid from "../../lib/sqid.js";
11
+ import * as time from "../../lib/time.js";
12
+
13
+ type Env = { Bindings: Bindings; Variables: AppVariables };
14
+
15
+ export const collectionRoutes = new Hono<Env>();
16
+
17
+ function CollectionContent({ collection, posts }: { collection: Collection; posts: Post[] }) {
18
+ const { t } = useLingui();
19
+
20
+ return (
21
+ <div class="container py-8">
22
+ <header class="mb-8">
23
+ <h1 class="text-2xl font-semibold">{collection.title}</h1>
24
+ {collection.description && (
25
+ <p class="text-muted-foreground mt-2">{collection.description}</p>
26
+ )}
27
+ </header>
28
+
29
+ <main class="flex flex-col gap-6">
30
+ {posts.length === 0 ? (
31
+ <p class="text-muted-foreground">{t({ message: "No posts in this collection.", comment: "@context: Empty state message" })}</p>
32
+ ) : (
33
+ posts.map((post) => (
34
+ <article key={post.id} class="h-entry">
35
+ {post.title && (
36
+ <h2 class="p-name text-lg font-medium mb-2">
37
+ <a href={`/p/${sqid.encode(post.id)}`} class="u-url hover:underline">
38
+ {post.title}
39
+ </a>
40
+ </h2>
41
+ )}
42
+ <div
43
+ class="e-content prose prose-sm"
44
+ dangerouslySetInnerHTML={{ __html: post.contentHtml || "" }}
45
+ />
46
+ <footer class="mt-2 text-sm text-muted-foreground">
47
+ <time class="dt-published" datetime={time.toISOString(post.publishedAt)}>
48
+ {time.formatDate(post.publishedAt)}
49
+ </time>
50
+ </footer>
51
+ </article>
52
+ ))
53
+ )}
54
+ </main>
55
+
56
+ <nav class="mt-8">
57
+ <a href="/" class="text-sm hover:underline">
58
+ {t({ message: "← Back to home", comment: "@context: Navigation link" })}
59
+ </a>
60
+ </nav>
61
+ </div>
62
+ );
63
+ }
64
+
65
+ collectionRoutes.get("/:path", async (c) => {
66
+ const path = c.req.param("path");
67
+
68
+ const collection = await c.var.services.collections.getByPath(path);
69
+ if (!collection) return c.notFound();
70
+
71
+ const posts = await c.var.services.collections.getPosts(collection.id);
72
+ const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
73
+
74
+ return c.html(
75
+ <BaseLayout title={`${collection.title} - ${siteName}`} description={collection.description ?? undefined} c={c}>
76
+ <CollectionContent collection={collection} posts={posts} />
77
+ </BaseLayout>
78
+ );
79
+ });
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Home Page Route
3
+ */
4
+
5
+ import { Hono } from "hono";
6
+ import { useLingui } from "../../i18n/index.js";
7
+ import type { Bindings, Post } from "../../types.js";
8
+ import type { AppVariables } from "../../app.js";
9
+ import { BaseLayout } from "../../theme/layouts/index.js";
10
+ import * as sqid from "../../lib/sqid.js";
11
+ import * as time from "../../lib/time.js";
12
+
13
+ type Env = { Bindings: Bindings; Variables: AppVariables };
14
+
15
+ export const homeRoutes = new Hono<Env>();
16
+
17
+ function HomeContent({ siteName, posts }: { siteName: string; posts: Post[] }) {
18
+ const { t } = useLingui();
19
+
20
+ return (
21
+ <div class="container py-8">
22
+ <header class="mb-8 flex items-center justify-between">
23
+ <h1 class="text-2xl font-semibold">{siteName}</h1>
24
+ <nav class="flex items-center gap-4 text-sm">
25
+ <a href="/archive" class="text-muted-foreground hover:text-foreground">
26
+ {t({ message: "Archive", comment: "@context: Navigation link to archive page" })}
27
+ </a>
28
+ <a href="/feed" class="text-muted-foreground hover:text-foreground">
29
+ RSS
30
+ </a>
31
+ </nav>
32
+ </header>
33
+
34
+ <main class="flex flex-col gap-6">
35
+ {posts.length === 0 ? (
36
+ <p class="text-muted-foreground">{t({ message: "No posts yet.", comment: "@context: Empty state message on home page" })}</p>
37
+ ) : (
38
+ posts.map((post) => (
39
+ <article key={post.id} class="h-entry">
40
+ {post.title && (
41
+ <h2 class="p-name text-lg font-medium mb-2">
42
+ <a href={`/p/${sqid.encode(post.id)}`} class="u-url hover:underline">
43
+ {post.title}
44
+ </a>
45
+ </h2>
46
+ )}
47
+ <div
48
+ class="e-content prose prose-sm"
49
+ dangerouslySetInnerHTML={{ __html: post.contentHtml || "" }}
50
+ />
51
+ <footer class="mt-2 text-sm text-muted-foreground">
52
+ <time class="dt-published" datetime={time.toISOString(post.publishedAt)}>
53
+ {time.formatDate(post.publishedAt)}
54
+ </time>
55
+ {post.visibility === "featured" && (
56
+ <span class="ml-2 text-xs">{t({ message: "Featured", comment: "@context: Post visibility badge" })}</span>
57
+ )}
58
+ </footer>
59
+ </article>
60
+ ))
61
+ )}
62
+ </main>
63
+
64
+ {posts.length >= 20 && (
65
+ <nav class="mt-8 text-center">
66
+ <a href="/archive" class="text-sm text-muted-foreground hover:text-foreground">
67
+ {t({ message: "View all posts →", comment: "@context: Link to view all posts on archive page" })}
68
+ </a>
69
+ </nav>
70
+ )}
71
+ </div>
72
+ );
73
+ }
74
+
75
+ homeRoutes.get("/", async (c) => {
76
+ const isComplete = await c.var.services.settings.isOnboardingComplete();
77
+ if (!isComplete) {
78
+ return c.redirect("/setup");
79
+ }
80
+
81
+ const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
82
+
83
+ const posts = await c.var.services.posts.list({
84
+ visibility: ["featured", "quiet"],
85
+ limit: 20,
86
+ });
87
+
88
+ return c.html(
89
+ <BaseLayout title={siteName} c={c}>
90
+ <HomeContent siteName={siteName} posts={posts} />
91
+ </BaseLayout>
92
+ );
93
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Custom Page Route
3
+ *
4
+ * Catch-all route for custom pages accessible via their path field
5
+ */
6
+
7
+ import { Hono } from "hono";
8
+ import { useLingui } from "../../i18n/index.js";
9
+ import type { Bindings, Post } from "../../types.js";
10
+ import type { AppVariables } from "../../app.js";
11
+ import { BaseLayout } from "../../theme/layouts/index.js";
12
+
13
+ type Env = { Bindings: Bindings; Variables: AppVariables };
14
+
15
+ export const pageRoutes = new Hono<Env>();
16
+
17
+ function PageContent({ page }: { page: Post }) {
18
+ const { t } = useLingui();
19
+
20
+ return (
21
+ <div class="container py-8 max-w-2xl">
22
+ <article class="h-entry">
23
+ {page.title && <h1 class="p-name text-3xl font-semibold mb-6">{page.title}</h1>}
24
+
25
+ <div
26
+ class="e-content prose"
27
+ dangerouslySetInnerHTML={{ __html: page.contentHtml || "" }}
28
+ />
29
+ </article>
30
+
31
+ <nav class="mt-8 pt-6 border-t">
32
+ <a href="/" class="text-sm hover:underline">
33
+ ← {t({ message: "Back to home", comment: "@context: Navigation link back to home page" })}
34
+ </a>
35
+ </nav>
36
+ </div>
37
+ );
38
+ }
39
+
40
+ // Catch-all for custom page paths
41
+ pageRoutes.get("/:path", async (c) => {
42
+ const path = c.req.param("path");
43
+
44
+ // Look up page by path
45
+ const page = await c.var.services.posts.getByPath(path);
46
+
47
+ // Not found or not a page
48
+ if (!page || page.type !== "page") {
49
+ return c.notFound();
50
+ }
51
+
52
+ // Don't show drafts
53
+ if (page.visibility === "draft") {
54
+ return c.notFound();
55
+ }
56
+
57
+ const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
58
+
59
+ return c.html(
60
+ <BaseLayout title={`${page.title} - ${siteName}`} description={page.content?.slice(0, 160)} c={c}>
61
+ <PageContent page={page} />
62
+ </BaseLayout>
63
+ );
64
+ });