@beechcms/api 0.4.0-preview.8 → 0.4.0

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 (89) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/index-CewtCjom.css +1 -0
  4. package/assets/dashboard/assets/index-FQ6JhvRH.js +554 -0
  5. package/assets/dashboard/beechLogoDark.svg +48 -48
  6. package/assets/dashboard/index.html +18 -18
  7. package/assets/dashboard/sol.svg +3 -3
  8. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  9. package/migrations/0000_v040_base.sql +213 -213
  10. package/package.json +2 -2
  11. package/src/auth/constants.ts +10 -10
  12. package/src/auth/login.ts +91 -91
  13. package/src/auth/refresh.ts +127 -127
  14. package/src/factory.ts +347 -303
  15. package/src/features/content/constants.ts +10 -0
  16. package/src/features/content/handlers/create.ts +153 -0
  17. package/src/features/content/handlers/delete.ts +76 -0
  18. package/src/features/content/handlers/facets.ts +45 -0
  19. package/src/features/content/handlers/get.ts +116 -0
  20. package/src/features/content/handlers/list.ts +88 -0
  21. package/src/features/content/handlers/update.ts +207 -0
  22. package/src/features/content/index.ts +20 -0
  23. package/src/features/draft/draft.handler.ts +272 -198
  24. package/src/features/draft/index.ts +1 -1
  25. package/src/features/email/email.provider.ts +38 -38
  26. package/src/features/email/email.service.ts +80 -80
  27. package/src/features/email/email.types.ts +98 -98
  28. package/src/features/email/index.ts +28 -28
  29. package/src/features/email/providers/resend.ts +63 -63
  30. package/src/features/email/templates/password-changed.ts +59 -59
  31. package/src/features/email/templates/password-reset.ts +64 -64
  32. package/src/features/email/templates/shell.ts +92 -93
  33. package/src/features/notifications/index.ts +1 -1
  34. package/src/features/notifications/notifications.handler.ts +130 -88
  35. package/src/features/password-reset/index.ts +15 -15
  36. package/src/features/password-reset/request.ts +106 -88
  37. package/src/features/password-reset/reset.ts +128 -110
  38. package/src/features/rotate-field/index.ts +1 -1
  39. package/src/features/rotate-field/rotate-field.handler.ts +132 -82
  40. package/src/features/rotate-field/rotate-field.schema.ts +13 -9
  41. package/src/features/schema/schema.handler.ts +16 -16
  42. package/src/features/settings/settings.handler.ts +414 -249
  43. package/src/features/setup/index.ts +96 -59
  44. package/src/features/stats/index.ts +1 -1
  45. package/src/features/stats/stats.handler.ts +458 -395
  46. package/src/index.ts +24 -11
  47. package/src/media-utils.ts +78 -78
  48. package/src/middleware/repository.middleware.ts +24 -0
  49. package/src/middleware/storage.middleware.ts +21 -0
  50. package/src/middleware.ts +67 -67
  51. package/src/public/access-policy.ts +23 -23
  52. package/src/public/api-key-middleware.ts +53 -53
  53. package/src/public/index.ts +12 -12
  54. package/src/public/problem-details.ts +48 -42
  55. package/src/public/public-add.ts +166 -183
  56. package/src/public/public-edit.ts +158 -183
  57. package/src/public/public-errors.ts +15 -15
  58. package/src/public/public-read.ts +216 -217
  59. package/src/public/public-routes.ts +84 -31
  60. package/src/public/query-builder.ts +152 -241
  61. package/src/public/rate-limit-middleware.ts +42 -42
  62. package/src/public/response-builder.ts +26 -26
  63. package/src/public/sanitize.ts +65 -65
  64. package/src/public/slug-utils.ts +14 -14
  65. package/src/search-utils.ts +192 -192
  66. package/src/search.ts +72 -72
  67. package/src/shared/activity-logger.ts +79 -79
  68. package/src/shared/apply-policies.ts +63 -63
  69. package/src/shared/base.repository.d1.ts +28 -0
  70. package/src/shared/content-utils.ts +82 -108
  71. package/src/shared/content.repository.d1.ts +382 -0
  72. package/src/shared/fts-sync.ts +4 -4
  73. package/src/shared/idempotency.repository.d1.ts +56 -0
  74. package/src/shared/media.repository.d1.ts +64 -0
  75. package/src/shared/notification-service.ts +56 -56
  76. package/src/shared/query-utils.ts +137 -137
  77. package/src/shared/storage/factory.ts +40 -0
  78. package/src/shared/storage/r2-binding-bucket.ts +81 -0
  79. package/src/shared/storage/s3-bucket.ts +163 -0
  80. package/src/shared/storage-utils.ts +36 -36
  81. package/src/shared/system-stats.repository.d1.ts +44 -0
  82. package/src/types.ts +46 -36
  83. package/src/upload.ts +179 -335
  84. package/src/widget.ts +349 -349
  85. package/assets/dashboard/assets/index-CC-jbp6g.js +0 -554
  86. package/assets/dashboard/assets/index-CQODXprH.css +0 -1
  87. package/src/content.ts +0 -502
  88. package/src/features/draft/draft.test.ts +0 -315
  89. package/src/features/rotate-field/rotate-field.test.ts +0 -297
