@jant/core 0.3.1 → 0.3.3

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 (41) hide show
  1. package/dist/app.d.ts.map +1 -1
  2. package/dist/app.js +0 -2
  3. package/dist/lib/constants.d.ts +1 -1
  4. package/dist/lib/constants.d.ts.map +1 -1
  5. package/dist/lib/constants.js +1 -2
  6. package/dist/routes/api/posts.js +1 -1
  7. package/dist/routes/dash/settings.d.ts +2 -0
  8. package/dist/routes/dash/settings.d.ts.map +1 -1
  9. package/dist/routes/dash/settings.js +413 -93
  10. package/dist/theme/layouts/DashLayout.d.ts.map +1 -1
  11. package/dist/theme/layouts/DashLayout.js +0 -8
  12. package/package.json +10 -5
  13. package/src/__tests__/helpers/app.ts +97 -0
  14. package/src/__tests__/helpers/db.ts +85 -0
  15. package/src/app.tsx +0 -3
  16. package/src/db/migrations/0001_add_search_fts.sql +34 -0
  17. package/src/db/migrations/meta/_journal.json +7 -0
  18. package/src/lib/__tests__/constants.test.ts +44 -0
  19. package/src/lib/__tests__/markdown.test.ts +133 -0
  20. package/src/lib/__tests__/schemas.test.ts +220 -0
  21. package/src/lib/__tests__/sqid.test.ts +65 -0
  22. package/src/lib/__tests__/sse.test.ts +86 -0
  23. package/src/lib/__tests__/time.test.ts +112 -0
  24. package/src/lib/__tests__/url.test.ts +138 -0
  25. package/src/lib/constants.ts +0 -1
  26. package/src/middleware/__tests__/auth.test.ts +139 -0
  27. package/src/routes/api/__tests__/posts.test.ts +306 -0
  28. package/src/routes/api/__tests__/search.test.ts +77 -0
  29. package/src/routes/api/posts.ts +3 -1
  30. package/src/routes/dash/settings.tsx +350 -16
  31. package/src/services/__tests__/collection.test.ts +226 -0
  32. package/src/services/__tests__/media.test.ts +134 -0
  33. package/src/services/__tests__/post.test.ts +636 -0
  34. package/src/services/__tests__/redirect.test.ts +110 -0
  35. package/src/services/__tests__/search.test.ts +143 -0
  36. package/src/services/__tests__/settings.test.ts +110 -0
  37. package/src/theme/layouts/DashLayout.tsx +0 -9
  38. package/dist/routes/dash/appearance.d.ts +0 -13
  39. package/dist/routes/dash/appearance.d.ts.map +0 -1
  40. package/dist/routes/dash/appearance.js +0 -160
  41. package/src/routes/dash/appearance.tsx +0 -176
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jant/core",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "A modern, open-source microblogging platform built on Cloudflare Workers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,7 +25,6 @@
25
25
  "files": [
26
26
  "bin",
27
27
  "dist",
28
- "migrations",
29
28
  "src"
30
29
  ],
31
30
  "publishConfig": {
@@ -54,12 +53,15 @@
54
53
  "@lingui/swc-plugin": "^5.10.1",
55
54
  "@swc/cli": "^0.6.0",
56
55
  "@swc/core": "^1.15.11",
56
+ "@types/better-sqlite3": "^7.6.13",
57
57
  "@types/node": "^25.1.0",
58
+ "better-sqlite3": "^12.6.2",
58
59
  "drizzle-kit": "^0.31.8",
59
60
  "glob": "^13.0.0",
60
61
  "tailwindcss": "^4.1.18",
61
62
  "tsx": "^4.21.0",
62
- "typescript": "^5.9.3"
63
+ "typescript": "^5.9.3",
64
+ "vitest": "^4.0.18"
63
65
  },
64
66
  "repository": {
65
67
  "type": "git",
@@ -86,12 +88,15 @@
86
88
  },
