@coywolf/emdash 0.0.0-stage → 0.35.13

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 (261) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -2
  3. package/package.json +70 -5
  4. package/src/admin/admin-css.ts +57 -0
  5. package/src/admin/ai.tsx +915 -0
  6. package/src/admin/backups.tsx +613 -0
  7. package/src/admin/breadcrumbs.tsx +542 -0
  8. package/src/admin/codeBlocks.tsx +204 -0
  9. package/src/admin/customBlocks.tsx +321 -0
  10. package/src/admin/dirty.ts +20 -0
  11. package/src/admin/discovery.tsx +463 -0
  12. package/src/admin/download.ts +28 -0
  13. package/src/admin/feature-switches.tsx +124 -0
  14. package/src/admin/features.tsx +142 -0
  15. package/src/admin/files.tsx +791 -0
  16. package/src/admin/formUploads.tsx +445 -0
  17. package/src/admin/guides.tsx +18 -0
  18. package/src/admin/headings.tsx +262 -0
  19. package/src/admin/images.tsx +825 -0
  20. package/src/admin/index.tsx +62 -0
  21. package/src/admin/links.tsx +980 -0
  22. package/src/admin/performance.tsx +330 -0
  23. package/src/admin/preview.tsx +63 -0
  24. package/src/admin/redirects-removed.tsx +266 -0
  25. package/src/admin/redirects.tsx +470 -0
  26. package/src/admin/reviews.tsx +232 -0
  27. package/src/admin/robots-bots.tsx +647 -0
  28. package/src/admin/robots-shared.tsx +109 -0
  29. package/src/admin/robots-wizard.tsx +748 -0
  30. package/src/admin/robots.tsx +911 -0
  31. package/src/admin/save-bar.tsx +134 -0
  32. package/src/admin/schema.tsx +1187 -0
  33. package/src/admin/search.tsx +355 -0
  34. package/src/admin/settings-ui.tsx +87 -0
  35. package/src/admin/trash-prompt-core.ts +135 -0
  36. package/src/admin/trash-prompt.tsx +156 -0
  37. package/src/admin/usage.tsx +301 -0
  38. package/src/admin/videos-display.tsx +248 -0
  39. package/src/admin/videos.tsx +1202 -0
  40. package/src/admin/wpImport.tsx +1516 -0
  41. package/src/ai/entities.ts +59 -0
  42. package/src/ai/export.ts +61 -0
  43. package/src/ai/logic.test.ts +187 -0
  44. package/src/ai/logic.ts +606 -0
  45. package/src/ai/pack.ts +121 -0
  46. package/src/ai/prompts.ts +87 -0
  47. package/src/ai/providers.ts +276 -0
  48. package/src/ai/queue.test.ts +97 -0
  49. package/src/ai/routes.ts +359 -0
  50. package/src/ai/store.ts +301 -0
  51. package/src/ai/wikidata.test.ts +184 -0
  52. package/src/ai/wikidata.ts +87 -0
  53. package/src/ai/worker.ts +530 -0
  54. package/src/astro/astro-modules.d.ts +6 -0
  55. package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
  56. package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
  57. package/src/astro/codeBlocks/CodeBlock.astro +62 -0
  58. package/src/astro/codeBlocks/EmDashCode.astro +61 -0
  59. package/src/astro/customBlocks/CustomBlock.astro +64 -0
  60. package/src/astro/customBlocks/Details.astro +8 -0
  61. package/src/astro/customBlocks/Disclosure.astro +8 -0
  62. package/src/astro/customBlocks/Note.astro +8 -0
  63. package/src/astro/customBlocks/Podcast.astro +8 -0
  64. package/src/astro/customBlocks/Quote.astro +8 -0
  65. package/src/astro/customBlocks/Testimonial.astro +8 -0
  66. package/src/astro/files/FileCard.astro +103 -0
  67. package/src/astro/headings/HeadingBlock.astro +129 -0
  68. package/src/astro/headings/TableOfContents.astro +45 -0
  69. package/src/astro/headings/request.ts +19 -0
  70. package/src/astro/images/ResponsiveImage.astro +60 -0
  71. package/src/astro/index.ts +84 -0
  72. package/src/astro/reviews/CoywolfReview.astro +27 -0
  73. package/src/astro/reviews/Review.astro +49 -0
  74. package/src/astro/search/SearchBox.astro +61 -0
  75. package/src/astro/search/SearchBoxForm.astro +509 -0
  76. package/src/astro/tables/CaptionedTable.astro +72 -0
  77. package/src/astro/videos/CoywolfVideo.astro +448 -0
  78. package/src/backups/download.ts +112 -0
  79. package/src/backups/dump.test.mjs +287 -0
  80. package/src/backups/dump.ts +288 -0
  81. package/src/backups/module.ts +276 -0
  82. package/src/backups/pack.ts +33 -0
  83. package/src/backups/restore.test.mjs +103 -0
  84. package/src/backups/restore.ts +209 -0
  85. package/src/backups/store.ts +409 -0
  86. package/src/breadcrumbs/pack.ts +113 -0
  87. package/src/breadcrumbs/settings.ts +102 -0
  88. package/src/breadcrumbs/stamp.ts +40 -0
  89. package/src/breadcrumbs/trail.ts +123 -0
  90. package/src/codeBlocks/contrast.ts +179 -0
  91. package/src/codeBlocks/hljs.d.ts +5 -0
  92. package/src/codeBlocks/pack.ts +110 -0
  93. package/src/codeBlocks/render.test.ts +152 -0
  94. package/src/codeBlocks/render.ts +304 -0
  95. package/src/codeBlocks/settings.ts +18 -0
  96. package/src/codeBlocks/themes.generated.ts +304 -0
  97. package/src/codeBlocks/themes.ts +114 -0
  98. package/src/core/compose.ts +244 -0
  99. package/src/core/content-url.ts +888 -0
  100. package/src/core/csv.ts +119 -0
  101. package/src/core/d1-batch.ts +65 -0
  102. package/src/core/entry-docs.ts +175 -0
  103. package/src/core/features-module.ts +48 -0
  104. package/src/core/features.ts +392 -0
  105. package/src/core/generation.ts +88 -0
  106. package/src/core/guide-content.ts +166 -0
  107. package/src/core/module.ts +104 -0
  108. package/src/core/read-failures.ts +57 -0
  109. package/src/core/request-memo.ts +71 -0
  110. package/src/core/secrets.ts +83 -0
  111. package/src/core/settings-enhance.ts +74 -0
  112. package/src/core/site.ts +36 -0
  113. package/src/core/storage.ts +27 -0
  114. package/src/customBlocks/pack.ts +260 -0
  115. package/src/customBlocks/render.ts +424 -0
  116. package/src/customBlocks/rich.ts +313 -0
  117. package/src/customBlocks/settings.ts +46 -0
  118. package/src/discovery/build.ts +252 -0
  119. package/src/discovery/feed-build.ts +217 -0
  120. package/src/discovery/feed.ts +172 -0
  121. package/src/discovery/indexnow.ts +114 -0
  122. package/src/discovery/llms.ts +79 -0
  123. package/src/discovery/markdown.ts +294 -0
  124. package/src/discovery/memory-cache.ts +97 -0
  125. package/src/discovery/middleware.ts +186 -0
  126. package/src/discovery/module.ts +353 -0
  127. package/src/discovery/news.ts +99 -0
  128. package/src/discovery/pack.ts +58 -0
  129. package/src/discovery/settings.ts +91 -0
  130. package/src/discovery/urls.ts +35 -0
  131. package/src/env.d.ts +10 -0
  132. package/src/files/card.ts +231 -0
  133. package/src/files/counts.test.ts +114 -0
  134. package/src/files/counts.ts +60 -0
  135. package/src/files/export.ts +44 -0
  136. package/src/files/format.test.ts +93 -0
  137. package/src/files/format.ts +163 -0
  138. package/src/files/module.ts +596 -0
  139. package/src/files/pack.ts +92 -0
  140. package/src/files/r2-rules.test.ts +30 -0
  141. package/src/files/r2-rules.ts +41 -0
  142. package/src/files/range.ts +50 -0
  143. package/src/files/s3.ts +114 -0
  144. package/src/files/serve.ts +141 -0
  145. package/src/files/sigv4.test.ts +64 -0
  146. package/src/files/sigv4.ts +160 -0
  147. package/src/files/site.ts +137 -0
  148. package/src/files/walker.ts +58 -0
  149. package/src/formUploads/lib.ts +266 -0
  150. package/src/formUploads/pack.ts +55 -0
  151. package/src/formUploads/wrap.ts +592 -0
  152. package/src/forms.ts +16 -0
  153. package/src/headings/headings.test.ts +159 -0
  154. package/src/headings/pack.ts +154 -0
  155. package/src/headings/render.ts +139 -0
  156. package/src/headings/settings.ts +117 -0
  157. package/src/headings/slug.ts +68 -0
  158. package/src/headings/stamp.ts +142 -0
  159. package/src/headings/test-hooks.mjs +14 -0
  160. package/src/headings/toc.ts +48 -0
  161. package/src/images/cloudflare.ts +329 -0
  162. package/src/images/lib.ts +221 -0
  163. package/src/images/module.ts +372 -0
  164. package/src/images/pack.ts +804 -0
  165. package/src/images/pending.ts +37 -0
  166. package/src/images/settings.ts +48 -0
  167. package/src/images/setup.ts +192 -0
  168. package/src/images/variants-estimate.ts +162 -0
  169. package/src/images/variants-store.ts +475 -0
  170. package/src/images/variants.ts +311 -0
  171. package/src/index.ts +143 -0
  172. package/src/links/check.ts +101 -0
  173. package/src/links/classify.ts +270 -0
  174. package/src/links/export.ts +59 -0
  175. package/src/links/module.ts +652 -0
  176. package/src/links/pack.ts +43 -0
  177. package/src/links/pt.ts +325 -0
  178. package/src/links/store.ts +704 -0
  179. package/src/middleware.ts +287 -0
  180. package/src/modules.ts +53 -0
  181. package/src/options.ts +66 -0
  182. package/src/pageCache/lib.test.mjs +224 -0
  183. package/src/pageCache/lib.ts +274 -0
  184. package/src/pageCache/pack.ts +240 -0
  185. package/src/pageCache/usage-core.ts +152 -0
  186. package/src/pageCache/usage.ts +262 -0
  187. package/src/pageCache/warm.test.mjs +670 -0
  188. package/src/pageCache/warm.ts +722 -0
  189. package/src/redirects/middleware.ts +169 -0
  190. package/src/redirects/module.ts +124 -0
  191. package/src/redirects/pack.ts +89 -0
  192. package/src/redirects/removed-core.ts +151 -0
  193. package/src/redirects/removed.ts +293 -0
  194. package/src/redirects/rules.ts +355 -0
  195. package/src/redirects/slug-changes.ts +207 -0
  196. package/src/redirects/slug-core.test.ts +50 -0
  197. package/src/redirects/slug-core.ts +49 -0
  198. package/src/redirects/transfer.ts +92 -0
  199. package/src/reviews/lib.ts +624 -0
  200. package/src/reviews/pack.ts +135 -0
  201. package/src/reviews/schema.ts +128 -0
  202. package/src/reviews/settings.ts +26 -0
  203. package/src/robots/bots.ts +321 -0
  204. package/src/robots/data/bots.json +696 -0
  205. package/src/robots/data/verified.json +582 -0
  206. package/src/robots/directory.ts +26 -0
  207. package/src/robots/explain.ts +191 -0
  208. package/src/robots/guided.ts +300 -0
  209. package/src/robots/importer.ts +263 -0
  210. package/src/robots/middleware.ts +151 -0
  211. package/src/robots/module.ts +446 -0
  212. package/src/robots/pack.ts +69 -0
  213. package/src/robots/radar.ts +129 -0
  214. package/src/robots/rep.test.ts +335 -0
  215. package/src/robots/rep.ts +362 -0
  216. package/src/robots/rules.test.ts +251 -0
  217. package/src/robots/rules.ts +883 -0
  218. package/src/robots/transfer.ts +45 -0
  219. package/src/robots/validate.ts +524 -0
  220. package/src/schema/catalog.ts +146 -0
  221. package/src/schema/graph.test.mjs +522 -0
  222. package/src/schema/graph.ts +791 -0
  223. package/src/schema/module.ts +1138 -0
  224. package/src/schema/og-image.ts +100 -0
  225. package/src/schema/pack.ts +58 -0
  226. package/src/search/engine.ts +578 -0
  227. package/src/search/fallback.ts +80 -0
  228. package/src/search/live-cache.ts +116 -0
  229. package/src/search/live-client.ts +770 -0
  230. package/src/search/live-serve.ts +282 -0
  231. package/src/search/module.ts +261 -0
  232. package/src/search/pack.ts +57 -0
  233. package/src/search/query.ts +132 -0
  234. package/src/search/ratelimit-core.ts +118 -0
  235. package/src/search/ratelimit.ts +79 -0
  236. package/src/search/snippet.ts +135 -0
  237. package/src/search/title-index.ts +139 -0
  238. package/src/search/title-match.ts +192 -0
  239. package/src/shared.ts +71 -0
  240. package/src/version.ts +2 -0
  241. package/src/videos/date.ts +40 -0
  242. package/src/videos/engagement.ts +75 -0
  243. package/src/videos/lib.ts +679 -0
  244. package/src/videos/module.ts +1160 -0
  245. package/src/videos/pack.ts +187 -0
  246. package/src/videos/pause.ts +42 -0
  247. package/src/videos/poster.ts +324 -0
  248. package/src/videos/render.ts +336 -0
  249. package/src/videos/store.ts +551 -0
  250. package/src/videos/stream.ts +134 -0
  251. package/src/wpImport/convert.ts +702 -0
  252. package/src/wpImport/guests.ts +329 -0
  253. package/src/wpImport/gutenberg.ts +157 -0
  254. package/src/wpImport/markers.ts +66 -0
  255. package/src/wpImport/pack.ts +451 -0
  256. package/src/wpImport/parents.ts +202 -0
  257. package/src/wpImport/prepare.ts +592 -0
  258. package/src/wpImport/redirects.ts +358 -0
  259. package/src/wpImport/sources.ts +67 -0
  260. package/src/wpImport/stream.ts +160 -0
  261. package/src/wpImport/urls.ts +354 -0
