@jant/core 0.3.22 → 0.3.24

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 (178) hide show
  1. package/dist/app.js +23 -5
  2. package/dist/db/schema.js +72 -47
  3. package/dist/i18n/locales/en.js +1 -1
  4. package/dist/i18n/locales/zh-Hans.js +1 -1
  5. package/dist/i18n/locales/zh-Hant.js +1 -1
  6. package/dist/index.js +5 -6
  7. package/dist/lib/constants.js +1 -4
  8. package/dist/lib/excerpt.js +76 -0
  9. package/dist/lib/feed.js +18 -7
  10. package/dist/lib/navigation.js +4 -5
  11. package/dist/lib/render.js +1 -1
  12. package/dist/lib/schemas.js +80 -38
  13. package/dist/lib/theme-components.js +8 -11
  14. package/dist/lib/time.js +56 -1
  15. package/dist/lib/timeline.js +119 -0
  16. package/dist/lib/view.js +62 -73
  17. package/dist/routes/api/posts.js +29 -35
  18. package/dist/routes/api/search.js +5 -6
  19. package/dist/routes/api/upload.js +13 -13
  20. package/dist/routes/dash/collections.js +22 -40
  21. package/dist/routes/dash/index.js +2 -2
  22. package/dist/routes/dash/navigation.js +25 -24
  23. package/dist/routes/dash/pages.js +42 -57
  24. package/dist/routes/dash/posts.js +27 -35
  25. package/dist/routes/feed/rss.js +2 -4
  26. package/dist/routes/feed/sitemap.js +10 -7
  27. package/dist/routes/pages/archive.js +12 -11
  28. package/dist/routes/pages/collection.js +11 -5
  29. package/dist/routes/pages/home.js +53 -61
  30. package/dist/routes/pages/page.js +60 -29
  31. package/dist/routes/pages/post.js +5 -12
  32. package/dist/routes/pages/search.js +3 -4
  33. package/dist/services/collection.js +52 -64
  34. package/dist/services/index.js +5 -3
  35. package/dist/services/navigation.js +29 -53
  36. package/dist/services/page.js +80 -0
  37. package/dist/services/post.js +68 -69
  38. package/dist/services/search.js +24 -18
  39. package/dist/theme/components/MediaGallery.js +19 -91
  40. package/dist/theme/components/PageForm.js +15 -15
  41. package/dist/theme/components/PostForm.js +136 -129
  42. package/dist/theme/components/PostList.js +13 -8
  43. package/dist/theme/components/ThreadView.js +3 -3
  44. package/dist/theme/components/TypeBadge.js +3 -14
  45. package/dist/theme/components/VisibilityBadge.js +33 -23
  46. package/dist/theme/components/index.js +0 -2
  47. package/dist/theme/index.js +10 -16
  48. package/dist/theme/layouts/index.js +0 -1
  49. package/dist/themes/threads/ThreadsSiteLayout.js +172 -0
  50. package/dist/themes/threads/index.js +81 -0
  51. package/dist/{theme → themes/threads}/pages/ArchivePage.js +31 -47
  52. package/dist/themes/threads/pages/CollectionPage.js +65 -0
  53. package/dist/{theme → themes/threads}/pages/HomePage.js +4 -5
  54. package/dist/{theme → themes/threads}/pages/PostPage.js +10 -8
  55. package/dist/{theme → themes/threads}/pages/SearchPage.js +8 -8
  56. package/dist/{theme → themes/threads}/pages/SinglePage.js +5 -6
  57. package/dist/{theme/components → themes/threads}/timeline/LinkCard.js +20 -11
  58. package/dist/themes/threads/timeline/NoteCard.js +53 -0
  59. package/dist/themes/threads/timeline/QuoteCard.js +59 -0
  60. package/dist/{theme/components → themes/threads}/timeline/ThreadPreview.js +5 -6
  61. package/dist/themes/threads/timeline/TimelineFeed.js +58 -0
  62. package/dist/{theme/components → themes/threads}/timeline/TimelineItem.js +8 -17
  63. package/dist/themes/threads/timeline/TimelineLoadMore.js +23 -0
  64. package/dist/themes/threads/timeline/groupByDate.js +22 -0
  65. package/dist/themes/threads/timeline/timelineMore.js +107 -0
  66. package/dist/types.js +24 -40
  67. package/package.json +2 -1
  68. package/src/__tests__/helpers/app.ts +4 -0
  69. package/src/__tests__/helpers/db.ts +51 -74
  70. package/src/app.tsx +27 -6
  71. package/src/db/migrations/0005_v2_schema_migration.sql +268 -0
  72. package/src/db/migrations/meta/_journal.json +7 -0
  73. package/src/db/schema.ts +63 -46
  74. package/src/i18n/locales/en.po +216 -164
  75. package/src/i18n/locales/en.ts +1 -1
  76. package/src/i18n/locales/zh-Hans.po +216 -164
  77. package/src/i18n/locales/zh-Hans.ts +1 -1
  78. package/src/i18n/locales/zh-Hant.po +216 -164
  79. package/src/i18n/locales/zh-Hant.ts +1 -1
  80. package/src/index.ts +30 -15
  81. package/src/lib/__tests__/excerpt.test.ts +125 -0
  82. package/src/lib/__tests__/schemas.test.ts +166 -105
  83. package/src/lib/__tests__/theme-components.test.ts +4 -25
  84. package/src/lib/__tests__/time.test.ts +62 -0
  85. package/src/{routes/api → lib}/__tests__/timeline.test.ts +108 -66
  86. package/src/lib/__tests__/view.test.ts +217 -67
  87. package/src/lib/constants.ts +1 -4
  88. package/src/lib/excerpt.ts +87 -0
  89. package/src/lib/feed.ts +22 -7
  90. package/src/lib/navigation.ts +6 -7
  91. package/src/lib/render.tsx +1 -1
  92. package/src/lib/schemas.ts +118 -52
  93. package/src/lib/theme-components.ts +10 -13
  94. package/src/lib/time.ts +64 -0
  95. package/src/lib/timeline.ts +170 -0
  96. package/src/lib/view.ts +81 -83
  97. package/src/preset.css +45 -0
  98. package/src/routes/api/__tests__/posts.test.ts +50 -108
  99. package/src/routes/api/__tests__/search.test.ts +2 -3
  100. package/src/routes/api/posts.ts +30 -30
  101. package/src/routes/api/search.ts +4 -4
  102. package/src/routes/api/upload.ts +16 -6
  103. package/src/routes/dash/collections.tsx +18 -40
  104. package/src/routes/dash/index.tsx +2 -2
  105. package/src/routes/dash/navigation.tsx +27 -26
  106. package/src/routes/dash/pages.tsx +45 -60
  107. package/src/routes/dash/posts.tsx +44 -52
  108. package/src/routes/feed/rss.ts +2 -1
  109. package/src/routes/feed/sitemap.ts +14 -4
  110. package/src/routes/pages/archive.tsx +14 -10
  111. package/src/routes/pages/collection.tsx +17 -6
  112. package/src/routes/pages/home.tsx +56 -81
  113. package/src/routes/pages/page.tsx +64 -27
  114. package/src/routes/pages/post.tsx +5 -14
  115. package/src/routes/pages/search.tsx +2 -2
  116. package/src/services/__tests__/collection.test.ts +257 -158
  117. package/src/services/__tests__/media.test.ts +18 -18
  118. package/src/services/__tests__/navigation.test.ts +161 -87
  119. package/src/services/__tests__/post-timeline.test.ts +92 -88
  120. package/src/services/__tests__/post.test.ts +342 -206
  121. package/src/services/__tests__/search.test.ts +19 -25
  122. package/src/services/collection.ts +71 -113
  123. package/src/services/index.ts +9 -8
  124. package/src/services/navigation.ts +38 -71
  125. package/src/services/page.ts +124 -0
  126. package/src/services/post.ts +93 -103
  127. package/src/services/search.ts +38 -27
  128. package/src/styles/components.css +0 -54
  129. package/src/theme/components/MediaGallery.tsx +27 -96
  130. package/src/theme/components/PageForm.tsx +21 -21
  131. package/src/theme/components/PostForm.tsx +122 -118
  132. package/src/theme/components/PostList.tsx +58 -49
  133. package/src/theme/components/ThreadView.tsx +6 -3
  134. package/src/theme/components/TypeBadge.tsx +9 -17
  135. package/src/theme/components/VisibilityBadge.tsx +40 -23
  136. package/src/theme/components/index.ts +0 -13
  137. package/src/theme/index.ts +10 -16
  138. package/src/theme/layouts/index.ts +0 -1
  139. package/src/themes/threads/ThreadsSiteLayout.tsx +194 -0
  140. package/src/themes/threads/index.ts +100 -0
  141. package/src/{theme → themes/threads}/pages/ArchivePage.tsx +52 -55
  142. package/src/themes/threads/pages/CollectionPage.tsx +61 -0
  143. package/src/{theme → themes/threads}/pages/HomePage.tsx +5 -6
  144. package/src/{theme → themes/threads}/pages/PostPage.tsx +11 -8
  145. package/src/{theme → themes/threads}/pages/SearchPage.tsx +9 -13
  146. package/src/themes/threads/pages/SinglePage.tsx +23 -0
  147. package/src/themes/threads/style.css +336 -0
  148. package/src/{theme/components → themes/threads}/timeline/LinkCard.tsx +21 -13
  149. package/src/themes/threads/timeline/NoteCard.tsx +58 -0
  150. package/src/themes/threads/timeline/QuoteCard.tsx +63 -0
  151. package/src/{theme/components → themes/threads}/timeline/ThreadPreview.tsx +6 -6
  152. package/src/themes/threads/timeline/TimelineFeed.tsx +62 -0
  153. package/src/{theme/components → themes/threads}/timeline/TimelineItem.tsx +9 -20
  154. package/src/themes/threads/timeline/TimelineLoadMore.tsx +35 -0
  155. package/src/themes/threads/timeline/groupByDate.ts +30 -0
  156. package/src/themes/threads/timeline/timelineMore.tsx +130 -0
  157. package/src/types.ts +242 -98
  158. package/dist/routes/api/timeline.js +0 -120
  159. package/dist/theme/components/timeline/ArticleCard.js +0 -46
  160. package/dist/theme/components/timeline/ImageCard.js +0 -83
  161. package/dist/theme/components/timeline/NoteCard.js +0 -34
  162. package/dist/theme/components/timeline/QuoteCard.js +0 -48
  163. package/dist/theme/components/timeline/TimelineFeed.js +0 -46
  164. package/dist/theme/components/timeline/index.js +0 -8
  165. package/dist/theme/layouts/SiteLayout.js +0 -131
  166. package/dist/theme/pages/CollectionPage.js +0 -63
  167. package/dist/theme/pages/index.js +0 -11
  168. package/src/routes/api/timeline.tsx +0 -159
  169. package/src/theme/components/timeline/ArticleCard.tsx +0 -45
  170. package/src/theme/components/timeline/ImageCard.tsx +0 -70
  171. package/src/theme/components/timeline/NoteCard.tsx +0 -34
  172. package/src/theme/components/timeline/QuoteCard.tsx +0 -48
  173. package/src/theme/components/timeline/TimelineFeed.tsx +0 -56
  174. package/src/theme/components/timeline/index.ts +0 -8
  175. package/src/theme/layouts/SiteLayout.tsx +0 -132
  176. package/src/theme/pages/CollectionPage.tsx +0 -60
  177. package/src/theme/pages/SinglePage.tsx +0 -24
  178. package/src/theme/pages/index.ts +0 -13
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Test Database Helper
3
3
  *