87
89
  "scripts": {
88
90
  "build": "pnpm build:lib",
89
- "build:lib": "swc src -d dist --strip-leading-paths && pnpm build:types",
91
+ "build:lib": "swc src -d dist --strip-leading-paths --ignore '**/__tests__/**' && pnpm build:types",
90
92
  "build:types": "tsc -p tsconfig.build.json",
91
93
  "typecheck": "tsc --noEmit && tsc -p tsconfig.client.json",
92
94
  "db:generate": "drizzle-kit generate",
93
95
  "i18n:extract": "lingui extract",
94
96
  "i18n:compile": "lingui compile --typescript",
95
- "i18n:build": "pnpm i18n:extract && pnpm i18n:compile"
97
+ "i18n:build": "pnpm i18n:extract && pnpm i18n:compile",
98
+ "test": "vitest run",
99
+ "test:watch": "vitest",
100
+ "test:coverage": "vitest run --coverage"
96
101
  }
97
102
  }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Test App Helper
3
+ *
4
+ * Creates a minimal Hono app with services wired up for route testing.
5
+ */
6
+
7
+ import { Hono } from "hono";
8
+ import type { Bindings } from "../../types.js";
9
+ import type { AppVariables } from "../../app.js";
10
+ import { createTestDatabase } from "./db.js";
11
+ import { createPostService } from "../../services/post.js";
12
+ import { createSettingsService } from "../../services/settings.js";
13
+ import { createRedirectService } from "../../services/redirect.js";
14
+ import { createMediaService } from "../../services/media.js";
15
+ import { createCollectionService } from "../../services/collection.js";
16
+ import { createSearchService } from "../../services/search.js";
17
+ import type { Database } from "../../db/index.js";
18
+ import type BetterSqlite3 from "better-sqlite3";
19
+
20
+ type Env = { Bindings: Bindings; Variables: AppVariables };
21
+
22
+ interface TestAppOptions {
23
+ /** If true, all requests are treated as authenticated */
24
+ authenticated?: boolean;
25
+ /** Enable FTS for search tests */
26
+ fts?: boolean;
27
+ }
28
+
29
+ /**
30
+ * Creates a test Hono app with real services backed by in-memory SQLite.
31
+ * Returns the app and service instances for assertions.
32
+ */
33
+ export function createTestApp(options: TestAppOptions = {}) {
34
+ const testDb = createTestDatabase({ fts: options.fts });
35
+ const db = testDb.db as unknown as Database;
36
+ const sqlite = testDb.sqlite;
37
+
38
+ // Create a mock D1 for search service
39
+ const mockD1 = createMockD1(sqlite);
40
+
41
+ const services = {
42
+ posts: createPostService(db),
43
+ settings: createSettingsService(db),
44
+ redirects: createRedirectService(db),
45
+ media: createMediaService(db),
46
+ collections: createCollectionService(db),
47
+ search: createSearchService(mockD1),
48
+ };
49
+
50
+ const app = new Hono<Env>();
51
+
52
+ // Inject services middleware
53
+ app.use("*", async (c, next) => {
54
+ c.set("services", services as AppVariables["services"]);
55
+ c.set("config", {});
56
+
57
+ if (options.authenticated) {
58
+ // Mock auth that always returns a session
59
+ c.set("auth", {
60
+ api: {
61
+ getSession: async () => ({
62
+ user: { id: "test-user", email: "test@test.com", name: "Test" },
63
+ session: { id: "test-session" },
64
+ }),
65
+ },
66
+ } as AppVariables["auth"]);
67
+ } else {
68
+ c.set("auth", {
69
+ api: {
70
+ getSession: async () => null,
71
+ },
72
+ } as AppVariables["auth"]);
73
+ }
74
+
75
+ await next();
76
+ });
77
+
78
+ return { app, services, db, sqlite };
79
+ }
80
+
81
+ function createMockD1(sqliteDb: BetterSqlite3.Database) {
82
+ return {
83
+ prepare(query: string) {
84
+ return {
85
+ bind(...params: unknown[]) {
86
+ return {
87
+ async all<T>() {
88
+ const stmt = sqliteDb.prepare(query);
89
+ const rows = stmt.all(...(params as never[])) as T[];
90
+ return { results: rows };
91
+ },
92
+ };
93
+ },
94
+ };
95
+ },
96
+ } as unknown as D1Database;
97
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Test Database Helper
3
+ *
4
+ * Creates an in-memory SQLite database with all migrations applied.
5
+ * Used for service integration tests.
6
+ */
7
+
8
+ import Database from "better-sqlite3";
9
+ import { drizzle } from "drizzle-orm/better-sqlite3";
10
+ import * as schema from "../../db/schema.js";
11
+ import { readFileSync } from "fs";
12
+ import { resolve } from "path";
13
+
14
+ const MIGRATIONS_DIR = resolve(import.meta.dirname, "../../db/migrations");
15
+
16
+ /**
17
+ * Creates a fresh in-memory SQLite database with all migrations applied.
18
+ * Each call returns an isolated database instance for test isolation.
19
+ *
20
+ * @param options.fts - Whether to apply FTS5 migration (default: false).
21
+ * The trigram tokenizer used in production may not be available in all
22
+ * better-sqlite3 builds, so FTS is opt-in for tests that need it.
23
+ */
24
+ export function createTestDatabase(options?: { fts?: boolean }) {
25
+ const sqlite = new Database(":memory:");
26
+
27
+ // Enable WAL mode for better performance
28
+ sqlite.pragma("journal_mode = WAL");
29
+ sqlite.pragma("foreign_keys = ON");
30
+
31
+ // Apply base schema migration
32
+ const migration0 = readFileSync(
33
+ resolve(MIGRATIONS_DIR, "0000_square_wallflower.sql"),
34
+ "utf-8",
35
+ );
36
+
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
+ }
42
+
43
+ // Optionally apply FTS5 migration (with fallback tokenizer)
44
+ if (options?.fts) {
45
+ try {
46
+ const migration1 = readFileSync(
47
+ resolve(MIGRATIONS_DIR, "0001_add_search_fts.sql"),
48
+ "utf-8",
49
+ );
50
+ sqlite.exec(migration1);
51
+ } 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
+ `);
79
+ }
80
+ }
81
+
82
+ const db = drizzle(sqlite, { schema });
83
+
84
+ return { db, sqlite };
85
+ }
package/src/app.tsx CHANGED
@@ -29,7 +29,6 @@ import { mediaRoutes as dashMediaRoutes } from "./routes/dash/media.js";
29
29
  import { settingsRoutes as dashSettingsRoutes } from "./routes/dash/settings.js";
30
30
  import { redirectsRoutes as dashRedirectsRoutes } from "./routes/dash/redirects.js";
31
31
  import { collectionsRoutes as dashCollectionsRoutes } from "./routes/dash/collections.js";
32
- import { appearanceRoutes as dashAppearanceRoutes } from "./routes/dash/appearance.js";
33
32
 
34
33
  // Routes - API
35
34
  import { postsApiRoutes } from "./routes/api/posts.js";
@@ -675,8 +674,6 @@ export function createApp(config: JantConfig = {}): App {
675
674
  app.route("/dash/settings", dashSettingsRoutes);
676
675
  app.route("/dash/redirects", dashRedirectsRoutes);
677
676
  app.route("/dash/collections", dashCollectionsRoutes);
678
- app.route("/dash/appearance", dashAppearanceRoutes);
679
-
680
677
  // API routes
681
678
  app.route("/api/upload", uploadApiRoutes);
682
679
  app.route("/api/search", searchApiRoutes);
@@ -0,0 +1,34 @@
1
+ -- FTS5 virtual table for full-text search (trigram tokenizer for CJK support)
2
+ CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
3
+ title,
4
+ content,
5
+ content='posts',
6
+ content_rowid='id',
7
+ tokenize='trigram'
8
+ );
9
+
10
+ -- Populate FTS with existing posts
11
+ INSERT INTO posts_fts(rowid, title, content)
12
+ SELECT id, COALESCE(title, ''), COALESCE(content, '')
13
+ FROM posts WHERE deleted_at IS NULL;
14
+
15
+ -- Trigger: sync FTS on INSERT
16
+ CREATE TRIGGER posts_fts_insert AFTER INSERT ON posts
17
+ WHEN NEW.deleted_at IS NULL
18
+ BEGIN
19
+ INSERT INTO posts_fts(rowid, title, content)
20
+ VALUES (NEW.id, COALESCE(NEW.title, ''), COALESCE(NEW.content, ''));
21
+ END;
22
+
23
+ -- Trigger: sync FTS on UPDATE
24
+ CREATE TRIGGER posts_fts_update AFTER UPDATE ON posts BEGIN
25
+ DELETE FROM posts_fts WHERE rowid = OLD.id;
26
+ INSERT INTO posts_fts(rowid, title, content)
27
+ SELECT NEW.id, COALESCE(NEW.title, ''), COALESCE(NEW.content, '')
28
+ WHERE NEW.deleted_at IS NULL;
29
+ END;
30
+
31
+ -- Trigger: sync FTS on DELETE
32
+ CREATE TRIGGER posts_fts_delete AFTER DELETE ON posts BEGIN
33
+ DELETE FROM posts_fts WHERE rowid = OLD.id;
34
+ END;
@@ -8,6 +8,13 @@
8
8
  "when": 1770564499811,
9
9
  "tag": "0000_square_wallflower",
10
10
  "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "6",
15
+ "when": 1770564499812,
16
+ "tag": "0001_add_search_fts",
17
+ "breakpoints": true
11
18
  }
12
19
  ]
13
20
  }
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { RESERVED_PATHS, isReservedPath } from "../constants.js";
3
+
4
+ describe("RESERVED_PATHS", () => {
5
+ it("contains expected critical paths", () => {
6
+ expect(RESERVED_PATHS).toContain("dash");
7
+ expect(RESERVED_PATHS).toContain("api");
8
+ expect(RESERVED_PATHS).toContain("feed");
9
+ expect(RESERVED_PATHS).toContain("signin");
10
+ expect(RESERVED_PATHS).toContain("search");
11
+ expect(RESERVED_PATHS).toContain("p");
12
+ expect(RESERVED_PATHS).toContain("c");
13
+ });
14
+ });
15
+
16
+ describe("isReservedPath", () => {
17
+ it("returns true for reserved paths", () => {
18
+ expect(isReservedPath("dash")).toBe(true);
19
+ expect(isReservedPath("api")).toBe(true);
20
+ expect(isReservedPath("feed")).toBe(true);
21
+ expect(isReservedPath("signin")).toBe(true);
22
+ });
23
+
24
+ it("checks only the first segment", () => {
25
+ expect(isReservedPath("dash/settings")).toBe(true);
26
+ expect(isReservedPath("api/posts")).toBe(true);
27
+ });
28
+
29
+ it("is case-insensitive", () => {
30
+ expect(isReservedPath("DASH")).toBe(true);
31
+ expect(isReservedPath("Api")).toBe(true);
32
+ expect(isReservedPath("FEED")).toBe(true);
33
+ });
34
+
35
+ it("returns false for non-reserved paths", () => {
36
+ expect(isReservedPath("about")).toBe(false);
37
+ expect(isReservedPath("contact")).toBe(false);
38
+ expect(isReservedPath("my-custom-page")).toBe(false);
39
+ });
40
+
41
+ it("returns false for empty string", () => {
42
+ expect(isReservedPath("")).toBe(false);
43
+ });
44
+ });
@@ -0,0 +1,133 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, toPlainText, extractTitle } from "../markdown.js";
3
+
4
+ describe("render", () => {
5
+ it("renders a heading", () => {
6
+ const html = render("# Hello");
7
+ expect(html).toContain("<h1>");
8
+ expect(html).toContain("Hello");
9
+ });
10
+
11
+ it("renders bold text", () => {
12
+ const html = render("This is **bold** text.");
13
+ expect(html).toContain("<strong>bold</strong>");
14
+ });
15
+
16
+ it("renders italic text", () => {
17
+ const html = render("This is *italic* text.");
18
+ expect(html).toContain("<em>italic</em>");
19
+ });
20
+
21
+ it("renders links", () => {
22
+ const html = render("[link](https://example.com)");
23
+ expect(html).toContain('href="https://example.com"');
24
+ expect(html).toContain(">link</a>");
25
+ });
26
+
27
+ it("renders code blocks", () => {
28
+ const html = render("```\nconst x = 1;\n```");
29
+ expect(html).toContain("<code>");
30
+ });
31
+
32
+ it("renders inline code", () => {
33
+ const html = render("Use `console.log()` here.");
34
+ expect(html).toContain("<code>console.log()</code>");
35
+ });
36
+
37
+ it("supports GFM line breaks", () => {
38
+ const html = render("Line 1\nLine 2");
39
+ expect(html).toContain("<br>");
40
+ });
41
+
42
+ it("returns a string", () => {
43
+ expect(typeof render("test")).toBe("string");
44
+ });
45
+
46
+ it("handles empty string", () => {
47
+ expect(render("")).toBe("");
48
+ });
49
+ });
50
+
51
+ describe("toPlainText", () => {
52
+ it("removes headers", () => {
53
+ expect(toPlainText("## Hello")).toBe("Hello");
54
+ });
55
+
56
+ it("removes bold syntax", () => {
57
+ expect(toPlainText("This is **bold** text")).toBe("This is bold text");
58
+ });
59
+
60
+ it("removes italic syntax", () => {
61
+ expect(toPlainText("This is *italic* text")).toBe("This is italic text");
62
+ });
63
+
64
+ it("extracts link text, removes URLs", () => {
65
+ expect(toPlainText("[a link](https://example.com)")).toBe("a link");
66
+ });
67
+
68
+ it("removes images (note: link regex runs first, leaving ! prefix)", () => {
69
+ // Known behavior: the link regex \[(.+?)\]\(.+?\) captures [alt](url) before
70
+ // the image regex !\[.*?\]\(.+?\) can match, leaving the "!" prefix
71
+ expect(toPlainText("![alt](image.png)")).toBe("!alt");
72
+ });
73
+
74
+ it("removes blockquotes", () => {
75
+ expect(toPlainText("> quoted text")).toBe("quoted text");
76
+ });
77
+
78
+ it("replaces newlines with spaces", () => {
79
+ const result = toPlainText("Line 1\nLine 2\nLine 3");
80
+ expect(result).toBe("Line 1 Line 2 Line 3");
81
+ });
82
+
83
+ it("handles complex markdown", () => {
84
+ const md = "## Hello\n\nThis is **bold** and [a link](url).";
85
+ const result = toPlainText(md);
86
+ expect(result).toBe("Hello This is bold and a link.");
87
+ });
88
+
89
+ it("handles empty string", () => {
90
+ expect(toPlainText("")).toBe("");
91
+ });
92
+ });
93
+
94
+ describe("extractTitle", () => {
95
+ it("extracts first sentence", () => {
96
+ expect(extractTitle("This is the first sentence. And another one.")).toBe(
97
+ "This is the first sentence",
98
+ );
99
+ });
100
+
101
+ it("extracts text before exclamation mark", () => {
102
+ expect(extractTitle("Hello world! More text here.")).toBe("Hello world");
103
+ });
104
+
105
+ it("extracts text before question mark", () => {
106
+ expect(extractTitle("What is this? Some answer.")).toBe("What is this");
107
+ });
108
+
109
+ it("truncates long text with ellipsis", () => {
110
+ const long = "A".repeat(200);
111
+ const result = extractTitle(long, 50);
112
+ expect(result.length).toBe(53); // 50 + "..."
113
+ expect(result.endsWith("...")).toBe(true);
114
+ });
115
+
116
+ it("returns full first sentence if under maxLength", () => {
117
+ expect(extractTitle("Short sentence.", 120)).toBe("Short sentence");
118
+ });
119
+
120
+ it("uses default maxLength of 120", () => {
121
+ const long = "A".repeat(200) + ".";
122
+ const result = extractTitle(long);
123
+ expect(result.length).toBe(123); // 120 + "..."
124
+ });
125
+
126
+ it("strips markdown before extracting", () => {
127
+ expect(extractTitle("## Hello world. More text.")).toBe("Hello world");
128
+ });
129
+
130
+ it("handles empty string", () => {
131
+ expect(extractTitle("")).toBe("");
132
+ });
133
+ });