@@ -0,0 +1,578 @@
1
+ /**
2
+ * Live results engine: EmDash's FTS5 search, read straight from D1 in a few
3
+ * batched round trips instead of EmDash's per-collection lookups.
4
+ *
5
+ * EmDash's search() re-reads each collection's search config, searchable
6
+ * fields and title column and checks the FTS table exists on every call, then
7
+ * runs one query per collection; live results called it two or three times
8
+ * (titles, full text, the any-word fallback) and then read collection info,
9
+ * terms, dates and excerpt text separately: 25–31 queries a keystroke. Here:
10
+ *
11
+ * - Collection info, fields, FTS tables and term parents: one batch, cached
12
+ * per isolate for a minute (and reloaded when the content version changes).
13
+ * - Title and full-text matches for every collection: one batch.
14
+ * - The any-word fallback, only when nothing matched every word: one batch.
15
+ * - Excerpt text and URL terms for the results: one batch. Publish dates come
16
+ * with the matches.
17
+ *
18
+ * The SQL is EmDash's (emdash/src/search/query.ts searchSingleCollection),
19
+ * with the same query escaping, BM25 weights, title scope and filters, so
20
+ * results match EmDash's search. The pure parts are exported for tests.
21
+ */
22
+ import {
23
+ COLLECTION_SLUG,
24
+ type CollectionInfo,
25
+ type PrimaryTermRow,
26
+ type SqlQuery,
27
+ type TermParentRow,
28
+ collectPrimaryTerms,
29
+ createEntryUrlResolver,
30
+ hostDefaultLocale,
31
+ interpolateUrlPattern,
32
+ overriddenCollections,
33
+ patternTaxonomies,
34
+ patternTermPathTaxonomies,
35
+ primaryTermsQuery,
36
+ termParentsFromRows,
37
+ termParentsQuery,
38
+ urlOverride,
39
+ } from "../core/content-url.js";
40
+ import { buildOrQuery, rankByCoverage } from "./fallback.js";
41
+ import { buildSnippet, highlightHtml, highlightWords, portableTextProse } from "./snippet.js";
42
+
43
+ // ── Metadata ─────────────────────────────────────────────────────
44
+
45
+ export interface CollectionMeta extends CollectionInfo {
46
+ /** search_config.enabled */
47
+ searchEnabled: boolean;
48
+ weights?: Record<string, number>;
49
+ /** Searchable fields in EmDash's order (the FTS table's column order, for BM25 weights). */
50
+ searchable: string[];
51
+ /** Searchable fields with their types, in schema (sort_order) order, for excerpts. */
52
+ excerptFields: Array<{ field: string; type: string }>;
53
+ /** The collection has a field named "title". */
54
+ hasTitleColumn: boolean;
55
+ /** The FTS table exists (unknown counts as true; the query then fails softly). */
56
+ ftsExists: boolean;
57
+ }
58
+
59
+ export interface SearchMeta {
60
+ collections: Map<string, CollectionMeta>;
61
+ /** Term parents per taxonomy named by `{termpath:…}` URL overrides. */
62
+ termParents: Map<string, Map<string, string>>;
63
+ }
64
+
65
+ interface CollectionRow {
66
+ slug: string;
67
+ label: string;
68
+ label_singular: string | null;
69
+ url_pattern: string | null;
70
+ title_field: string | null;
71
+ search_config: string | null;
72
+ }
73
+
74
+ interface FieldRow {
75
+ collection: string;
76
+ field: string;
77
+ type: string;
78
+ searchable: number;
79
+ sort_order: number | null;
80
+ }
81
+
82
+ export const META_COLLECTIONS_SQL = "SELECT slug, label, label_singular, url_pattern, title_field, search_config FROM _emdash_collections";
83
+ // rowid order is the order EmDash's getSearchableFields returns (no ORDER BY), which built the FTS table's columns.
84
+ export const META_FIELDS_SQL =
85
+ "SELECT c.slug AS collection, f.slug AS field, f.type AS type, f.searchable AS searchable, f.sort_order AS sort_order FROM _emdash_fields f JOIN _emdash_collections c ON c.id = f.collection_id ORDER BY f.rowid";
86
+ export const META_FTS_SQL = "SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE '_emdash_fts_%'";
87
+
88
+ /** Parse a collection's search_config the way EmDash's getSearchConfig does. */
89
+ export function parseSearchConfig(raw: string | null): { enabled: boolean; weights?: Record<string, number> } | null {
90
+ if (!raw) return null;
91
+ try {
92
+ const parsed: unknown = JSON.parse(raw);
93
+ if (!parsed || typeof parsed !== "object" || typeof (parsed as { enabled?: unknown }).enabled !== "boolean") return null;
94
+ const out: { enabled: boolean; weights?: Record<string, number> } = { enabled: (parsed as { enabled: boolean }).enabled };
95
+ const weights = (parsed as { weights?: unknown }).weights;
96
+ if (weights && typeof weights === "object") {
97
+ out.weights = {};
98
+ for (const [k, v] of Object.entries(weights)) if (typeof v === "number") out.weights[k] = v;
99
+ }
100
+ return out;
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ /** Build the metadata from the batch's rows. `ftsTables` null means "couldn't tell" (treated as present). */
107
+ export function buildMeta(collections: CollectionRow[], fields: FieldRow[], ftsTables: string[] | null, termParents: Map<string, Map<string, string>>): SearchMeta {
108
+ const out = new Map<string, CollectionMeta>();
109
+ const fts = ftsTables ? new Set(ftsTables) : null;
110
+ for (const c of collections) {
111
+ if (!COLLECTION_SLUG.test(c.slug)) continue;
112
+ const config = parseSearchConfig(c.search_config);
113
+ const own = fields.filter((f) => f.collection === c.slug);
114
+ out.set(c.slug, {
115
+ slug: c.slug,
116
+ label: c.label,
117
+ labelSingular: c.label_singular,
118
+ urlPattern: c.url_pattern,
119
+ titleField: c.title_field,
120
+ searchEnabled: config?.enabled === true,
121
+ weights: config?.weights,
122
+ searchable: own.filter((f) => f.searchable === 1).map((f) => f.field),
123
+ excerptFields: own
124
+ .filter((f) => f.searchable === 1)
125
+ .map((f, i) => ({ f, i }))
126
+ .sort((a, b) => (a.f.sort_order ?? 0) - (b.f.sort_order ?? 0) || a.i - b.i)
127
+ .map(({ f }) => ({ field: f.field, type: f.type })),
128
+ hasTitleColumn: own.some((f) => f.field === "title"),
129
+ ftsExists: fts ? fts.has(`_emdash_fts_${c.slug}`) : true,
130
+ });
131
+ }
132
+ return { collections: out, termParents };
133
+ }
134
+
135
+ /** Taxonomies whose term parents URL overrides need (`{termpath:…}`). */
136
+ function overrideParentTaxonomies(slugs: string[]): string[] {
137
+ return [...new Set(slugs.flatMap((slug) => patternTermPathTaxonomies(urlOverride(slug))))];
138
+ }
139
+
140
+ const META_TTL_MS = 60_000;
141
+ const metaCache = new Map<string, { meta: SearchMeta; at: number; version: string }>();
142
+
143
+ /** Forget cached metadata (call after search settings change). */
144
+ export function invalidateSearchMeta(): void {
145
+ metaCache.clear();
146
+ }
147
+
148
+ /**
149
+ * Collection info, fields, FTS tables and term parents in one batch, cached
150
+ * per isolate for a minute, and reloaded when `version` (the search content
151
+ * version) changes.
152
+ */
153
+ export async function loadSearchMeta(db: D1Database, cacheKey = "DB", version = ""): Promise<SearchMeta> {
154
+ const hit = metaCache.get(cacheKey);
155
+ if (hit && hit.version === version && Date.now() - hit.at < META_TTL_MS) return hit.meta;
156
+
157
+ // URL overrides are configuration, so their taxonomies are known before reading anything.
158
+ const parentTaxonomies = overrideParentTaxonomies(overriddenCollections());
159
+ const statements = [db.prepare(META_COLLECTIONS_SQL), db.prepare(META_FIELDS_SQL), db.prepare(META_FTS_SQL)];
160
+ const parentsQuery = parentTaxonomies.length ? termParentsQuery(parentTaxonomies) : null;
161
+ if (parentsQuery) statements.push(db.prepare(parentsQuery.sql).bind(...parentsQuery.binds));
162
+
163
+ let collections: CollectionRow[] = [];
164
+ let fields: FieldRow[] = [];
165
+ let fts: string[] | null = null;
166
+ let parentRows: TermParentRow[] = [];
167
+ try {
168
+ const results = await db.batch(statements);
169
+ collections = (results[0]?.results ?? []) as CollectionRow[];
170
+ fields = (results[1]?.results ?? []) as FieldRow[];
171
+ fts = ((results[2]?.results ?? []) as Array<{ name: string }>).map((r) => r.name);
172
+ parentRows = (results[3]?.results ?? []) as TermParentRow[];
173
+ } catch {
174
+ // One failing statement fails the batch (e.g. sqlite_master or taxonomies unreadable): read the essentials alone.
175
+ const [c, f, t, p] = await Promise.all([
176
+ statements[0].all<CollectionRow>(),
177
+ statements[1].all<FieldRow>(),
178
+ statements[2].all<{ name: string }>().catch(() => null),
179
+ statements[3] ? statements[3].all<TermParentRow>().catch(() => null) : null,
180
+ ]);
181
+ collections = c.results;
182
+ fields = f.results;
183
+ fts = t ? t.results.map((r) => r.name) : null;
184
+ parentRows = p?.results ?? [];
185
+ }
186
+ const termParents = termParentsFromRows(parentRows, parentTaxonomies, await hostDefaultLocale());
187
+ const meta = buildMeta(collections, fields, fts, termParents);
188
+ metaCache.set(cacheKey, { meta, at: Date.now(), version });
189
+ return meta;
190
+ }
191
+
192
+ // ── Queries (pure) ───────────────────────────────────────────────
193
+
194
+ const WHITESPACE = /\s+/;
195
+ const FTS_OPERATORS = /\b(AND|OR|NOT|NEAR)\b/i;
196
+
197
+ /** EmDash's escapeQuery (search/query.ts): quoted, prefix-matched words, or the visitor's own operators. */
198
+ export function escapeFtsQuery(query: string, allowOperators = true): string {
199
+ if (!query || typeof query !== "string") return "";
200
+ const trimmed = query.trim();
201
+ if (!trimmed) return "";
202
+ if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) return `"${trimmed.slice(1, -1).replace(/"/g, '""')}"`;
203
+ const escaped = trimmed.replace(/"/g, '""');
204
+ if (allowOperators && FTS_OPERATORS.test(trimmed)) return escaped;
205
+ const terms = escaped.split(WHITESPACE).filter((t) => t.length > 0);
206
+ if (!terms.length) return "";
207
+ return terms.map((t) => `"${t}"*`).join(" ");
208
+ }
209
+
210
+ export type SearchScope = "title" | "all" | "any";
211
+
212
+ /** The FTS5 column holding a collection's title, for title scope (null: it can't match by title). */
213
+ export function titleColumn(meta: CollectionMeta): string | null {
214
+ if (meta.titleField && meta.searchable.includes(meta.titleField)) return meta.titleField;
215
+ return meta.searchable.includes("title") ? "title" : null;
216
+ }
217
+
218
+ const IDENT = /^[a-z][a-z0-9_]*$/;
219
+
220
+ /** The title expression EmDash selects: the configured title field, else a "title" field, else NULL. */
221
+ export function titleExpression(meta: CollectionMeta): string {
222
+ if (meta.titleField && IDENT.test(meta.titleField)) return `c."${meta.titleField}"`;
223
+ return meta.hasTitleColumn ? "c.title" : "NULL";
224
+ }
225
+
226
+ /**
227
+ * One collection's match query (EmDash's searchSingleCollection SQL plus the
228
+ * publish date), or null when this collection can't match. "any" also
229
+ * selects EmDash's snippet, which the fallback's coverage ranking reads.
230
+ */
231
+ export function matchQuery(meta: CollectionMeta, query: string, scope: SearchScope, options: { locale?: string; limit: number }): SqlQuery | null {
232
+ if (!meta.searchEnabled || !meta.ftsExists || !COLLECTION_SLUG.test(meta.slug)) return null;
233
+ let match = escapeFtsQuery(query);
234
+ if (!match) return null;
235
+ if (scope === "title") {
236
+ const column = titleColumn(meta);
237
+ if (!column || !IDENT.test(column)) return null;
238
+ match = `${column} : (${escapeFtsQuery(query, false)})`;
239
+ }
240
+ const fts = `_emdash_fts_${meta.slug}`;
241
+ const weights = meta.weights && meta.searchable.length ? ["0", "0", ...meta.searchable.map((f) => String(meta.weights?.[f] ?? 1))].join(", ") : "";
242
+ const bm25 = weights ? `bm25("${fts}", ${weights})` : `bm25("${fts}")`;
243
+ const snippet = scope === "any" ? `, snippet("${fts}", 2, '<mark>', '</mark>', '...', 32) AS snippet` : "";
244
+ const binds: unknown[] = [match];
245
+ let where = `"${fts}" MATCH ? AND c.status = 'published' AND c.deleted_at IS NULL`;
246
+ if (options.locale) {
247
+ where += " AND c.locale = ? COLLATE NOCASE";
248
+ binds.push(options.locale);
249
+ }
250
+ binds.push(options.limit);
251
+ return {
252
+ sql: `SELECT c.id AS id, c.slug AS slug, c.locale AS locale, c.published_at AS published_at, ${titleExpression(meta)} AS title${snippet}, ${bm25} AS score FROM "${fts}" f JOIN "ec_${meta.slug}" c ON f.id = c.id WHERE ${where} ORDER BY score LIMIT ?`,
253
+ binds,
254
+ };
255
+ }
256
+
257
+ /** The excerpt source columns for a set of entries (searchable fields other than the title). */
258
+ export function excerptQuery(meta: CollectionMeta, ids: string[]): { query: SqlQuery; columns: Array<{ field: string; type: string }> } | null {
259
+ const titleField = meta.titleField || "title";
260
+ const columns = meta.excerptFields.filter((f) => f.field !== titleField && IDENT.test(f.field));
261
+ if (!columns.length || !ids.length || !COLLECTION_SLUG.test(meta.slug)) return null;
262
+ const select = columns.map((c, i) => `c."${c.field}" AS f${i}`).join(", ");
263
+ return { query: { sql: `SELECT c.id AS id, ${select} FROM "ec_${meta.slug}" c WHERE c.id IN (${ids.map(() => "?").join(",")})`, binds: ids }, columns };
264
+ }
265
+
266
+ // ── Running ──────────────────────────────────────────────────────
267
+
268
+ /** One matched entry. */
269
+ export interface MatchRow {
270
+ collection: string;
271
+ id: string;
272
+ slug: string | null;
273
+ locale: string | null;
274
+ publishedAt: string | null;
275
+ title?: string;
276
+ snippet?: string;
277
+ score: number;
278
+ }
279
+
280
+ interface RawMatch {
281
+ id: string;
282
+ slug: string | null;
283
+ locale: string | null;
284
+ published_at: string | null;
285
+ title: string | null;
286
+ snippet?: string | null;
287
+ score: number;
288
+ }
289
+
290
+ const FTS_SYNTAX = /fts5: syntax error|unknown special query/i;
291
+
292
+ /**
293
+ * Run statements as one D1 batch (one round trip). A failing statement fails
294
+ * a batch, so on error each runs alone and a failure there (an FTS syntax
295
+ * error from the visitor's own operators) reads as no rows.
296
+ */
297
+ export async function runBatch<T = Record<string, unknown>>(db: D1Database, queries: SqlQuery[]): Promise<T[][]> {
298
+ if (!queries.length) return [];
299
+ const statements = queries.map((q) => db.prepare(q.sql).bind(...q.binds));
300
+ try {
301
+ return (await db.batch<T>(statements)).map((r) => r.results ?? []);
302
+ } catch {
303
+ return Promise.all(
304
+ statements.map((s) =>
305
+ s
306
+ .all<T>()
307
+ .then((r) => r.results)
308
+ .catch((error: unknown) => {
309
+ if (!(error instanceof Error && FTS_SYNTAX.test(error.message))) console.error("coywolf-pack search: query failed", error);
310
+ return [] as T[];
311
+ }),
312
+ ),
313
+ );
314
+ }
315
+ }
316
+
317
+ // EmDash's snippet() markers are escaped the way its sanitizeSnippet does, so rankByCoverage sees the same text.
318
+ const sanitizeSnippet = (snippet: string) =>
319
+ snippet.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;").replaceAll("&lt;mark&gt;", "<mark>").replaceAll("&lt;/mark&gt;", "</mark>");
320
+
321
+ /** Merge per-collection rows the way EmDash's search does: by score (|bm25|), highest first. */
322
+ export function mergeMatches(perCollection: Array<{ collection: string; rows: RawMatch[] }>, limit: number): MatchRow[] {
323
+ const all: MatchRow[] = [];
324
+ for (const { collection, rows } of perCollection) {
325
+ for (const r of rows) {
326
+ all.push({
327
+ collection,
328
+ id: String(r.id),
329
+ slug: r.slug,
330
+ locale: r.locale,
331
+ publishedAt: r.published_at ?? null,
332
+ title: r.title ?? undefined,
333
+ snippet: r.snippet == null ? undefined : sanitizeSnippet(r.snippet),
334
+ score: Math.abs(Number(r.score) || 0),
335
+ });
336
+ }
337
+ }
338
+ return all.sort((a, b) => b.score - a.score).slice(0, limit);
339
+ }
340
+
341
+ /** How many OR results to rank in one go (as in searchWithFallback). */
342
+ const FALLBACK_POOL = 50;
343
+ /** Characters of indexed text read per entry for its excerpt. */
344
+ const SNIPPET_SOURCE_CHARS = 20_000;
345
+
346
+ /** One live result: everything the dropdown shows, already escaped where it's HTML. */
347
+ export interface LiveSearchResult {
348
+ id: string;
349
+ collection: string;
350
+ title: string;
351
+ /** The title, escaped, with the visitor's words in <mark> (the only tag). */
352
+ titleHtml: string;
353
+ url: string;
354
+ type: string;
355
+ /** An excerpt around the first match, escaped, with matches in <mark> and "…" where it was cut. */
356
+ snippet: string;
357
+ }
358
+
359
+ export interface LiveSearchResponse {
360
+ items: LiveSearchResult[];
361
+ /** True when nothing matched every word, so these results match any word. */
362
+ fallback: boolean;
363
+ }
364
+
365
+ export interface LiveEngineOptions {
366
+ collections?: string[];
367
+ locale?: string;
368
+ limit?: number;
369
+ /** Cache key for this database's metadata (its binding name). */
370
+ database?: string;
371
+ /** The search content version: metadata reloads when it changes. */
372
+ version?: string;
373
+ }
374
+
375
+ /** Search-enabled collections to search: the requested ones (that exist), else all. */
376
+ export function searchTargets(meta: SearchMeta, collections?: string[]): CollectionMeta[] {
377
+ const slugs = collections ? [...new Set(collections)].filter((c) => COLLECTION_SLUG.test(c)).slice(0, 10) : [...meta.collections.keys()];
378
+ return slugs.map((s) => meta.collections.get(s)).filter((m): m is CollectionMeta => !!m && m.searchEnabled);
379
+ }
380
+
381
+ /**
382
+ * Live results: title matches first, then full-text matches (with the
383
+ * any-word fallback) to fill the list, each with a resolved URL, a type
384
+ * label and an excerpt. Published entries only.
385
+ */
386
+ export async function liveSearchD1(db: D1Database, query: string, options: LiveEngineOptions = {}): Promise<LiveSearchResponse> {
387
+ const limit = Math.min(Math.max(options.limit ?? 8, 1), 20);
388
+ if (options.collections && !options.collections.length) return { items: [], fallback: false };
389
+ const meta = await loadSearchMeta(db, options.database, options.version);
390
+ const targets = searchTargets(meta, options.collections);
391
+ if (!targets.length) return { items: [], fallback: false };
392
+ const locale = options.locale || undefined;
393
+
394
+ // Titles and every-word matches together: one round trip.
395
+ const firstRound: Array<{ collection: string; scope: SearchScope; query: SqlQuery }> = [];
396
+ for (const t of targets) {
397
+ for (const scope of ["title", "all"] as const) {
398
+ const q = matchQuery(t, query, scope, { locale, limit });
399
+ if (q) firstRound.push({ collection: t.slug, scope, query: q });
400
+ }
401
+ }
402
+ const firstRows = await runBatch<RawMatch>(db, firstRound.map((r) => r.query));
403
+ const pick = (scope: SearchScope) => firstRound.map((r, i) => ({ r, rows: firstRows[i] ?? [] })).filter(({ r }) => r.scope === scope).map(({ r, rows }) => ({ collection: r.collection, rows }));
404
+ const titles = mergeMatches(pick("title"), limit);
405
+ const all = mergeMatches(pick("all"), limit);
406
+
407
+ const merged: MatchRow[] = [...titles];
408
+ const seen = new Set(merged.map((m) => `${m.collection}:${m.id}`));
409
+ let fallback = false;
410
+ const fill = (rows: MatchRow[]) => {
411
+ for (const row of rows) {
412
+ if (merged.length >= limit) break;
413
+ const key = `${row.collection}:${row.id}`;
414
+ if (seen.has(key)) continue;
415
+ seen.add(key);
416
+ merged.push(row);
417
+ }
418
+ };
419
+ if (merged.length < limit) {
420
+ if (all.length) fill(all);
421
+ else {
422
+ const orQuery = buildOrQuery(query);
423
+ if (orQuery) {
424
+ const round = targets.map((t) => ({ collection: t.slug, query: matchQuery(t, orQuery, "any", { locale, limit: FALLBACK_POOL }) })).filter((r): r is { collection: string; query: SqlQuery } => !!r.query);
425
+ const rows = await runBatch<RawMatch>(db, round.map((r) => r.query));
426
+ const any = mergeMatches(round.map((r, i) => ({ collection: r.collection, rows: rows[i] ?? [] })), FALLBACK_POOL);
427
+ const ranked = rankByCoverage(any, query).slice(0, limit);
428
+ fill(ranked);
429
+ fallback = ranked.length > 0 && titles.length === 0;
430
+ }
431
+ }
432
+ }
433
+ if (!merged.length) return { items: [], fallback: false };
434
+
435
+ const { texts, urls } = await resolveResults(db, meta, merged);
436
+ const words = highlightWords(query);
437
+ return {
438
+ fallback,
439
+ items: merged.map((item) => {
440
+ const info = meta.collections.get(item.collection);
441
+ const title = item.title || item.slug || item.id;
442
+ const key = `${item.collection}:${item.id}`;
443
+ return {
444
+ id: item.id,
445
+ collection: item.collection,
446
+ title,
447
+ titleHtml: highlightHtml(title, words),
448
+ url: urls.get(key) ?? interpolateUrlPattern({ pattern: null, collection: item.collection, slug: item.slug ?? item.id, id: item.id }),
449
+ type: info?.labelSingular || info?.label || item.collection,
450
+ snippet: buildSnippet(texts.get(key) ?? "", words),
451
+ };
452
+ }),
453
+ };
454
+ }
455
+
456
+ /** Excerpt text and URL terms for the results in one round trip, then their URLs (no further queries). */
457
+ async function resolveResults(db: D1Database, meta: SearchMeta, rows: MatchRow[]): Promise<{ texts: Map<string, string>; urls: Map<string, string | null> }> {
458
+ const slugs = [...new Set(rows.map((r) => r.collection))];
459
+ const defaultLocale = await hostDefaultLocale();
460
+ const batch: Array<{ kind: "text"; slug: string; columns: Array<{ field: string; type: string }> } | { kind: "terms"; slug: string }> = [];
461
+ const queries: SqlQuery[] = [];
462
+ for (const slug of slugs) {
463
+ const info = meta.collections.get(slug);
464
+ if (!info) continue;
465
+ const ids = rows.filter((r) => r.collection === slug).map((r) => r.id);
466
+ const excerpt = excerptQuery(info, ids);
467
+ if (excerpt) {
468
+ batch.push({ kind: "text", slug, columns: excerpt.columns });
469
+ queries.push(excerpt.query);
470
+ }
471
+ const taxonomies = patternTaxonomies(urlOverride(slug));
472
+ if (taxonomies.length) {
473
+ batch.push({ kind: "terms", slug });
474
+ queries.push(primaryTermsQuery(slug, ids, taxonomies, defaultLocale));
475
+ }
476
+ }
477
+ const results = await runBatch(db, queries);
478
+
479
+ const texts = new Map<string, string>();
480
+ const terms = new Map<string, Map<string, Record<string, string>>>();
481
+ batch.forEach((b, i) => {
482
+ const found = results[i] ?? [];
483
+ if (b.kind === "terms") {
484
+ terms.set(b.slug, collectPrimaryTerms(found as unknown as PrimaryTermRow[]));
485
+ return;
486
+ }
487
+ for (const row of found) {
488
+ const text = b.columns
489
+ .map((c, j) => {
490
+ const value = row[`f${j}`];
491
+ if (c.type === "portableText") return portableTextProse(value, SNIPPET_SOURCE_CHARS);
492
+ return typeof value === "string" ? value.slice(0, SNIPPET_SOURCE_CHARS) : "";
493
+ })
494
+ .filter(Boolean)
495
+ .join(" ");
496
+ if (text) texts.set(`${b.slug}:${String(row.id)}`, text);
497
+ }
498
+ });
499
+
500
+ const urls = await entryUrlsFromMeta(db, meta, slugs, (slug) =>
501
+ rows
502
+ .filter((r) => r.collection === slug)
503
+ .map((r) => ({ id: r.id, slug: r.slug ?? r.id, locale: r.locale, publishedAt: r.publishedAt, terms: terms.has(slug) ? (terms.get(slug)?.get(r.id) ?? {}) : undefined })),
504
+ );
505
+ return { texts, urls };
506
+ }
507
+
508
+ /** URLs from the cached metadata (collection patterns, term parents) and entries that carry their dates and terms. */
509
+ export async function entryUrlsFromMeta(
510
+ db: D1Database,
511
+ meta: SearchMeta,
512
+ slugs: string[],
513
+ entriesOf: (slug: string) => Array<{ id: string; slug: string; locale?: string | null; publishedAt?: string | null; terms?: Record<string, string> }>,
514
+ ): Promise<Map<string, string | null>> {
515
+ const resolver = createEntryUrlResolver(db, {
516
+ collections: meta.collections,
517
+ termParents: async (taxonomies) => {
518
+ const out = new Map<string, Map<string, string>>();
519
+ for (const t of taxonomies) out.set(t, meta.termParents.get(t) ?? new Map());
520
+ return out;
521
+ },
522
+ });
523
+ const urls = new Map<string, string | null>();
524
+ for (const slug of slugs) {
525
+ try {
526
+ for (const [id, url] of await resolver.urls(slug, entriesOf(slug))) urls.set(`${slug}:${id}`, url);
527
+ } catch {
528
+ // Default routes below rather than fail the search.
529
+ }
530
+ }
531
+ return urls;
532
+ }
533
+
534
+ // ── Query counting (Server-Timing, tests) ────────────────────────
535
+
536
+ export interface QueryStats {
537
+ /** SQL statements run. */
538
+ statements: number;
539
+ /** Requests to D1 (a batch is one). */
540
+ roundTrips: number;
541
+ }
542
+
543
+ /** Wrap a D1 binding to count statements and round trips into `stats`. */
544
+ export function countingD1(db: D1Database, stats: QueryStats): D1Database {
545
+ const inner = new WeakMap<object, D1PreparedStatement>();
546
+ const wrap = (stmt: D1PreparedStatement): D1PreparedStatement => {
547
+ const proxy = new Proxy(stmt, {
548
+ get(target, prop) {
549
+ if (prop === "bind") return (...args: unknown[]) => wrap(target.bind(...args));
550
+ if (prop === "all" || prop === "first" || prop === "run" || prop === "raw") {
551
+ return (...args: unknown[]) => {
552
+ stats.statements++;
553
+ stats.roundTrips++;
554
+ return (target as unknown as Record<string, (...a: unknown[]) => unknown>)[prop](...args);
555
+ };
556
+ }
557
+ const value = Reflect.get(target, prop, target);
558
+ return typeof value === "function" ? value.bind(target) : value;
559
+ },
560
+ });
561
+ inner.set(proxy, stmt);
562
+ return proxy;
563
+ };
564
+ return new Proxy(db, {
565
+ get(target, prop) {
566
+ if (prop === "prepare") return (sql: string) => wrap(target.prepare(sql));
567
+ if (prop === "batch") {
568
+ return (statements: D1PreparedStatement[]) => {
569
+ stats.roundTrips++;
570
+ stats.statements += statements.length;
571
+ return target.batch(statements.map((s) => inner.get(s) ?? s));
572
+ };
573
+ }
574
+ const value = Reflect.get(target, prop, target);
575
+ return typeof value === "function" ? value.bind(target) : value;
576
+ },
577
+ });
578
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * OR fallback for EmDash search. EmDash matches every word (AND, with prefix
3
+ * matching); when that finds nothing, we retry with any word (OR) and rank
4
+ * the results by how many of the words they contain, then by BM25.
5
+ *
6
+ * The OR query is handed to EmDash's own search, whose query escaping passes
7
+ * strings containing FTS5 operators through with double quotes escaped. So
8
+ * the terms here are bare words (letters and digits only), never quoted,
9
+ * which keeps the expression valid FTS5 whatever the visitor typed.
10
+ *
11
+ * Pure functions (no imports) so they can be unit tested in Node.
12
+ */
13
+
14
+ /** Most words an OR query may contain (query cost cap, as in Coywolf Search). */
15
+ export const MAX_OR_TERMS = 16;
16
+
17
+ /** Common English words dropped from OR queries; matching any of them would match nearly everything. */
18
+ const STOPWORDS = new Set(
19
+ (
20
+ "a an and are as at be but by for from has have how i if in into is it its of on or that the their them then there these they this " +
21
+ "to was we were what when where which who why will with you your"
22
+ ).split(" "),
23
+ );
24
+
25
+ /** FTS5 operators are only operators in upper case; a visitor who typed them wrote their own boolean query. */
26
+ const EXPLICIT_OPERATORS = /\b(AND|OR|NOT|NEAR)\b/;
27
+
28
+ /** Split a query into lower-case word tokens (letters and digits), de-duplicated, in order. */
29
+ export function queryTerms(query: string): string[] {
30
+ const seen = new Set<string>();
31
+ for (const raw of query.toLowerCase().split(/[^\p{L}\p{N}]+/u)) {
32
+ if (raw) seen.add(raw);
33
+ }
34
+ return [...seen];
35
+ }
36
+
37
+ /**
38
+ * Build the query to try when the AND search found nothing: any of the
39
+ * words, without stopwords. Null when a fallback can't find anything new
40
+ * (a one-word search, or the visitor already wrote operators).
41
+ */
42
+ export function buildOrQuery(query: string, maxTerms = MAX_OR_TERMS): string | null {
43
+ const trimmed = query.trim();
44
+ if (!trimmed || EXPLICIT_OPERATORS.test(trimmed)) return null;
45
+ const all = queryTerms(trimmed);
46
+ if (all.length < 2) return null;
47
+ // One-letter Latin words match too much as prefixes; keep digits and single CJK characters.
48
+ let terms = all.filter((t) => t.length > 1 || /[\d\u0080-\uffff]/.test(t));
49
+ const meaningful = terms.filter((t) => !STOPWORDS.has(t));
50
+ if (meaningful.length > 0) terms = meaningful;
51
+ terms = terms.slice(0, maxTerms);
52
+ if (terms.length === 0) return null;
53
+ // A single word goes through EmDash's normal escaping (quoted, prefix-matched).
54
+ if (terms.length === 1) return terms[0];
55
+ return terms.map((t) => `${t}*`).join(" OR ");
56
+ }
57
+
58
+ interface Rankable {
59
+ title?: string;
60
+ snippet?: string;
61
+ score: number;
62
+ }
63
+
64
+ /** How many of the query's terms appear (as a word prefix) in the result's title and snippet. */
65
+ export function coverage(result: Rankable, terms: string[]): number {
66
+ const text = `${result.title ?? ""} ${(result.snippet ?? "").replace(/<\/?mark>/g, "")}`.toLowerCase();
67
+ const words = text.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
68
+ let n = 0;
69
+ for (const term of terms) if (words.some((w) => w.startsWith(term))) n++;
70
+ return n;
71
+ }
72
+
73
+ /** Order OR results by coverage (more of the visitor's words first), then by BM25 score. */
74
+ export function rankByCoverage<T extends Rankable>(results: T[], query: string): T[] {
75
+ const terms = queryTerms(query);
76
+ return results
77
+ .map((result, index) => ({ result, index, covered: coverage(result, terms) }))
78
+ .sort((a, b) => b.covered - a.covered || b.result.score - a.result.score || a.index - b.index)
79
+ .map((r) => r.result);
80
+ }