4
- * Creates an in-memory SQLite database with all migrations applied.
4
+ * Creates an in-memory SQLite database with all migrations applied (up to v2).
5
5
  * Used for service integration tests.
6
6
  */
7
7
 
@@ -13,11 +13,22 @@ import { resolve } from "path";
13
13
 
14
14
  const MIGRATIONS_DIR = resolve(import.meta.dirname, "../../db/migrations");
15
15
 
16
+ /**
17
+ * Applies a migration file, splitting on Drizzle statement breakpoints.
18
+ */
19
+ function applyMigration(sqlite: Database.Database, filename: string) {
20
+ const migration = readFileSync(resolve(MIGRATIONS_DIR, filename), "utf-8");
21
+ for (const sql of migration.split("--> statement-breakpoint")) {
22
+ const trimmed = sql.trim();
23
+ if (trimmed) sqlite.exec(trimmed);
24
+ }
25
+ }
26
+
16
27
  /**
17
28
  * Creates a fresh in-memory SQLite database with all migrations applied.
18
29
  * Each call returns an isolated database instance for test isolation.
19
30
  *
20
- * @param options.fts - Whether to apply FTS5 migration (default: false).
31
+ * @param options.fts - Whether to enable FTS5 for search tests (default: false).
21
32
  * The trigram tokenizer used in production may not be available in all
22
33
  * better-sqlite3 builds, so FTS is opt-in for tests that need it.
23
34
  */
