@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
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { createTestDatabase } from "../../__tests__/helpers/db.js";
3
+ import { createRedirectService } from "../redirect.js";
4
+ import type { Database } from "../../db/index.js";
5
+
6
+ describe("RedirectService", () => {
7
+ let db: Database;
8
+ let redirectService: ReturnType<typeof createRedirectService>;
9
+
10
+ beforeEach(() => {
11
+ const testDb = createTestDatabase();
12
+ db = testDb.db as unknown as Database;
13
+ redirectService = createRedirectService(db);
14
+ });
15
+
16
+ describe("create", () => {
17
+ it("creates a 301 redirect by default", async () => {
18
+ const redirect = await redirectService.create("/old-path", "/new-path");
19
+
20
+ expect(redirect.fromPath).toBe("old-path"); // normalizePath removes leading slash
21
+ expect(redirect.toPath).toBe("/new-path");
22
+ expect(redirect.type).toBe(301);
23
+ expect(redirect.id).toBe(1);
24
+ });
25
+
26
+ it("creates a 302 redirect", async () => {
27
+ const redirect = await redirectService.create(
28
+ "/temp",
29
+ "/destination",
30
+ 302,
31
+ );
32
+
33
+ expect(redirect.type).toBe(302);
34
+ });
35
+
36
+ it("normalizes from path", async () => {
37
+ const redirect = await redirectService.create(
38
+ " /OLD-PATH/ ",
39
+ "/new-path",
40
+ );
41
+
42
+ expect(redirect.fromPath).toBe("old-path");
43
+ });
44
+
45
+ it("replaces existing redirect for same from path", async () => {
46
+ await redirectService.create("/old", "/first");
47
+ const second = await redirectService.create("/old", "/second");
48
+
49
+ expect(second.toPath).toBe("/second");
50
+
51
+ const list = await redirectService.list();
52
+ expect(list).toHaveLength(1);
53
+ });
54
+ });
55
+
56
+ describe("getByPath", () => {
57
+ it("finds redirect by from path", async () => {
58
+ await redirectService.create("/old-page", "/new-page");
59
+
60
+ const found = await redirectService.getByPath("/old-page");
61
+ expect(found).not.toBeNull();
62
+ expect(found?.toPath).toBe("/new-page");
63
+ });
64
+
65
+ it("normalizes the lookup path", async () => {
66
+ await redirectService.create("/old-page", "/new-page");
67
+
68
+ const found = await redirectService.getByPath(" /OLD-PAGE/ ");
69
+ expect(found).not.toBeNull();
70
+ });
71
+
72
+ it("returns null for non-existent path", async () => {
73
+ const found = await redirectService.getByPath("/nonexistent");
74
+ expect(found).toBeNull();
75
+ });
76
+ });
77
+
78
+ describe("delete", () => {
79
+ it("deletes a redirect by ID", async () => {
80
+ const redirect = await redirectService.create("/old", "/new");
81
+ const result = await redirectService.delete(redirect.id);
82
+
83
+ expect(result).toBe(true);
84
+
85
+ const found = await redirectService.getByPath("/old");
86
+ expect(found).toBeNull();
87
+ });
88
+
89
+ it("returns false for non-existent ID", async () => {
90
+ const result = await redirectService.delete(9999);
91
+ expect(result).toBe(false);
92
+ });
93
+ });
94
+
95
+ describe("list", () => {
96
+ it("returns empty array when no redirects exist", async () => {
97
+ const redirects = await redirectService.list();
98
+ expect(redirects).toEqual([]);
99
+ });
100
+
101
+ it("returns all redirects", async () => {
102
+ await redirectService.create("/a", "/b");
103
+ await redirectService.create("/c", "/d");
104
+ await redirectService.create("/e", "/f");
105
+
106
+ const redirects = await redirectService.list();
107
+ expect(redirects).toHaveLength(3);
108
+ });
109
+ });
110
+ });
@@ -0,0 +1,143 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { createTestDatabase } from "../../__tests__/helpers/db.js";
3
+ import { createSearchService } from "../search.js";
4
+ import { createPostService } from "../post.js";
5
+ import type { Database } from "../../db/index.js";
6
+ import type BetterSqlite3 from "better-sqlite3";
7
+
8
+ describe("SearchService", () => {
9
+ let db: Database;
10
+ let sqlite: BetterSqlite3.Database;
11
+ let postService: ReturnType<typeof createPostService>;
12
+
13
+ // Create a mock D1Database interface wrapping better-sqlite3
14
+ function createMockD1(sqliteDb: BetterSqlite3.Database) {
15
+ return {
16
+ prepare(query: string) {
17
+ return {
18
+ bind(...params: unknown[]) {
19
+ return {
20
+ async all<T>() {
21
+ const stmt = sqliteDb.prepare(query);
22
+ const rows = stmt.all(...(params as never[])) as T[];
23
+ return { results: rows };
24
+ },
25
+ };
26
+ },
27
+ };
28
+ },
29
+ } as unknown as D1Database;
30
+ }
31
+
32
+ beforeEach(() => {
33
+ const testDb = createTestDatabase({ fts: true });
34
+ db = testDb.db as unknown as Database;
35
+ sqlite = testDb.sqlite;
36
+ postService = createPostService(db);
37
+ });
38
+
39
+ it("returns empty results for empty query", async () => {
40
+ const d1 = createMockD1(sqlite);
41
+ const searchService = createSearchService(d1);
42
+
43
+ const results = await searchService.search("");
44
+ expect(results).toEqual([]);
45
+ });
46
+
47
+ it("returns empty results for whitespace-only query", async () => {
48
+ const d1 = createMockD1(sqlite);
49
+ const searchService = createSearchService(d1);
50
+
51
+ const results = await searchService.search(" ");
52
+ expect(results).toEqual([]);
53
+ });
54
+
55
+ it("finds posts by content", async () => {
56
+ await postService.create({
57
+ type: "note",
58
+ content: "Hello world from jant",
59
+ visibility: "featured",
60
+ });
61
+ await postService.create({
62
+ type: "note",
63
+ content: "Another post entirely",
64
+ visibility: "featured",
65
+ });
66
+
67
+ const d1 = createMockD1(sqlite);
68
+ const searchService = createSearchService(d1);
69
+
70
+ const results = await searchService.search("jant");
71
+ expect(results.length).toBeGreaterThanOrEqual(1);
72
+ expect(results[0]?.post.content).toContain("jant");
73
+ });
74
+
75
+ it("finds posts by title", async () => {
76
+ await postService.create({
77
+ type: "article",
78
+ title: "Introduction to TypeScript",
79
+ content: "Some article body",
80
+ visibility: "quiet",
81
+ });
82
+
83
+ const d1 = createMockD1(sqlite);
84
+ const searchService = createSearchService(d1);
85
+
86
+ const results = await searchService.search("TypeScript");
87
+ expect(results.length).toBeGreaterThanOrEqual(1);
88
+ expect(results[0]?.post.title).toContain("TypeScript");
89
+ });
90
+
91
+ it("respects visibility filter", async () => {
92
+ await postService.create({
93
+ type: "note",
94
+ content: "visible post about testing",
95
+ visibility: "featured",
96
+ });
97
+ await postService.create({
98
+ type: "note",
99
+ content: "draft post about testing",
100
+ visibility: "draft",
101
+ });
102
+
103
+ const d1 = createMockD1(sqlite);
104
+ const searchService = createSearchService(d1);
105
+
106
+ const results = await searchService.search("testing", {
107
+ visibility: ["featured"],
108
+ });
109
+
110
+ expect(results.every((r) => r.post.visibility === "featured")).toBe(true);
111
+ });
112
+
113
+ it("excludes deleted posts", async () => {
114
+ const post = await postService.create({
115
+ type: "note",
116
+ content: "deleted post with unique search term xyzzy",
117
+ visibility: "featured",
118
+ });
119
+ await postService.delete(post.id);
120
+
121
+ const d1 = createMockD1(sqlite);
122
+ const searchService = createSearchService(d1);
123
+
124
+ const results = await searchService.search("xyzzy");
125
+ expect(results).toHaveLength(0);
126
+ });
127
+
128
+ it("supports limit and offset", async () => {
129
+ for (let i = 0; i < 5; i++) {
130
+ await postService.create({
131
+ type: "note",
132
+ content: `searchable post number ${i}`,
133
+ visibility: "featured",
134
+ });
135
+ }
136
+
137
+ const d1 = createMockD1(sqlite);
138
+ const searchService = createSearchService(d1);
139
+
140
+ const limited = await searchService.search("searchable", { limit: 2 });
141
+ expect(limited.length).toBeLessThanOrEqual(2);
142
+ });
143
+ });
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { createTestDatabase } from "../../__tests__/helpers/db.js";
3
+ import { createSettingsService } from "../settings.js";
4
+ import type { Database } from "../../db/index.js";
5
+
6
+ describe("SettingsService", () => {
7
+ let db: Database;
8
+ let settingsService: ReturnType<typeof createSettingsService>;
9
+
10
+ beforeEach(() => {
11
+ const testDb = createTestDatabase();
12
+ db = testDb.db as unknown as Database;
13
+ settingsService = createSettingsService(db);
14
+ });
15
+
16
+ describe("get", () => {
17
+ it("returns null for non-existent key", async () => {
18
+ const result = await settingsService.get("SITE_NAME");
19
+ expect(result).toBeNull();
20
+ });
21
+
22
+ it("returns value after set", async () => {
23
+ await settingsService.set("SITE_NAME", "My Blog");
24
+ const result = await settingsService.get("SITE_NAME");
25
+ expect(result).toBe("My Blog");
26
+ });
27
+ });
28
+
29
+ describe("set", () => {
30
+ it("creates a new setting", async () => {
31
+ await settingsService.set("SITE_NAME", "Test Site");
32
+ const result = await settingsService.get("SITE_NAME");
33
+ expect(result).toBe("Test Site");
34
+ });
35
+
36
+ it("updates existing setting (upsert)", async () => {
37
+ await settingsService.set("SITE_NAME", "Original");
38
+ await settingsService.set("SITE_NAME", "Updated");
39
+ const result = await settingsService.get("SITE_NAME");
40
+ expect(result).toBe("Updated");
41
+ });
42
+ });
43
+
44
+ describe("getAll", () => {
45
+ it("returns empty object when no settings exist", async () => {
46
+ const result = await settingsService.getAll();
47
+ expect(result).toEqual({});
48
+ });
49
+
50
+ it("returns all settings as key-value pairs", async () => {
51
+ await settingsService.set("SITE_NAME", "My Blog");
52
+ await settingsService.set("SITE_DESCRIPTION", "A cool blog");
53
+
54
+ const result = await settingsService.getAll();
55
+ expect(result).toEqual({
56
+ SITE_NAME: "My Blog",
57
+ SITE_DESCRIPTION: "A cool blog",
58
+ });
59
+ });
60
+ });
61
+
62
+ describe("setMany", () => {
63
+ it("sets multiple values at once", async () => {
64
+ await settingsService.setMany({
65
+ SITE_NAME: "My Blog",
66
+ SITE_DESCRIPTION: "Description",
67
+ });
68
+
69
+ expect(await settingsService.get("SITE_NAME")).toBe("My Blog");
70
+ expect(await settingsService.get("SITE_DESCRIPTION")).toBe("Description");
71
+ });
72
+
73
+ it("skips undefined values", async () => {
74
+ await settingsService.set("SITE_NAME", "Original");
75
+ await settingsService.setMany({
76
+ SITE_NAME: undefined,
77
+ SITE_DESCRIPTION: "New",
78
+ });
79
+
80
+ expect(await settingsService.get("SITE_NAME")).toBe("Original");
81
+ expect(await settingsService.get("SITE_DESCRIPTION")).toBe("New");
82
+ });
83
+ });
84
+
85
+ describe("remove", () => {
86
+ it("removes a setting", async () => {
87
+ await settingsService.set("SITE_NAME", "Test");
88
+ await settingsService.remove("SITE_NAME");
89
+ const result = await settingsService.get("SITE_NAME");
90
+ expect(result).toBeNull();
91
+ });
92
+
93
+ it("does not throw when removing non-existent key", async () => {
94
+ await expect(settingsService.remove("SITE_NAME")).resolves.not.toThrow();
95
+ });
96
+ });
97
+
98
+ describe("onboarding", () => {
99
+ it("returns false when onboarding is not complete", async () => {
100
+ const result = await settingsService.isOnboardingComplete();
101
+ expect(result).toBe(false);
102
+ });
103
+
104
+ it("returns true after completing onboarding", async () => {
105
+ await settingsService.completeOnboarding();
106
+ const result = await settingsService.isOnboardingComplete();
107
+ expect(result).toBe(true);
108
+ });
109
+ });
110
+ });
@@ -135,15 +135,6 @@ function DashLayoutContent({
135
135
  comment: "@context: Dashboard navigation - site settings",
136
136
  })}
