@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.
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +0 -2
- package/dist/lib/constants.d.ts +1 -1
- package/dist/lib/constants.d.ts.map +1 -1
- package/dist/lib/constants.js +1 -2
- package/dist/routes/api/posts.js +1 -1
- package/dist/routes/dash/settings.d.ts +2 -0
- package/dist/routes/dash/settings.d.ts.map +1 -1
- package/dist/routes/dash/settings.js +413 -93
- package/dist/theme/layouts/DashLayout.d.ts.map +1 -1
- package/dist/theme/layouts/DashLayout.js +0 -8
- package/package.json +10 -5
- package/src/__tests__/helpers/app.ts +97 -0
- package/src/__tests__/helpers/db.ts +85 -0
- package/src/app.tsx +0 -3
- package/src/db/migrations/0001_add_search_fts.sql +34 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/lib/__tests__/constants.test.ts +44 -0
- package/src/lib/__tests__/markdown.test.ts +133 -0
- package/src/lib/__tests__/schemas.test.ts +220 -0
- package/src/lib/__tests__/sqid.test.ts +65 -0
- package/src/lib/__tests__/sse.test.ts +86 -0
- package/src/lib/__tests__/time.test.ts +112 -0
- package/src/lib/__tests__/url.test.ts +138 -0
- package/src/lib/constants.ts +0 -1
- package/src/middleware/__tests__/auth.test.ts +139 -0
- package/src/routes/api/__tests__/posts.test.ts +306 -0
- package/src/routes/api/__tests__/search.test.ts +77 -0
- package/src/routes/api/posts.ts +3 -1
- package/src/routes/dash/settings.tsx +350 -16
- package/src/services/__tests__/collection.test.ts +226 -0
- package/src/services/__tests__/media.test.ts +134 -0
- package/src/services/__tests__/post.test.ts +636 -0
- package/src/services/__tests__/redirect.test.ts +110 -0
- package/src/services/__tests__/search.test.ts +143 -0
- package/src/services/__tests__/settings.test.ts +110 -0
- package/src/theme/layouts/DashLayout.tsx +0 -9
- package/dist/routes/dash/appearance.d.ts +0 -13
- package/dist/routes/dash/appearance.d.ts.map +0 -1
- package/dist/routes/dash/appearance.js +0 -160
- 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.
|
|
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;
|
|
@@ -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("")).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
|
+
});
|