@@ -28,87 +39,53 @@ export function createTestDatabase(options?: { fts?: boolean }) {
28
39
  sqlite.pragma("journal_mode = WAL");
29
40
  sqlite.pragma("foreign_keys = ON");
30
41
 
31
- // Apply base schema migration
32
- const migration0 = readFileSync(
33
- resolve(MIGRATIONS_DIR, "0000_square_wallflower.sql"),
42
+ // Apply v1 base migrations (0000-0004)
43
+ applyMigration(sqlite, "0000_square_wallflower.sql");
44
+ // Skip 0001 (FTS) — v2 migration will create updated FTS if needed
45
+ applyMigration(sqlite, "0002_add_media_attachments.sql");
46
+ applyMigration(sqlite, "0003_add_navigation_links.sql");
47
+ applyMigration(sqlite, "0004_add_storage_provider.sql");
48
+
49
+ // Apply v2 schema migration (0005)
50
+ // Split FTS-related statements so we can handle them separately
51
+ const v2Migration = readFileSync(
52
+ resolve(MIGRATIONS_DIR, "0005_v2_schema_migration.sql"),
34
53
  "utf-8",
35
54
  );
36
55
 
37
- // Drizzle migrations use --> statement-breakpoint as separator
38
- for (const sql of migration0.split("--> statement-breakpoint")) {
39
- const trimmed = sql.trim();
40
- if (trimmed) sqlite.exec(trimmed);
41
- }
56
+ for (const stmt of v2Migration.split("--> statement-breakpoint")) {
57
+ const trimmed = stmt.trim();
58
+ if (!trimmed) continue;
59
+
60
+ // Skip FTS-related statements if FTS not requested
61
+ const isFts = trimmed.includes("posts_fts");
62
+ if (!options?.fts && isFts) continue;
42
63
 
43
- // Optionally apply FTS5 migration (with fallback tokenizer)
44
- if (options?.fts) {
45
64
  try {
46
- const migration1 = readFileSync(
47
- resolve(MIGRATIONS_DIR, "0001_add_search_fts.sql"),
48
- "utf-8",
49
- );
50
- sqlite.exec(migration1);
65
+ sqlite.exec(trimmed);
51
66
  } catch {
52
- // Fallback: create FTS table with default tokenizer if trigram not available
53
- sqlite.exec(`
54
- CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
55
- title,
56
- content,
57
- content='posts',
58
- content_rowid='id'
59
- );
60
-
61
- CREATE TRIGGER IF NOT EXISTS posts_fts_insert AFTER INSERT ON posts
62
- WHEN NEW.deleted_at IS NULL
63
- BEGIN
64
- INSERT INTO posts_fts(rowid, title, content)
65
- VALUES (NEW.id, COALESCE(NEW.title, ''), COALESCE(NEW.content, ''));
66
- END;
67
-
68
- CREATE TRIGGER IF NOT EXISTS posts_fts_update AFTER UPDATE ON posts BEGIN
69
- DELETE FROM posts_fts WHERE rowid = OLD.id;
70
- INSERT INTO posts_fts(rowid, title, content)
71
- SELECT NEW.id, COALESCE(NEW.title, ''), COALESCE(NEW.content, '')
72
- WHERE NEW.deleted_at IS NULL;
73
- END;
74
-
75
- CREATE TRIGGER IF NOT EXISTS posts_fts_delete AFTER DELETE ON posts BEGIN
76
- DELETE FROM posts_fts WHERE rowid = OLD.id;
77
- END;
78
- `);
67
+ // Handle trigram tokenizer failure for FTS virtual table
68
+ if (options?.fts && trimmed.includes("CREATE VIRTUAL TABLE")) {
69
+ sqlite.exec(`
70
+ CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
71
+ title,
72
+ body,
73
+ quote_text,
74
+ content='posts',
75
+ content_rowid='id'
76
+ );
77
+ `);
78
+ }
79
+ // Ignore DROP TRIGGER/TABLE IF EXISTS failures silently
80
+ else if (
81
+ !trimmed.startsWith("DROP TRIGGER") &&
82
+ !trimmed.startsWith("DROP TABLE")
83
+ ) {
84
+ throw new Error(`Migration statement failed: ${trimmed.slice(0, 100)}`);
85
+ }
79
86
  }
80
87
  }
81
88
 
82
- // Apply media attachments migration (position + blurhash)
83
- const migration2 = readFileSync(
84
- resolve(MIGRATIONS_DIR, "0002_add_media_attachments.sql"),
85
- "utf-8",
86
- );
87
- for (const sql of migration2.split("--> statement-breakpoint")) {
88
- const trimmed = sql.trim();
89
- if (trimmed) sqlite.exec(trimmed);
90
- }
91
-
92
- // Apply navigation links migration
93
- const migration3 = readFileSync(
94
- resolve(MIGRATIONS_DIR, "0003_add_navigation_links.sql"),
95
- "utf-8",
96
- );
97
- for (const sql of migration3.split("--> statement-breakpoint")) {
98
- const trimmed = sql.trim();
99
- if (trimmed) sqlite.exec(trimmed);
100
- }
101
-
102
- // Apply storage provider migration
103
- const migration4 = readFileSync(
104
- resolve(MIGRATIONS_DIR, "0004_add_storage_provider.sql"),
105
- "utf-8",
106
- );
107
- for (const sql of migration4.split("--> statement-breakpoint")) {
108
- const trimmed = sql.trim();
109
- if (trimmed) sqlite.exec(trimmed);
110
- }
111
-
112
89
  const db = drizzle(sqlite, { schema });
113
90
 
114
91
  return { db, sqlite };
package/src/app.tsx CHANGED
@@ -11,6 +11,7 @@ import { i18nMiddleware } from "./i18n/index.js";
11
11
  import { useLingui } from "@lingui/react/macro";
12
12
  import type { Bindings, JantConfig } from "./types.js";
13
13
  import { SETTINGS_KEYS } from "./lib/constants.js";
14
+ import { theme as threadsTheme } from "./themes/threads/index.js";
14
15
  import { hashPassword } from "better-auth/crypto";
15
16
 
16
17
  // Routes - Pages
@@ -35,8 +36,6 @@ import { navigationRoutes as dashNavigationRoutes } from "./routes/dash/navigati
35
36
  import { postsApiRoutes } from "./routes/api/posts.js";
36
37
  import { uploadApiRoutes } from "./routes/api/upload.js";
37
38
  import { searchApiRoutes } from "./routes/api/search.js";
38
- import { timelineApiRoutes } from "./routes/api/timeline.js";
39
-
40
39
  // Routes - Feed
41
40
  import { rssRoutes } from "./routes/feed/rss.js";
42
41
  import { sitemapRoutes } from "./routes/feed/sitemap.js";
@@ -82,6 +81,26 @@ export type App = Hono<{ Bindings: Bindings; Variables: AppVariables }>;
82
81
  * ```
83
82
  */
84
83
  export function createApp(config: JantConfig = {}): App {
84
+ // Merge with default threads theme
85
+ const defaultTheme = threadsTheme();
86
+ const resolvedConfig: JantConfig = {
87
+ ...config,
88
+ theme: {
89
+ name: config.theme?.name ?? defaultTheme.name,
90
+ components: {
91
+ ...defaultTheme.components,
92
+ ...config.theme?.components,
93
+ },
94
+ timelineMore: config.theme?.timelineMore ?? defaultTheme.timelineMore,
95
+ cssVariables: {
96
+ ...defaultTheme.cssVariables,
97
+ ...config.theme?.cssVariables,
98
+ },
99
+ colorThemes: config.theme?.colorThemes ?? defaultTheme.colorThemes,
100
+ feed: config.theme?.feed,
101
+ },
102
+ };
103
+
85
104
  const app = new Hono<{ Bindings: Bindings; Variables: AppVariables }>();
86
105
 
87
106
  // Initialize services, auth, and config middleware
@@ -96,7 +115,7 @@ export function createApp(config: JantConfig = {}): App {
96
115
  const db = createDatabase(session as unknown as D1Database);
97
116
  const services = createServices(db, session as unknown as D1Database);
98
117
  c.set("services", services);
99
- c.set("config", config);
118
+ c.set("config", resolvedConfig);
100
119
  c.set("storage", createStorageDriver(c.env));
101
120
 
102
121
  if (c.env.AUTH_SECRET) {
@@ -117,11 +136,14 @@ export function createApp(config: JantConfig = {}): App {
117
136
  // Theme middleware - resolve active color theme and build CSS
118
137
  app.use("*", async (c, next) => {
119
138
  const themeId = await c.var.services.settings.get(SETTINGS_KEYS.THEME);
120
- const themes = getAvailableThemes(config);
139
+ const themes = getAvailableThemes(resolvedConfig);
121
140
  const activeTheme = themeId
122
141
  ? themes.find((t) => t.id === themeId)
123
142
  : undefined;
124
- const themeStyle = buildThemeStyle(activeTheme, config.theme?.cssVariables);
143
+ const themeStyle = buildThemeStyle(
144
+ activeTheme,
145
+ resolvedConfig.theme?.cssVariables,
146
+ );
125
147
  c.set("themeStyle", themeStyle);
126
148
  await next();
127
149
  });
@@ -174,7 +196,6 @@ export function createApp(config: JantConfig = {}): App {
174
196
 
175
197
  // API Routes
176
198
  app.route("/api/posts", postsApiRoutes);
177
- app.route("/api/timeline", timelineApiRoutes);
178
199
 
179
200
  // Setup page component
180
201
  const SetupContent: FC = () => {
@@ -0,0 +1,268 @@
1
+ -- v2 Schema Migration
2
+ -- Restructures posts, creates pages, updates collections, replaces navigation_links with nav_items
3
+
4
+ -- Disable FK checks for migration (dropping/recreating tables with cross-references)
5
+ PRAGMA foreign_keys = OFF;
6
+ --> statement-breakpoint
7
+
8
+ -- =============================================================================
9
+ -- 1. Create pages table (before modifying posts, migrate type='page' data)
10
+ -- =============================================================================
11
+
12
+ CREATE TABLE `pages` (
13
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
14
+ `slug` text NOT NULL,
15
+ `title` text,
16
+ `body` text,
17
+ `body_html` text,
18
+ `status` text DEFAULT 'published' NOT NULL,
19
+ `created_at` integer NOT NULL,
20
+ `updated_at` integer NOT NULL
21
+ );
22
+ --> statement-breakpoint
23
+ CREATE UNIQUE INDEX `pages_slug_unique` ON `pages` (`slug`);
24
+ --> statement-breakpoint
25
+
26
+ -- Migrate type='page' posts into pages table
27
+ INSERT INTO `pages` (`slug`, `title`, `body`, `body_html`, `status`, `created_at`, `updated_at`)
28
+ SELECT
29
+ CASE
30
+ WHEN `path` IS NOT NULL AND `path` != '' THEN REPLACE(`path`, '/', '')
31
+ ELSE 'page-' || `id`
32
+ END,
33
+ `title`,
34
+ `content`,
35
+ `content_html`,
36
+ CASE WHEN `visibility` = 'draft' THEN 'draft' ELSE 'published' END,
37
+ `created_at`,
38
+ `updated_at`
39
+ FROM `posts`
40
+ WHERE `type` = 'page';
41
+ --> statement-breakpoint
42
+
43
+ -- =============================================================================
44
+ -- 2. Restructure posts table (create new → migrate → drop old → rename)
45
+ -- =============================================================================
46
+
47
+ CREATE TABLE `posts_new` (
48
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
49
+ `format` text DEFAULT 'note' NOT NULL,
50
+ `status` text DEFAULT 'published' NOT NULL,
51
+ `featured` integer DEFAULT 0 NOT NULL,
52
+ `pinned` integer DEFAULT 0 NOT NULL,
53
+ `slug` text,
54
+ `title` text,
55
+ `url` text,
56
+ `body` text,
57
+ `body_html` text,
58
+ `quote_text` text,
59
+ `rating` integer,
60
+ `collection_id` integer,
61
+ `reply_to_id` integer,
62
+ `thread_id` integer,
63
+ `deleted_at` integer,
64
+ `published_at` integer NOT NULL,
65
+ `created_at` integer NOT NULL,
66
+ `updated_at` integer NOT NULL,
67
+ FOREIGN KEY (`collection_id`) REFERENCES `collections`(`id`) ON UPDATE no action ON DELETE set null
68
+ );
69
+ --> statement-breakpoint
70
+
71
+ -- Migrate data from old posts to new posts (excluding type='page')
72
+ INSERT INTO `posts_new` (
73
+ `id`, `format`, `status`, `featured`, `pinned`,
74
+ `slug`, `title`, `url`, `body`, `body_html`, `quote_text`, `rating`,
75
+ `collection_id`, `reply_to_id`, `thread_id`,
76
+ `deleted_at`, `published_at`, `created_at`, `updated_at`
77
+ )
78
+ SELECT
79
+ `id`,
80
+ -- format mapping: article→note, image→note, note→note, link→link, quote→quote
81
+ CASE
82
+ WHEN `type` IN ('article', 'image', 'note') THEN 'note'
83
+ WHEN `type` = 'link' THEN 'link'
84
+ WHEN `type` = 'quote' THEN 'quote'
85
+ ELSE 'note'
86
+ END,
87
+ -- status mapping from visibility
88
+ CASE WHEN `visibility` = 'draft' THEN 'draft' ELSE 'published' END,
89
+ -- featured mapping from visibility
90
+ CASE WHEN `visibility` = 'featured' THEN 1 ELSE 0 END,
91
+ -- pinned: default 0
92
+ 0,
93
+ -- slug: migrate from path (strip leading /)
94
+ CASE
95
+ WHEN `path` IS NOT NULL AND `path` != '' THEN REPLACE(`path`, '/', '')
96
+ ELSE NULL
97
+ END,
98
+ `title`,
99
+ `source_url`,
100
+ `content`,
101
+ `content_html`,
102
+ -- quote_text: for quote type, content was the quote; set to null (manual fix may be needed)
103
+ NULL,
104
+ -- rating: null
105
+ NULL,
106
+ -- collection_id: migrate from post_collections (take first collection for each post)
107
+ (SELECT `collection_id` FROM `post_collections` WHERE `post_collections`.`post_id` = `posts`.`id` LIMIT 1),
108
+ `reply_to_id`,
109
+ `thread_id`,
110
+ `deleted_at`,
111
+ `published_at`,
112
+ `created_at`,
113
+ `updated_at`
114
+ FROM `posts`
115
+ WHERE `type` != 'page';
116
+ --> statement-breakpoint
117
+
118
+ -- Update media references to point at new table (foreign keys reference posts)
119
+ -- media.post_id still works since IDs are preserved
120
+ --> statement-breakpoint
121
+
122
+ -- Drop old posts table and rename new one
123
+ DROP TABLE `posts`;
124
+ --> statement-breakpoint
125
+ ALTER TABLE `posts_new` RENAME TO `posts`;
126
+ --> statement-breakpoint
127
+ CREATE UNIQUE INDEX `posts_slug_unique` ON `posts` (`slug`);
128
+ --> statement-breakpoint
129
+
130
+ -- =============================================================================
131
+ -- 3. Update collections table (add new columns, rename path→slug)
132
+ -- =============================================================================
133
+
134
+ CREATE TABLE `collections_new` (
135
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
136
+ `slug` text NOT NULL,
137
+ `title` text NOT NULL,
138
+ `description` text,
139
+ `icon` text,
140
+ `sort_order` text DEFAULT 'newest' NOT NULL,
141
+ `position` integer DEFAULT 0 NOT NULL,
142
+ `show_divider` integer DEFAULT 0 NOT NULL,
143
+ `created_at` integer NOT NULL,
144
+ `updated_at` integer NOT NULL
145
+ );
146
+ --> statement-breakpoint
147
+ CREATE UNIQUE INDEX `collections_new_slug_unique` ON `collections_new` (`slug`);
148
+ --> statement-breakpoint
149
+
150
+ INSERT INTO `collections_new` (`id`, `slug`, `title`, `description`, `icon`, `sort_order`, `position`, `show_divider`, `created_at`, `updated_at`)
151
+ SELECT
152
+ `id`,
153
+ COALESCE(`path`, 'collection-' || `id`),
154
+ `title`,
155
+ `description`,
156
+ NULL,
157
+ 'newest',
158
+ 0,
159
+ 0,
160
+ `created_at`,
161
+ `updated_at`
162
+ FROM `collections`;
163
+ --> statement-breakpoint
164
+
165
+ DROP TABLE `collections`;
166
+ --> statement-breakpoint
167
+ ALTER TABLE `collections_new` RENAME TO `collections`;
168
+ --> statement-breakpoint
169
+ CREATE UNIQUE INDEX `collections_slug_unique` ON `collections` (`slug`);
170
+ --> statement-breakpoint
171
+
172
+ -- =============================================================================
173
+ -- 4. Replace navigation_links with nav_items
174
+ -- =============================================================================
175
+
176
+ CREATE TABLE `nav_items` (
177
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
178
+ `type` text DEFAULT 'link' NOT NULL,
179
+ `label` text NOT NULL,
180
+ `url` text NOT NULL,
181
+ `page_id` integer,
182
+ `position` integer DEFAULT 0 NOT NULL,
183
+ `created_at` integer NOT NULL,
184
+ `updated_at` integer NOT NULL,
185
+ FOREIGN KEY (`page_id`) REFERENCES `pages`(`id`) ON UPDATE no action ON DELETE cascade
186
+ );
187
+ --> statement-breakpoint
188
+
189
+ -- Migrate existing navigation_links as type='link'
190
+ INSERT INTO `nav_items` (`type`, `label`, `url`, `page_id`, `position`, `created_at`, `updated_at`)
191
+ SELECT 'link', `label`, `url`, NULL, `position`, `created_at`, `updated_at`
192
+ FROM `navigation_links`;
193
+ --> statement-breakpoint
194
+
195
+ DROP TABLE `navigation_links`;
196
+ --> statement-breakpoint
197
+
198
+ -- =============================================================================
199
+ -- 5. Drop post_collections table (replaced by posts.collection_id)
200
+ -- =============================================================================
201
+
202
+ DROP TABLE `post_collections`;
203
+ --> statement-breakpoint
204
+
205
+ -- =============================================================================
206
+ -- 6. Rebuild FTS5 (column rename: content→body, add quote_text)
207
+ -- =============================================================================
208
+
209
+ -- Drop old FTS triggers
210
+ DROP TRIGGER IF EXISTS posts_fts_insert;
211
+ --> statement-breakpoint
212
+ DROP TRIGGER IF EXISTS posts_fts_update;
213
+ --> statement-breakpoint
214
+ DROP TRIGGER IF EXISTS posts_fts_delete;
215
+ --> statement-breakpoint
216
+
217
+ -- Drop old FTS table
218
+ DROP TABLE IF EXISTS posts_fts;
219
+ --> statement-breakpoint
220
+
221
+ -- Create new FTS table with updated columns
222
+ CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
223
+ title,
224
+ body,
225
+ quote_text,
226
+ content=posts,
227
+ content_rowid=id,
228
+ tokenize='trigram'
229
+ );
230
+ --> statement-breakpoint
231
+
232
+ -- Populate FTS with migrated data
233
+ INSERT INTO posts_fts(rowid, title, body, quote_text)
234
+ SELECT id, COALESCE(title, ''), COALESCE(body, ''), COALESCE(quote_text, '')
235
+ FROM posts WHERE deleted_at IS NULL;
236
+ --> statement-breakpoint
237
+
238
+ -- Trigger: sync FTS on INSERT
239
+ CREATE TRIGGER posts_fts_insert AFTER INSERT ON posts
240
+ WHEN NEW.deleted_at IS NULL
241
+ BEGIN
242
+ INSERT INTO posts_fts(rowid, title, body, quote_text)
243
+ VALUES (NEW.id, COALESCE(NEW.title, ''), COALESCE(NEW.body, ''), COALESCE(NEW.quote_text, ''));
244
+ END;
245
+ --> statement-breakpoint
246
+
247
+ -- Trigger: sync FTS on UPDATE
248
+ CREATE TRIGGER posts_fts_update AFTER UPDATE ON posts BEGIN
249
+ DELETE FROM posts_fts WHERE rowid = OLD.id;
250
+ INSERT INTO posts_fts(rowid, title, body, quote_text)
251
+ SELECT NEW.id, COALESCE(NEW.title, ''), COALESCE(NEW.body, ''), COALESCE(NEW.quote_text, '')
252
+ WHERE NEW.deleted_at IS NULL;
253
+ END;
254
+ --> statement-breakpoint
255
+
256
+ -- Trigger: sync FTS on DELETE
257
+ CREATE TRIGGER posts_fts_delete AFTER DELETE ON posts BEGIN
258
+ DELETE FROM posts_fts WHERE rowid = OLD.id;
259
+ END;
260
+ --> statement-breakpoint
261
+
262
+ -- =============================================================================
263
+ -- 7. Re-enable FK checks and verify integrity
264
+ -- =============================================================================
265
+
266
+ PRAGMA foreign_keys = ON;
267
+ --> statement-breakpoint
268
+ PRAGMA foreign_key_check;
@@ -36,6 +36,13 @@
36
36
  "when": 1770946168874,
37
37
  "tag": "0004_add_storage_provider",
38
38
  "breakpoints": true
39
+ },
40
+ {
41
+ "idx": 5,
42
+ "version": "6",
43
+ "when": 1771346168875,
44
+ "tag": "0005_v2_schema_migration",
45
+ "breakpoints": true
39
46
  }
40
47
  ]
41
48
  }
package/src/db/schema.ts CHANGED
@@ -1,15 +1,10 @@
1
1
  /**
2
2
  * Drizzle Schema
3
3
  *
4
- * Database schema for Jant
4
+ * Database schema for Jant v2
5
5
  */
6
6
 
7
- import {
8
- sqliteTable,
9
- text,
10
- integer,
11
- primaryKey,
12
- } from "drizzle-orm/sqlite-core";
7
+ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
13
8
 
14
9
  // =============================================================================
15
10
  // Posts
@@ -17,21 +12,26 @@ import {
17
12
 
18
13
  export const posts = sqliteTable("posts", {
19
14
  id: integer("id").primaryKey({ autoIncrement: true }),
20
- type: text("type", {
21
- enum: ["note", "article", "link", "quote", "image", "page"],
15
+ format: text("format", {
16
+ enum: ["note", "link", "quote"],
22
17
  }).notNull(),
23
- visibility: text("visibility", {
24
- enum: ["featured", "quiet", "unlisted", "draft"],
18
+ status: text("status", {
19
+ enum: ["draft", "published"],
25
20
  })
26
21
  .notNull()
27
- .default("quiet"),
22
+ .default("published"),
23
+ featured: integer("featured").notNull().default(0),
24
+ pinned: integer("pinned").notNull().default(0),
25
+ slug: text("slug").unique(),
28
26
  title: text("title"),
29
- path: text("path"),
30
- content: text("content"),
31
- contentHtml: text("content_html"),
32
- sourceUrl: text("source_url"),
33
- sourceName: text("source_name"),
34
- sourceDomain: text("source_domain"),
27
+ url: text("url"),
28
+ body: text("body"),
29
+ bodyHtml: text("body_html"),
30
+ quoteText: text("quote_text"),
31
+ rating: integer("rating"),
32
+ collectionId: integer("collection_id").references(() => collections.id, {
33
+ onDelete: "set null",
34
+ }),
35
35
  replyToId: integer("reply_to_id"),
36
36
  threadId: integer("thread_id"),
37
37
  deletedAt: integer("deleted_at"),
@@ -40,6 +40,25 @@ export const posts = sqliteTable("posts", {
40
40
  updatedAt: integer("updated_at").notNull(),
41
41
  });
42
42
 
43
+ // =============================================================================
44
+ // Pages
45
+ // =============================================================================
46
+
47
+ export const pages = sqliteTable("pages", {
48
+ id: integer("id").primaryKey({ autoIncrement: true }),
49
+ slug: text("slug").notNull().unique(),
50
+ title: text("title"),
51
+ body: text("body"),
52
+ bodyHtml: text("body_html"),
53
+ status: text("status", {
54
+ enum: ["draft", "published"],
55
+ })
56
+ .notNull()
57
+ .default("published"),
58
+ createdAt: integer("created_at").notNull(),
59
+ updatedAt: integer("updated_at").notNull(),
60
+ });
61
+
43
62
  // =============================================================================
44
63
  // Media
45
64
  // =============================================================================
@@ -67,30 +86,41 @@ export const media = sqliteTable("media", {
67
86
 
68
87
  export const collections = sqliteTable("collections", {
69
88
  id: integer("id").primaryKey({ autoIncrement: true }),
70
- path: text("path").unique(),
89
+ slug: text("slug").notNull().unique(),
71
90
  title: text("title").notNull(),
72
91
  description: text("description"),
92
+ icon: text("icon"),
93
+ sortOrder: text("sort_order", {
94
+ enum: ["newest", "oldest", "rating_desc", "rating_asc"],
95
+ })
96
+ .notNull()
97
+ .default("newest"),
98
+ position: integer("position").notNull().default(0),
99
+ showDivider: integer("show_divider").notNull().default(0),
73
100
  createdAt: integer("created_at").notNull(),
74
101
  updatedAt: integer("updated_at").notNull(),
75
102
  });
76
103
 
77
104
  // =============================================================================
78
- // Post-Collections (Many-to-Many)
105
+ // Navigation Items
79
106
  // =============================================================================
80
107
 
81
- export const postCollections = sqliteTable(
82
- "post_collections",
83
- {
84
- postId: integer("post_id")
85
- .notNull()
86
- .references(() => posts.id),
87
- collectionId: integer("collection_id")
88
- .notNull()
89
- .references(() => collections.id),
90
- addedAt: integer("added_at").notNull(),
91
- },
92
- (table) => [primaryKey({ columns: [table.postId, table.collectionId] })],
93
- );
108
+ export const navItems = sqliteTable("nav_items", {
109
+ id: integer("id").primaryKey({ autoIncrement: true }),
110
+ type: text("type", {
111
+ enum: ["page", "link"],
112
+ })
113
+ .notNull()
114
+ .default("link"),
115
+ label: text("label").notNull(),
116
+ url: text("url").notNull(),
117
+ pageId: integer("page_id").references(() => pages.id, {
118
+ onDelete: "cascade",
119
+ }),
120
+ position: integer("position").notNull().default(0),
121
+ createdAt: integer("created_at").notNull(),
122
+ updatedAt: integer("updated_at").notNull(),
123
+ });
94
124
 
95
125
  // =============================================================================
96
126
  // Redirects
@@ -104,19 +134,6 @@ export const redirects = sqliteTable("redirects", {
104
134
  createdAt: integer("created_at").notNull(),
105
135
  });
106
136
 
107
- // =============================================================================
108
- // Navigation Links
109
- // =============================================================================
110
-
111
- export const navigationLinks = sqliteTable("navigation_links", {
112
- id: integer("id").primaryKey({ autoIncrement: true }),
113
- label: text("label").notNull(),
114
- url: text("url").notNull(),
115
- position: integer("position").notNull().default(0),
116
- createdAt: integer("created_at").notNull(),
117
- updatedAt: integer("updated_at").notNull(),
118
- });
119
-
120
137
  // =============================================================================
121
138
  // Settings (Key-Value)
122
139
  // =============================================================================