@@ -1,65 +1,65 @@
1
- import { validateAndSanitizeSeedPayload } from '@beechcms/core'
2
- import type { Seed, ValidationDetail } from '@beechcms/core'
3
-
4
- type PublicSanitizeSuccess = {
5
- ok: true
6
- data: Record<string, unknown>
7
- }
8
-
9
- type PublicSanitizeFailure = {
10
- ok: false
11
- status: 400 | 422
12
- code: 'validation_failed' | 'dangerous_content'
13
- message: string
14
- details?: ValidationDetail[]
15
- }
16
-
17
- export type PublicSanitizeResult = PublicSanitizeSuccess | PublicSanitizeFailure
18
-
19
- /**
20
- * Adapter Public API: usa la foundation del core e mappa errori nel formato sprint.
21
- */
22
- export function sanitizePublicPayload(
23
- seed: Seed,
24
- payload: Record<string, unknown>,
25
- options: {
26
- allowNull?: boolean
27
- operation?: 'create' | 'update'
28
- requireAtLeastOneValidField?: boolean
29
- enforceRequiredFields?: boolean
30
- } = {}
31
- ): PublicSanitizeResult {
32
- const operation = options.operation ?? 'create'
33
- const result = validateAndSanitizeSeedPayload(seed, payload, {
34
- allowNull: options.allowNull ?? false,
35
- operation,
36
- requireAtLeastOneValidField: options.requireAtLeastOneValidField ?? true,
37
- enforceRequiredFields: options.enforceRequiredFields ?? true,
38
- })
39
-
40
- if (result.dangerousFields.length > 0) {
41
- const field = result.dangerousFields[0]
42
- return {
43
- ok: false,
44
- status: 422,
45
- code: 'dangerous_content',
46
- message: `Content rejected: dangerous markup detected in field '${field}'`,
47
- }
48
- }
49
-
50
- if (result.details.length > 0) {
51
- return {
52
- ok: false,
53
- status: 400,
54
- code: 'validation_failed',
55
- message: 'Validation failed',
56
- details: result.details,
57
- }
58
- }
59
-
60
- return {
61
- ok: true,
62
- data: result.data,
63
- }
64
- }
65
-
1
+ import { validateAndSanitizeSeedPayload } from '@beechcms/core'
2
+ import type { Seed, ValidationDetail } from '@beechcms/core'
3
+
4
+ type PublicSanitizeSuccess = {
5
+ ok: true
6
+ data: Record<string, unknown>
7
+ }
8
+
9
+ type PublicSanitizeFailure = {
10
+ ok: false
11
+ status: 400 | 422
12
+ code: 'validation_failed' | 'dangerous_content'
13
+ message: string
14
+ details?: ValidationDetail[]
15
+ }
16
+
17
+ export type PublicSanitizeResult = PublicSanitizeSuccess | PublicSanitizeFailure
18
+
19
+ /**
20
+ * Public API Adapter: uses the core foundation and maps errors into the sprint format.
21
+ */
22
+ export function sanitizePublicPayload(
23
+ seed: Seed,
24
+ payload: Record<string, unknown>,
25
+ options: {
26
+ allowNull?: boolean
27
+ operation?: 'create' | 'update'
28
+ requireAtLeastOneValidField?: boolean
29
+ enforceRequiredFields?: boolean
30
+ } = {}
31
+ ): PublicSanitizeResult {
32
+ const operation = options.operation ?? 'create'
33
+ const result = validateAndSanitizeSeedPayload(seed, payload, {
34
+ allowNull: options.allowNull ?? false,
35
+ operation,
36
+ requireAtLeastOneValidField: options.requireAtLeastOneValidField ?? true,
37
+ enforceRequiredFields: options.enforceRequiredFields ?? true,
38
+ })
39
+
40
+ if (result.dangerousFields.length > 0) {
41
+ const field = result.dangerousFields[0]
42
+ return {
43
+ ok: false,
44
+ status: 422,
45
+ code: 'dangerous_content',
46
+ message: `Content rejected: dangerous markup detected in field '${field}'`,
47
+ }
48
+ }
49
+
50
+ if (result.details.length > 0) {
51
+ return {
52
+ ok: false,
53
+ status: 400,
54
+ code: 'validation_failed',
55
+ message: 'Validation failed',
56
+ details: result.details,
57
+ }
58
+ }
59
+
60
+ return {
61
+ ok: true,
62
+ data: result.data,
63
+ }
64
+ }
65
+
@@ -1,14 +1,14 @@
1
- import { slugify, generateEntrySlug } from '@beechcms/core'
2
-
3
- /**
4
- * Converte stringa in slug URL-safe.
5
- * Logica spostata in @beechcms/core per consistenza tra Dashboard e API.
6
- */
7
- export { slugify }
8
-
9
- /**
10
- * Genera slug da title/name o fallback UUID-like.
11
- * Logica spostata in @beechcms/core per consistenza tra Dashboard e API.
12
- */
13
- export { generateEntrySlug }
14
-
1
+ import { slugify, generateEntrySlug } from '@beechcms/core'
2
+
3
+ /**
4
+ * Converts a string into a URL-safe slug.
5
+ * Logic moved to @beechcms/core for consistency between Dashboard and API.
6
+ */
7
+ export { slugify }
8
+
9
+ /**
10
+ * Generates a slug from a title/name or a UUID-like fallback.
11
+ * Logic moved to @beechcms/core for consistency between Dashboard and API.
12
+ */
13
+ export { generateEntrySlug }
14
+
@@ -1,192 +1,192 @@
1
- // apps/api/src/search-utils.ts
2
- // Pure functions — zero Hono dependencies, importable from Vitest.
3
- // v0.4.0: FTS is per-seed (fts_{slug}), joined with content_{slug} for metadata.
4
-
5
- import type { Seed } from "@beechcms/core"
6
-
7
- // ─── Types ───────────────────────────────────────────────────────────────────
8
-
9
- // Row returned by UNION ALL query across fts_{slug} JOIN content_{slug}
10
- export interface FtsRow {
11
- entry_id: string
12
- schema_slug: string
13
- slug: string | null
14
- status: string
15
- title: string | null
16
- excerpt: string
17
- rank: number
18
- }
19
-
20
- export interface SearchResultItem {
21
- id: string
22
- schema_slug: string
23
- slug: string | null
24
- status: string
25
- title: string
26
- excerpt: string
27
- data: Record<string, unknown>
28
- }
29
-
30
- export interface SearchResponse {
31
- items: SearchResultItem[]
32
- nextCursor: string | null
33
- total: number
34
- }
35
-
36
- export interface SearchQueryParams {
37
- q: string
38
- schemaSlug: string | null
39
- status: string | null
40
- limit: number // already clamped 1–50 by handler
41
- cursor: string | null
42
- }
43
-
44
- // ─── Cursor ──────────────────────────────────────────────────────────────────
45
-
46
- export function encodeCursor(rank: number, entryId: string): string {
47
- return btoa(`${rank}:${entryId}`)
48
- }
49
-
50
- export function decodeCursor(cursor: string): { rank: number; entryId: string } | null {
51
- try {
52
- const decoded = atob(cursor)
53
- const sep = decoded.lastIndexOf(":")
54
- if (sep === -1) return null
55
- return {
56
- rank: parseFloat(decoded.slice(0, sep)),
57
- entryId: decoded.slice(sep + 1),
58
- }
59
- } catch {
60
- return null
61
- }
62
- }
63
-
64
- // ─── Helpers ─────────────────────────────────────────────────────────────────
65
-
66
- function hasSearchableFts(seed: Seed): boolean {
67
- return seed.branches.some(b =>
68
- (b.type === 'text' || b.type === 'richtext') && b.policies?.search !== false
69
- )
70
- }
71
-
72
- function buildMatchExpr(q: string): string {
73
- const MIN_PREFIX = 3
74
- const safeQ = q.replace(/["*^()]/g, " ").trim()
75
- const terms = safeQ.split(/\s+/).filter(t => t.length >= 2)
76
- if (terms.length === 0) throw new Error("EMPTY_QUERY")
77
-
78
- return terms
79
- .map(t => {
80
- if (/^\d+$/.test(t)) return `"${t}"`
81
- if (t.length <= MIN_PREFIX) return `"${t}"*`
82
- const prefixes: string[] = []
83
- for (let i = MIN_PREFIX; i <= t.length; i++) {
84
- prefixes.push(`"${t.slice(0, i)}"*`)
85
- }
86
- return `(${prefixes.join(" OR ")})`
87
- })
88
- .join(" ")
89
- }
90
-
91
- // ─── Query builder ───────────────────────────────────────────────────────────
92
-
93
- /**
94
- * Builds a UNION ALL query across all per-seed fts_{slug} tables (v0.4.0).
95
- * Each SELECT joins fts_{slug} with content_{slug} to fetch title, slug, status.
96
- * Seeds param: full registry — filtered internally by schemaSlug and FTS availability.
97
- */
98
- export function buildFtsQuery(
99
- params: SearchQueryParams,
100
- seeds: Seed[],
101
- ): {
102
- sql: string
103
- binds: unknown[]
104
- countSql: string
105
- countBinds: unknown[]
106
- } {
107
- const { q, schemaSlug, status, limit, cursor } = params
108
-
109
- const matchExpr = buildMatchExpr(q) // throws EMPTY_QUERY if needed
110
-
111
- const targetSeeds = seeds.filter(s =>
112
- hasSearchableFts(s) && (schemaSlug === null || s.slug === schemaSlug)
113
- )
114
-
115
- if (targetSeeds.length === 0) {
116
- return {
117
- sql: "SELECT NULL as entry_id, NULL as schema_slug, NULL as slug, NULL as status, NULL as title, '' as excerpt, 0 as rank WHERE 1=0",
118
- binds: [],
119
- countSql: "SELECT 0 as total",
120
- countBinds: [],
121
- }
122
- }
123
-
124
- const decoded = cursor ? decodeCursor(cursor) : null
125
-
126
- const parts: string[] = []
127
- const binds: unknown[] = []
128
- const countParts: string[] = []
129
- const countBinds: unknown[] = []
130
-
131
- for (const seed of targetSeeds) {
132
- const fts = `fts_${seed.slug}`
133
- const table = `content_${seed.slug}`
134
- const title = seed.displayNameAlias
135
-
136
- // Main query per seed
137
- const where: string[] = [`${fts} MATCH ?`]
138
- const lb: unknown[] = [matchExpr]
139
-
140
- if (status) { where.push("ce.status = ?"); lb.push(status) }
141
-
142
- if (decoded) {
143
- where.push(`(bm25(${fts}) > ? OR (bm25(${fts}) = ? AND f.entry_id > ?))`)
144
- lb.push(decoded.rank, decoded.rank, decoded.entryId)
145
- }
146
-
147
- parts.push(
148
- `SELECT f.entry_id, '${seed.slug}' AS schema_slug, ce.slug, ce.status,` +
149
- ` ce.${title} AS title,` +
150
- ` snippet(${fts}, 1, '<mark>', '</mark>', '…', 16) AS excerpt,` +
151
- ` bm25(${fts}) AS rank` +
152
- ` FROM ${fts} f JOIN ${table} ce ON ce.id = f.entry_id` +
153
- ` WHERE ${where.join(' AND ')}`
154
- )
155
- binds.push(...lb)
156
-
157
- // Count per seed (no cursor, no limit)
158
- const cw: string[] = [`${fts} MATCH ?`]
159
- const cb: unknown[] = [matchExpr]
160
- if (status) { cw.push("ce.status = ?"); cb.push(status) }
161
-
162
- countParts.push(
163
- `SELECT COUNT(*) as c FROM ${fts} f JOIN ${table} ce ON ce.id = f.entry_id WHERE ${cw.join(' AND ')}`
164
- )
165
- countBinds.push(...cb)
166
- }
167
-
168
- const sql = `${parts.join(' UNION ALL ')} ORDER BY rank, entry_id LIMIT ?`
169
- binds.push(limit + 1)
170
-
171
- const countSql = `SELECT SUM(c) as total FROM (${countParts.join(' UNION ALL ')})`
172
-
173
- return { sql, binds, countSql, countBinds }
174
- }
175
-
176
- // ─── Mapper ──────────────────────────────────────────────────────────────────
177
-
178
- function stripHtmlPreserveMark(html: string): string {
179
- return html.replace(/<(?!\/?mark\b)[^>]*>/gi, ' ').replace(/\s+/g, ' ').trim()
180
- }
181
-
182
- export function mapFtsRow(row: FtsRow): SearchResultItem {
183
- return {
184
- id: row.entry_id,
185
- schema_slug: row.schema_slug,
186
- slug: row.slug,
187
- status: row.status,
188
- title: row.title ?? "",
189
- excerpt: stripHtmlPreserveMark(row.excerpt ?? ""),
190
- data: {},
191
- }
192
- }
1
+ // apps/api/src/search-utils.ts
2
+ // Pure functions — zero Hono dependencies, importable from Vitest.
3
+ // v0.4.0: FTS is per-seed (fts_{slug}), joined with content_{slug} for metadata.
4
+
5
+ import type { Seed } from "@beechcms/core"
6
+
7
+ // ─── Types ───────────────────────────────────────────────────────────────────
8
+
9
+ // Row returned by UNION ALL query across fts_{slug} JOIN content_{slug}
10
+ export interface FtsRow {
11
+ entry_id: string
12
+ schema_slug: string
13
+ slug: string | null
14
+ status: string
15
+ title: string | null
16
+ excerpt: string
17
+ rank: number
18
+ }
19
+
20
+ export interface SearchResultItem {
21
+ id: string
22
+ schema_slug: string
23
+ slug: string | null
24
+ status: string
25
+ title: string
26
+ excerpt: string
27
+ data: Record<string, unknown>
28
+ }
29
+
30
+ export interface SearchResponse {
31
+ items: SearchResultItem[]
32
+ nextCursor: string | null
33
+ total: number
34
+ }
35
+
36
+ export interface SearchQueryParams {
37
+ q: string
38
+ schemaSlug: string | null
39
+ status: string | null
40
+ limit: number // already clamped 1–50 by handler
41
+ cursor: string | null
42
+ }
43
+
44
+ // ─── Cursor ──────────────────────────────────────────────────────────────────
45
+
46
+ export function encodeCursor(rank: number, entryId: string): string {
47
+ return btoa(`${rank}:${entryId}`)
48
+ }
49
+
50
+ export function decodeCursor(cursor: string): { rank: number; entryId: string } | null {
51
+ try {
52
+ const decoded = atob(cursor)
53
+ const sep = decoded.lastIndexOf(":")
54
+ if (sep === -1) return null
55
+ return {
56
+ rank: parseFloat(decoded.slice(0, sep)),
57
+ entryId: decoded.slice(sep + 1),
58
+ }
59
+ } catch {
60
+ return null
61
+ }
62
+ }
63
+
64
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
65
+
66
+ function hasSearchableFts(seed: Seed): boolean {
67
+ return seed.branches.some(b =>
68
+ (b.type === 'text' || b.type === 'richtext') && b.policies?.search !== false
69
+ )
70
+ }
71
+
72
+ function buildMatchExpr(q: string): string {
73
+ const MIN_PREFIX = 3
74
+ const safeQ = q.replace(/["*^()]/g, " ").trim()
75
+ const terms = safeQ.split(/\s+/).filter(t => t.length >= 2)
76
+ if (terms.length === 0) throw new Error("EMPTY_QUERY")
77
+
78
+ return terms
79
+ .map(t => {
80
+ if (/^\d+$/.test(t)) return `"${t}"`
81
+ if (t.length <= MIN_PREFIX) return `"${t}"*`
82
+ const prefixes: string[] = []
83
+ for (let i = MIN_PREFIX; i <= t.length; i++) {
84
+ prefixes.push(`"${t.slice(0, i)}"*`)
85
+ }
86
+ return `(${prefixes.join(" OR ")})`
87
+ })
88
+ .join(" ")
89
+ }
90
+
91
+ // ─── Query builder ───────────────────────────────────────────────────────────
92
+
93
+ /**
94
+ * Builds a UNION ALL query across all per-seed fts_{slug} tables (v0.4.0).
95
+ * Each SELECT joins fts_{slug} with content_{slug} to fetch title, slug, status.
96
+ * Seeds param: full registry — filtered internally by schemaSlug and FTS availability.
97
+ */
98
+ export function buildFtsQuery(
99
+ params: SearchQueryParams,
100
+ seeds: Seed[],
101
+ ): {
102
+ sql: string
103
+ binds: unknown[]
104
+ countSql: string
105
+ countBinds: unknown[]
106
+ } {
107
+ const { q, schemaSlug, status, limit, cursor } = params
108
+
109
+ const matchExpr = buildMatchExpr(q) // throws EMPTY_QUERY if needed
110
+
111
+ const targetSeeds = seeds.filter(s =>
112
+ hasSearchableFts(s) && (schemaSlug === null || s.slug === schemaSlug)
113
+ )
114
+
115
+ if (targetSeeds.length === 0) {
116
+ return {
117
+ sql: "SELECT NULL as entry_id, NULL as schema_slug, NULL as slug, NULL as status, NULL as title, '' as excerpt, 0 as rank WHERE 1=0",
118
+ binds: [],
119
+ countSql: "SELECT 0 as total",
120
+ countBinds: [],
121
+ }
122
+ }
123
+
124
+ const decoded = cursor ? decodeCursor(cursor) : null
125
+
126
+ const parts: string[] = []
127
+ const binds: unknown[] = []
128
+ const countParts: string[] = []
129
+ const countBinds: unknown[] = []
130
+
131
+ for (const seed of targetSeeds) {
132
+ const fts = `fts_${seed.slug}`
133
+ const table = `content_${seed.slug}`
134
+ const title = seed.displayNameAlias
135
+
136
+ // Main query per seed
137
+ const where: string[] = [`${fts} MATCH ?`]
138
+ const lb: unknown[] = [matchExpr]
139
+
140
+ if (status) { where.push("ce.status = ?"); lb.push(status) }
141
+
142
+ if (decoded) {
143
+ where.push(`(bm25(${fts}) > ? OR (bm25(${fts}) = ? AND f.entry_id > ?))`)
144
+ lb.push(decoded.rank, decoded.rank, decoded.entryId)
145
+ }
146
+
147
+ parts.push(
148
+ `SELECT f.entry_id, '${seed.slug}' AS schema_slug, ce.slug, ce.status,` +
149
+ ` ce.${title} AS title,` +
150
+ ` snippet(${fts}, 1, '<mark>', '</mark>', '…', 16) AS excerpt,` +
151
+ ` bm25(${fts}) AS rank` +
152
+ ` FROM ${fts} f JOIN ${table} ce ON ce.id = f.entry_id` +
153
+ ` WHERE ${where.join(' AND ')}`
154
+ )
155
+ binds.push(...lb)
156
+
157
+ // Count per seed (no cursor, no limit)
158
+ const cw: string[] = [`${fts} MATCH ?`]
159
+ const cb: unknown[] = [matchExpr]
160
+ if (status) { cw.push("ce.status = ?"); cb.push(status) }
161
+
162
+ countParts.push(
163
+ `SELECT COUNT(*) as c FROM ${fts} f JOIN ${table} ce ON ce.id = f.entry_id WHERE ${cw.join(' AND ')}`
164
+ )
165
+ countBinds.push(...cb)
166
+ }
167
+
168
+ const sql = `${parts.join(' UNION ALL ')} ORDER BY rank, entry_id LIMIT ?`
169
+ binds.push(limit + 1)
170
+
171
+ const countSql = `SELECT SUM(c) as total FROM (${countParts.join(' UNION ALL ')})`
172
+
173
+ return { sql, binds, countSql, countBinds }
174
+ }
175
+
176
+ // ─── Mapper ──────────────────────────────────────────────────────────────────
177
+
178
+ function stripHtmlPreserveMark(html: string): string {
179
+ return html.replace(/<(?!\/?mark\b)[^>]*>/gi, ' ').replace(/\s+/g, ' ').trim()
180
+ }
181
+
182
+ export function mapFtsRow(row: FtsRow): SearchResultItem {
183
+ return {
184
+ id: row.entry_id,
185
+ schema_slug: row.schema_slug,
186
+ slug: row.slug,
187
+ status: row.status,
188
+ title: row.title ?? "",
189
+ excerpt: stripHtmlPreserveMark(row.excerpt ?? ""),
190
+ data: {},
191
+ }
192
+ }