137
137
  </a>
138
- <a
139
- href="/dash/appearance"
140
- class={navClass("/dash/appearance", /^\/dash\/appearance/)}
141
- >
142
- {t({
143
- message: "Appearance",
144
- comment: "@context: Dashboard navigation - appearance settings",
145
- })}
146
- </a>
147
138
  </nav>
148
139
  </aside>
149
140
 
@@ -1,13 +0,0 @@
1
- /**
2
- * Dashboard Appearance Routes
3
- */
4
- import { Hono } from "hono";
5
- import type { Bindings } from "../../types.js";
6
- import type { AppVariables } from "../../app.js";
7
- type Env = {
8
- Bindings: Bindings;
9
- Variables: AppVariables;
10
- };
11
- export declare const appearanceRoutes: Hono<Env, import("hono/types").BlankSchema, "/">;
12
- export {};
13
- //# sourceMappingURL=appearance.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"appearance.d.ts","sourceRoot":"","sources":["../../../src/routes/dash/appearance.tsx"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE5B,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC/C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAQjD,KAAK,GAAG,GAAG;IAAE,QAAQ,EAAE,QAAQ,CAAC;IAAC,SAAS,EAAE,YAAY,CAAA;CAAE,CAAC;AAE3D,eAAO,MAAM,gBAAgB,kDAAkB,CAAC"}
@@ -1,160 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "hono/jsx/jsx-runtime";
2
- /**
3
- * Dashboard Appearance Routes
4
- */ import { Hono } from "hono";
5
- import { useLingui as $_useLingui } from "@jant/core/i18n";
6
- import { DashLayout } from "../../theme/layouts/index.js";
7
- import { dsRedirect, dsToast } from "../../lib/sse.js";
8
- import { getSiteName } from "../../lib/config.js";
9
- import { SETTINGS_KEYS } from "../../lib/constants.js";
10
- import { getAvailableThemes } from "../../lib/theme.js";
11
- export const appearanceRoutes = new Hono();
12
- function ThemeCard({ theme, selected }) {
13
- const expr = `$theme === '${theme.id}'`;
14
- const { preview } = theme;
15
- return /*#__PURE__*/ _jsx("label", {
16
- class: `block cursor-pointer rounded-lg border overflow-hidden transition-colors ${selected ? "border-primary" : "border-border"}`,
17
- "data-class:border-primary": expr,
18
- "data-class:border-border": `$theme !== '${theme.id}'`,
19
- children: /*#__PURE__*/ _jsxs("div", {
20
- class: "grid grid-cols-2",
21
- children: [
22
- /*#__PURE__*/ _jsxs("div", {
23
- class: "p-5",
24
- style: `background-color:${preview.lightBg};color:${preview.lightText}`,
25
- children: [
26
- /*#__PURE__*/ _jsx("input", {
27
- type: "radio",
28
- name: "theme",
29
- value: theme.id,
30
- "data-bind": "theme",
31
- checked: selected || undefined,
32
- class: "mb-1"
33
- }),
34
- /*#__PURE__*/ _jsx("h3", {
35
- class: "font-bold text-lg",
36
- children: theme.name
37
- }),
38
- /*#__PURE__*/ _jsxs("p", {
39
- class: "text-sm mt-2 leading-relaxed",
40
- children: [
41
- "This is the ",
42
- theme.name,
43
- " theme in light mode. Links",
44
- " ",
45
- /*#__PURE__*/ _jsx("a", {
46
- tabIndex: -1,
47
- class: "underline",
48
- style: `color:${preview.lightLink}`,
49
- children: "look like this"
50
- }),
51
- ". We'll show the correct light or dark mode based on your visitor's settings."
52
- ]
53
- })
54
- ]
55
- }),
56
- /*#__PURE__*/ _jsxs("div", {
57
- class: "p-5",
58
- style: `background-color:${preview.darkBg};color:${preview.darkText}`,
59
- children: [
60
- /*#__PURE__*/ _jsx("h3", {
61
- class: "font-bold text-lg",
62
- children: theme.name
63
- }),
64
- /*#__PURE__*/ _jsxs("p", {
65
- class: "text-sm mt-2 leading-relaxed",
66
- children: [
67
- "This is the ",
68
- theme.name,
69
- " theme in dark mode. Links",
70
- " ",
71
- /*#__PURE__*/ _jsx("a", {
72
- tabIndex: -1,
73
- class: "underline",
74
- style: `color:${preview.darkLink}`,
75
- children: "look like this"
76
- }),
77
- ". We'll show the correct light or dark mode based on your visitor's settings."
78
- ]
79
- })
80
- ]
81
- })
82
- ]
83
- })
84
- });
85
- }
86
- function AppearanceContent({ themes, currentThemeId }) {
87
- const { i18n: $__i18n, _: $__ } = $_useLingui();
88
- const signals = JSON.stringify({
89
- theme: currentThemeId
90
- }).replace(/</g, "\\u003c");
91
- return /*#__PURE__*/ _jsx("div", {
92
- "data-signals": signals,
93
- "data-on:change": "@post('/dash/appearance')",
94
- class: "max-w-3xl",
95
- children: /*#__PURE__*/ _jsxs("fieldset", {
96
- children: [
97
- /*#__PURE__*/ _jsx("legend", {
98
- class: "text-lg font-semibold",
99
- children: $__i18n._({
100
- id: "rFmBG3",
101
- message: "Color theme"
102
- })
103
- }),
104
- /*#__PURE__*/ _jsx("p", {
105
- class: "text-sm text-muted-foreground mb-4",
106
- children: $__i18n._({
107
- id: "07Epll",
108
- message: "This will theme both your site and your dashboard. All color themes support dark mode."
109
- })
110
- }),
111
- /*#__PURE__*/ _jsx("div", {
112
- class: "flex flex-col gap-4",
113
- children: themes.map((theme)=>/*#__PURE__*/ _jsx(ThemeCard, {
114
- theme: theme,
115
- selected: theme.id === currentThemeId
116
- }, theme.id))
117
- })
118
- ]
119
- })
120
- });
121
- }
122
- // Appearance page
123
- appearanceRoutes.get("/", async (c)=>{
124
- const { settings } = c.var.services;
125
- const siteName = await getSiteName(c);
126
- const currentThemeId = await settings.get(SETTINGS_KEYS.THEME) ?? "default";
127
- const themes = getAvailableThemes(c.var.config);
128
- const saved = c.req.query("saved") !== undefined;
129
- return c.html(/*#__PURE__*/ _jsx(DashLayout, {
130
- c: c,
131
- title: "Appearance",
132
- siteName: siteName,
133
- currentPath: "/dash/appearance",
134
- toast: saved ? {
135
- message: "Theme saved successfully."
136
- } : undefined,
137
- children: /*#__PURE__*/ _jsx(AppearanceContent, {
138
- themes: themes,
139
- currentThemeId: currentThemeId
140
- })
141
- }));
142
- });
143
- // Save theme
144
- appearanceRoutes.post("/", async (c)=>{
145
- const body = await c.req.json();
146
- const { settings } = c.var.services;
147
- const themes = getAvailableThemes(c.var.config);
148
- // Validate theme ID
149
- const validTheme = themes.find((t)=>t.id === body.theme);
150
- if (!validTheme) {
151
- return dsToast("Invalid theme selected.", "error");
152
- }
153
- if (validTheme.id === "default") {
154
- await settings.remove(SETTINGS_KEYS.THEME);
155
- } else {
156
- await settings.set(SETTINGS_KEYS.THEME, validTheme.id);
157
- }
158
- // Full page reload to apply the new theme CSS
159
- return dsRedirect("/dash/appearance?saved");
160
- });