@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,220 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ PostTypeSchema,
4
+ VisibilitySchema,
5
+ RedirectTypeSchema,
6
+ CreatePostSchema,
7
+ UpdatePostSchema,
8
+ parseFormData,
9
+ parseFormDataOptional,
10
+ } from "../schemas.js";
11
+ import { z } from "zod";
12
+ import { POST_TYPES, VISIBILITY_LEVELS } from "../../types.js";
13
+
14
+ describe("PostTypeSchema", () => {
15
+ it("accepts all valid post types", () => {
16
+ for (const type of POST_TYPES) {
17
+ expect(PostTypeSchema.parse(type)).toBe(type);
18
+ }
19
+ });
20
+
21
+ it("rejects invalid post types", () => {
22
+ expect(() => PostTypeSchema.parse("invalid")).toThrow();
23
+ expect(() => PostTypeSchema.parse("")).toThrow();
24
+ expect(() => PostTypeSchema.parse(123)).toThrow();
25
+ });
26
+ });
27
+
28
+ describe("VisibilitySchema", () => {
29
+ it("accepts all valid visibility levels", () => {
30
+ for (const level of VISIBILITY_LEVELS) {
31
+ expect(VisibilitySchema.parse(level)).toBe(level);
32
+ }
33
+ });
34
+
35
+ it("rejects invalid visibility levels", () => {
36
+ expect(() => VisibilitySchema.parse("public")).toThrow();
37
+ expect(() => VisibilitySchema.parse("private")).toThrow();
38
+ });
39
+ });
40
+
41
+ describe("RedirectTypeSchema", () => {
42
+ it("accepts 301 and 302 as strings", () => {
43
+ expect(RedirectTypeSchema.parse("301")).toBe("301");
44
+ expect(RedirectTypeSchema.parse("302")).toBe("302");
45
+ });
46
+
47
+ it("rejects other values", () => {
48
+ expect(() => RedirectTypeSchema.parse("200")).toThrow();
49
+ expect(() => RedirectTypeSchema.parse("404")).toThrow();
50
+ expect(() => RedirectTypeSchema.parse(301)).toThrow();
51
+ });
52
+ });
53
+
54
+ describe("CreatePostSchema", () => {
55
+ const validPost = {
56
+ type: "note",
57
+ content: "Hello world",
58
+ visibility: "quiet",
59
+ };
60
+
61
+ it("accepts a valid post with required fields", () => {
62
+ const result = CreatePostSchema.parse(validPost);
63
+ expect(result.type).toBe("note");
64
+ expect(result.content).toBe("Hello world");
65
+ expect(result.visibility).toBe("quiet");
66
+ });
67
+
68
+ it("accepts all post types", () => {
69
+ for (const type of POST_TYPES) {
70
+ expect(() =>
71
+ CreatePostSchema.parse({ ...validPost, type }),
72
+ ).not.toThrow();
73
+ }
74
+ });
75
+
76
+ it("accepts optional title", () => {
77
+ const result = CreatePostSchema.parse({
78
+ ...validPost,
79
+ title: "My Post",
80
+ });
81
+ expect(result.title).toBe("My Post");
82
+ });
83
+
84
+ it("accepts valid path format", () => {
85
+ const result = CreatePostSchema.parse({
86
+ ...validPost,
87
+ path: "my-post-slug",
88
+ });
89
+ expect(result.path).toBe("my-post-slug");
90
+ });
91
+
92
+ it("accepts empty path", () => {
93
+ const result = CreatePostSchema.parse({ ...validPost, path: "" });
94
+ expect(result.path).toBe("");
95
+ });
96
+
97
+ it("rejects invalid path format (uppercase)", () => {
98
+ expect(() =>
99
+ CreatePostSchema.parse({ ...validPost, path: "MyPost" }),
100
+ ).toThrow();
101
+ });
102
+
103
+ it("rejects invalid path format (special chars)", () => {
104
+ expect(() =>
105
+ CreatePostSchema.parse({ ...validPost, path: "my post!" }),
106
+ ).toThrow();
107
+ });
108
+
109
+ it("accepts valid source URL", () => {
110
+ const result = CreatePostSchema.parse({
111
+ ...validPost,
112
+ sourceUrl: "https://example.com",
113
+ });
114
+ expect(result.sourceUrl).toBe("https://example.com");
115
+ });
116
+
117
+ it("accepts empty source URL", () => {
118
+ const result = CreatePostSchema.parse({ ...validPost, sourceUrl: "" });
119
+ expect(result.sourceUrl).toBe("");
120
+ });
121
+
122
+ it("rejects invalid source URL", () => {
123
+ expect(() =>
124
+ CreatePostSchema.parse({ ...validPost, sourceUrl: "not-a-url" }),
125
+ ).toThrow();
126
+ });
127
+
128
+ it("accepts optional publishedAt as positive integer", () => {
129
+ const result = CreatePostSchema.parse({
130
+ ...validPost,
131
+ publishedAt: 1706745600,
132
+ });
133
+ expect(result.publishedAt).toBe(1706745600);
134
+ });
135
+
136
+ it("rejects negative publishedAt", () => {
137
+ expect(() =>
138
+ CreatePostSchema.parse({ ...validPost, publishedAt: -1 }),
139
+ ).toThrow();
140
+ });
141
+
142
+ it("rejects non-integer publishedAt", () => {
143
+ expect(() =>
144
+ CreatePostSchema.parse({ ...validPost, publishedAt: 1.5 }),
145
+ ).toThrow();
146
+ });
147
+
148
+ it("rejects missing required fields", () => {
149
+ expect(() => CreatePostSchema.parse({})).toThrow();
150
+ expect(() => CreatePostSchema.parse({ type: "note" })).toThrow();
151
+ expect(() => CreatePostSchema.parse({ content: "hello" })).toThrow();
152
+ });
153
+ });
154
+
155
+ describe("UpdatePostSchema", () => {
156
+ it("accepts empty object (all fields optional)", () => {
157
+ const result = UpdatePostSchema.parse({});
158
+ expect(result).toEqual({});
159
+ });
160
+
161
+ it("accepts partial updates", () => {
162
+ const result = UpdatePostSchema.parse({ title: "New Title" });
163
+ expect(result.title).toBe("New Title");
164
+ });
165
+
166
+ it("accepts only type", () => {
167
+ const result = UpdatePostSchema.parse({ type: "article" });
168
+ expect(result.type).toBe("article");
169
+ });
170
+
171
+ it("still validates field types", () => {
172
+ expect(() => UpdatePostSchema.parse({ type: "invalid" })).toThrow();
173
+ });
174
+ });
175
+
176
+ describe("parseFormData", () => {
177
+ it("parses a valid form field", () => {
178
+ const form = new FormData();
179
+ form.set("name", "hello");
180
+ expect(parseFormData(form, "name", z.string())).toBe("hello");
181
+ });
182
+
183
+ it("throws for missing required field", () => {
184
+ const form = new FormData();
185
+ expect(() => parseFormData(form, "missing", z.string())).toThrow(
186
+ "Missing required field: missing",
187
+ );
188
+ });
189
+
190
+ it("throws for invalid value", () => {
191
+ const form = new FormData();
192
+ form.set("type", "invalid-type");
193
+ expect(() => parseFormData(form, "type", PostTypeSchema)).toThrow();
194
+ });
195
+ });
196
+
197
+ describe("parseFormDataOptional", () => {
198
+ it("returns parsed value when present", () => {
199
+ const form = new FormData();
200
+ form.set("name", "hello");
201
+ expect(parseFormDataOptional(form, "name", z.string())).toBe("hello");
202
+ });
203
+
204
+ it("returns undefined when field is missing", () => {
205
+ const form = new FormData();
206
+ expect(parseFormDataOptional(form, "missing", z.string())).toBeUndefined();
207
+ });
208
+
209
+ it("returns undefined when field is empty string", () => {
210
+ const form = new FormData();
211
+ form.set("name", "");
212
+ expect(parseFormDataOptional(form, "name", z.string())).toBeUndefined();
213
+ });
214
+
215
+ it("throws for invalid value", () => {
216
+ const form = new FormData();
217
+ form.set("type", "invalid");
218
+ expect(() => parseFormDataOptional(form, "type", PostTypeSchema)).toThrow();
219
+ });
220
+ });
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { encode, decode, isValidSqid } from "../sqid.js";
3
+
4
+ describe("encode", () => {
5
+ it("encodes a numeric ID to a string", () => {
6
+ const result = encode(1);
7
+ expect(typeof result).toBe("string");
8
+ expect(result.length).toBeGreaterThanOrEqual(4);
9
+ });
10
+
11
+ it("produces minimum 4-character strings", () => {
12
+ expect(encode(0).length).toBeGreaterThanOrEqual(4);
13
+ expect(encode(1).length).toBeGreaterThanOrEqual(4);
14
+ expect(encode(100).length).toBeGreaterThanOrEqual(4);
15
+ });
16
+
17
+ it("produces different strings for different IDs", () => {
18
+ const a = encode(1);
19
+ const b = encode(2);
20
+ const c = encode(100);
21
+ expect(a).not.toBe(b);
22
+ expect(b).not.toBe(c);
23
+ });
24
+
25
+ it("produces consistent results for the same ID", () => {
26
+ expect(encode(42)).toBe(encode(42));
27
+ });
28
+ });
29
+
30
+ describe("decode", () => {
31
+ it("decodes an encoded string back to the original ID", () => {
32
+ for (const id of [0, 1, 42, 100, 999, 10000]) {
33
+ const encoded = encode(id);
34
+ expect(decode(encoded)).toBe(id);
35
+ }
36
+ });
37
+
38
+ it("returns null for empty string", () => {
39
+ expect(decode("")).toBe(null);
40
+ });
41
+
42
+ it("handles round-trip encoding", () => {
43
+ const original = 12345;
44
+ const sqid = encode(original);
45
+ const decoded = decode(sqid);
46
+ expect(decoded).toBe(original);
47
+ });
48
+ });
49
+
50
+ describe("isValidSqid", () => {
51
+ it("returns true for valid encoded sqids", () => {
52
+ const sqid = encode(1);
53
+ expect(isValidSqid(sqid)).toBe(true);
54
+ });
55
+
56
+ it("returns true for various valid sqids", () => {
57
+ for (const id of [0, 1, 100, 999]) {
58
+ expect(isValidSqid(encode(id))).toBe(true);
59
+ }
60
+ });
61
+
62
+ it("returns false for empty string", () => {
63
+ expect(isValidSqid("")).toBe(false);
64
+ });
65
+ });
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { dsRedirect, dsToast, dsSignals } from "../sse.js";
3
+
4
+ describe("dsRedirect", () => {
5
+ it("returns a Response with text/html content-type", () => {
6
+ const res = dsRedirect("/dash");
7
+ expect(res.headers.get("Content-Type")).toBe("text/html");
8
+ });
9
+
10
+ it("includes Datastar headers for append mode", () => {
11
+ const res = dsRedirect("/dash");
12
+ expect(res.headers.get("Datastar-Mode")).toBe("append");
13
+ expect(res.headers.get("Datastar-Selector")).toBe("body");
14
+ });
15
+
16
+ it("body contains redirect script with correct URL", async () => {
17
+ const res = dsRedirect("/dash/posts");
18
+ const body = await res.text();
19
+ expect(body).toContain("window.location.href='/dash/posts'");
20
+ });
21
+
22
+ it("escapes single quotes in URL", async () => {
23
+ const res = dsRedirect("/path/with'quote");
24
+ const body = await res.text();
25
+ expect(body).toContain("\\'");
26
+ });
27
+
28
+ it("merges additional headers", () => {
29
+ const res = dsRedirect("/dash", {
30
+ headers: { "Set-Cookie": "session=abc" },
31
+ });
32
+ expect(res.headers.get("Set-Cookie")).toBe("session=abc");
33
+ expect(res.headers.get("Content-Type")).toBe("text/html");
34
+ });
35
+ });
36
+
37
+ describe("dsToast", () => {
38
+ it("returns text/html content-type", () => {
39
+ const res = dsToast("Saved!");
40
+ expect(res.headers.get("Content-Type")).toBe("text/html");
41
+ });
42
+
43
+ it("targets #toast-container", () => {
44
+ const res = dsToast("Saved!");
45
+ expect(res.headers.get("Datastar-Selector")).toBe("#toast-container");
46
+ expect(res.headers.get("Datastar-Mode")).toBe("append");
47
+ });
48
+
49
+ it("defaults to success type", async () => {
50
+ const res = dsToast("Saved!");
51
+ const body = await res.text();
52
+ expect(body).toContain("toast-success");
53
+ });
54
+
55
+ it("supports error type", async () => {
56
+ const res = dsToast("Failed!", "error");
57
+ const body = await res.text();
58
+ expect(body).toContain("toast-error");
59
+ });
60
+
61
+ it("escapes HTML in message", async () => {
62
+ const res = dsToast("<script>alert('xss')</script>");
63
+ const body = await res.text();
64
+ expect(body).not.toContain("<script>alert");
65
+ expect(body).toContain("&lt;script&gt;");
66
+ });
67
+ });
68
+
69
+ describe("dsSignals", () => {
70
+ it("returns application/json content-type", () => {
71
+ const res = dsSignals({ count: 1 });
72
+ expect(res.headers.get("Content-Type")).toBe("application/json");
73
+ });
74
+
75
+ it("body contains JSON-serialized signals", async () => {
76
+ const res = dsSignals({ _error: "File too large", count: 42 });
77
+ const body = await res.json();
78
+ expect(body).toEqual({ _error: "File too large", count: 42 });
79
+ });
80
+
81
+ it("handles empty signals", async () => {
82
+ const res = dsSignals({});
83
+ const body = await res.json();
84
+ expect(body).toEqual({});
85
+ });
86
+ });
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import {
3
+ now,
4
+ isWithinMonth,
5
+ toISOString,
6
+ formatDate,
7
+ formatYearMonth,
8
+ } from "../time.js";
9
+
10
+ describe("now", () => {
11
+ afterEach(() => {
12
+ vi.restoreAllMocks();
13
+ });
14
+
15
+ it("returns current time in seconds", () => {
16
+ const before = Math.floor(Date.now() / 1000);
17
+ const result = now();
18
+ const after = Math.floor(Date.now() / 1000);
19
+ expect(result).toBeGreaterThanOrEqual(before);
20
+ expect(result).toBeLessThanOrEqual(after);
21
+ });
22
+
23
+ it("returns an integer (not milliseconds)", () => {
24
+ const result = now();
25
+ expect(Number.isInteger(result)).toBe(true);
26
+ // Should be in seconds, not milliseconds (less than 10 billion)
27
+ expect(result).toBeLessThan(10_000_000_000);
28
+ });
29
+ });
30
+
31
+ describe("isWithinMonth", () => {
32
+ afterEach(() => {
33
+ vi.restoreAllMocks();
34
+ });
35
+
36
+ it("returns true for a timestamp within the last 30 days", () => {
37
+ const recent = now() - 60 * 60; // 1 hour ago
38
+ expect(isWithinMonth(recent)).toBe(true);
39
+ });
40
+
41
+ it("returns true for current timestamp", () => {
42
+ expect(isWithinMonth(now())).toBe(true);
43
+ });
44
+
45
+ it("returns false for a timestamp older than 30 days", () => {
46
+ const old = now() - 31 * 24 * 60 * 60; // 31 days ago
47
+ expect(isWithinMonth(old)).toBe(false);
48
+ });
49
+
50
+ it("returns false for timestamp exactly at 30-day boundary", () => {
51
+ // 30 days = 2592000 seconds
52
+ const boundary = now() - 30 * 24 * 60 * 60;
53
+ expect(isWithinMonth(boundary)).toBe(false);
54
+ });
55
+
56
+ it("returns true for timestamp just under 30 days", () => {
57
+ const justUnder = now() - (30 * 24 * 60 * 60 - 1);
58
+ expect(isWithinMonth(justUnder)).toBe(true);
59
+ });
60
+ });
61
+
62
+ describe("toISOString", () => {
63
+ it("converts Unix timestamp to ISO string", () => {
64
+ // Feb 1, 2024 00:00:00 UTC
65
+ expect(toISOString(1706745600)).toBe("2024-02-01T00:00:00.000Z");
66
+ });
67
+
68
+ it("converts epoch 0", () => {
69
+ expect(toISOString(0)).toBe("1970-01-01T00:00:00.000Z");
70
+ });
71
+
72
+ it("handles timestamps with time components", () => {
73
+ // 2024-01-15T12:30:00Z = 1705321800
74
+ expect(toISOString(1705321800)).toBe("2024-01-15T12:30:00.000Z");
75
+ });
76
+ });
77
+
78
+ describe("formatDate", () => {
79
+ it("formats as MMM DD, YYYY", () => {
80
+ expect(formatDate(1706745600)).toBe("Feb 1, 2024");
81
+ });
82
+
83
+ it("formats epoch start", () => {
84
+ expect(formatDate(0)).toBe("Jan 1, 1970");
85
+ });
86
+
87
+ it("uses UTC timezone consistently", () => {
88
+ // Dec 31, 2023 23:59:59 UTC
89
+ const timestamp = 1704067199;
90
+ expect(formatDate(timestamp)).toBe("Dec 31, 2023");
91
+ });
92
+ });
93
+
94
+ describe("formatYearMonth", () => {
95
+ it("formats as YYYY-MM", () => {
96
+ expect(formatYearMonth(1706745600)).toBe("2024-02");
97
+ });
98
+
99
+ it("zero-pads single-digit months", () => {
100
+ // Jan 15, 2024
101
+ expect(formatYearMonth(1705276800)).toBe("2024-01");
102
+ });
103
+
104
+ it("handles December correctly", () => {
105
+ // Dec 15, 2023
106
+ expect(formatYearMonth(1702598400)).toBe("2023-12");
107
+ });
108
+
109
+ it("formats epoch start", () => {
110
+ expect(formatYearMonth(0)).toBe("1970-01");
111
+ });
112
+ });
@@ -0,0 +1,138 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { extractDomain, normalizePath, isFullUrl, slugify } from "../url.js";
3
+
4
+ describe("extractDomain", () => {
5
+ it("extracts hostname from HTTPS URL", () => {
6
+ expect(extractDomain("https://example.com/path")).toBe("example.com");
7
+ });
8
+
9
+ it("extracts hostname from HTTP URL", () => {
10
+ expect(extractDomain("http://example.com")).toBe("example.com");
11
+ });
12
+
13
+ it("includes www subdomain", () => {
14
+ expect(extractDomain("https://www.example.com/path")).toBe(
15
+ "www.example.com",
16
+ );
17
+ });
18
+
19
+ it("handles URLs with ports", () => {
20
+ expect(extractDomain("http://localhost:3000/api")).toBe("localhost");
21
+ });
22
+
23
+ it("handles URLs with query params and hash", () => {
24
+ expect(extractDomain("https://example.com/path?q=1#section")).toBe(
25
+ "example.com",
26
+ );
27
+ });
28
+
29
+ it("returns null for invalid URLs", () => {
30
+ expect(extractDomain("not-a-url")).toBe(null);
31
+ expect(extractDomain("")).toBe(null);
32
+ });
33
+
34
+ it("handles complex subdomains", () => {
35
+ expect(extractDomain("https://blog.sub.example.com")).toBe(
36
+ "blog.sub.example.com",
37
+ );
38
+ });
39
+ });
40
+
41
+ describe("normalizePath", () => {
42
+ it("converts to lowercase", () => {
43
+ expect(normalizePath("About")).toBe("about");
44
+ expect(normalizePath("HELLO")).toBe("hello");
45
+ });
46
+
47
+ it("removes leading and trailing slashes", () => {
48
+ expect(normalizePath("/about/")).toBe("about");
49
+ expect(normalizePath("///about///")).toBe("about");
50
+ });
51
+
52
+ it("collapses multiple slashes", () => {
53
+ expect(normalizePath("about//contact")).toBe("about/contact");
54
+ expect(normalizePath("a///b////c")).toBe("a/b/c");
55
+ });
56
+
57
+ it("trims whitespace", () => {
58
+ expect(normalizePath(" about ")).toBe("about");
59
+ });
60
+
61
+ it("handles combined transformations", () => {
62
+ expect(normalizePath(" /About/Contact// ")).toBe("about/contact");
63
+ });
64
+
65
+ it("returns empty string for root path", () => {
66
+ expect(normalizePath("/")).toBe("");
67
+ expect(normalizePath("///")).toBe("");
68
+ });
69
+
70
+ it("handles empty input", () => {
71
+ expect(normalizePath("")).toBe("");
72
+ expect(normalizePath(" ")).toBe("");
73
+ });
74
+ });
75
+
76
+ describe("isFullUrl", () => {
77
+ it("returns true for https URLs", () => {
78
+ expect(isFullUrl("https://example.com")).toBe(true);
79
+ });
80
+
81
+ it("returns true for http URLs", () => {
82
+ expect(isFullUrl("http://example.com")).toBe(true);
83
+ });
84
+
85
+ it("returns false for relative paths", () => {
86
+ expect(isFullUrl("/about")).toBe(false);
87
+ expect(isFullUrl("about")).toBe(false);
88
+ });
89
+
90
+ it("returns false for domain-only strings", () => {
91
+ expect(isFullUrl("example.com")).toBe(false);
92
+ });
93
+
94
+ it("returns false for empty string", () => {
95
+ expect(isFullUrl("")).toBe(false);
96
+ });
97
+
98
+ it("returns false for other protocols", () => {
99
+ expect(isFullUrl("ftp://example.com")).toBe(false);
100
+ expect(isFullUrl("mailto:test@test.com")).toBe(false);
101
+ });
102
+ });
103
+
104
+ describe("slugify", () => {
105
+ it("converts text to lowercase hyphenated slug", () => {
106
+ expect(slugify("Hello World")).toBe("hello-world");
107
+ });
108
+
109
+ it("removes special characters", () => {
110
+ expect(slugify("Hello World! This is a Test.")).toBe(
111
+ "hello-world-this-is-a-test",
112
+ );
113
+ });
114
+
115
+ it("collapses multiple spaces", () => {
116
+ expect(slugify("Multiple Spaces")).toBe("multiple-spaces");
117
+ });
118
+
119
+ it("trims leading/trailing whitespace and hyphens", () => {
120
+ expect(slugify(" Multiple Spaces ")).toBe("multiple-spaces");
121
+ });
122
+
123
+ it("replaces underscores with hyphens", () => {
124
+ expect(slugify("hello_world")).toBe("hello-world");
125
+ });
126
+
127
+ it("handles already-slugified text", () => {
128
+ expect(slugify("already-a-slug")).toBe("already-a-slug");
129
+ });
130
+
131
+ it("handles empty string", () => {
132
+ expect(slugify("")).toBe("");
133
+ });
134
+
135
+ it("removes non-word characters", () => {
136
+ expect(slugify("café & résumé")).toBe("caf-rsum");
137
+ });
138
+ });
@@ -27,7 +27,6 @@ export const RESERVED_PATHS = [
27
27
  "static",
28
28
  "assets",
29
29
  "health",
30
- "appearance",
31
30
  ] as const;
32
31
 
33
32
  export type ReservedPath = (typeof RESERVED_PATHS)[number];