@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.
- package/LICENSE +21 -0
- package/README.md +201 -2
- package/package.json +70 -5
- package/src/admin/admin-css.ts +57 -0
- package/src/admin/ai.tsx +915 -0
- package/src/admin/backups.tsx +613 -0
- package/src/admin/breadcrumbs.tsx +542 -0
- package/src/admin/codeBlocks.tsx +204 -0
- package/src/admin/customBlocks.tsx +321 -0
- package/src/admin/dirty.ts +20 -0
- package/src/admin/discovery.tsx +463 -0
- package/src/admin/download.ts +28 -0
- package/src/admin/feature-switches.tsx +124 -0
- package/src/admin/features.tsx +142 -0
- package/src/admin/files.tsx +791 -0
- package/src/admin/formUploads.tsx +445 -0
- package/src/admin/guides.tsx +18 -0
- package/src/admin/headings.tsx +262 -0
- package/src/admin/images.tsx +825 -0
- package/src/admin/index.tsx +62 -0
- package/src/admin/links.tsx +980 -0
- package/src/admin/performance.tsx +330 -0
- package/src/admin/preview.tsx +63 -0
- package/src/admin/redirects-removed.tsx +266 -0
- package/src/admin/redirects.tsx +470 -0
- package/src/admin/reviews.tsx +232 -0
- package/src/admin/robots-bots.tsx +647 -0
- package/src/admin/robots-shared.tsx +109 -0
- package/src/admin/robots-wizard.tsx +748 -0
- package/src/admin/robots.tsx +911 -0
- package/src/admin/save-bar.tsx +134 -0
- package/src/admin/schema.tsx +1187 -0
- package/src/admin/search.tsx +355 -0
- package/src/admin/settings-ui.tsx +87 -0
- package/src/admin/trash-prompt-core.ts +135 -0
- package/src/admin/trash-prompt.tsx +156 -0
- package/src/admin/usage.tsx +301 -0
- package/src/admin/videos-display.tsx +248 -0
- package/src/admin/videos.tsx +1202 -0
- package/src/admin/wpImport.tsx +1516 -0
- package/src/ai/entities.ts +59 -0
- package/src/ai/export.ts +61 -0
- package/src/ai/logic.test.ts +187 -0
- package/src/ai/logic.ts +606 -0
- package/src/ai/pack.ts +121 -0
- package/src/ai/prompts.ts +87 -0
- package/src/ai/providers.ts +276 -0
- package/src/ai/queue.test.ts +97 -0
- package/src/ai/routes.ts +359 -0
- package/src/ai/store.ts +301 -0
- package/src/ai/wikidata.test.ts +184 -0
- package/src/ai/wikidata.ts +87 -0
- package/src/ai/worker.ts +530 -0
- package/src/astro/astro-modules.d.ts +6 -0
- package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
- package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
- package/src/astro/codeBlocks/CodeBlock.astro +62 -0
- package/src/astro/codeBlocks/EmDashCode.astro +61 -0
- package/src/astro/customBlocks/CustomBlock.astro +64 -0
- package/src/astro/customBlocks/Details.astro +8 -0
- package/src/astro/customBlocks/Disclosure.astro +8 -0
- package/src/astro/customBlocks/Note.astro +8 -0
- package/src/astro/customBlocks/Podcast.astro +8 -0
- package/src/astro/customBlocks/Quote.astro +8 -0
- package/src/astro/customBlocks/Testimonial.astro +8 -0
- package/src/astro/files/FileCard.astro +103 -0
- package/src/astro/headings/HeadingBlock.astro +129 -0
- package/src/astro/headings/TableOfContents.astro +45 -0
- package/src/astro/headings/request.ts +19 -0
- package/src/astro/images/ResponsiveImage.astro +60 -0
- package/src/astro/index.ts +84 -0
- package/src/astro/reviews/CoywolfReview.astro +27 -0
- package/src/astro/reviews/Review.astro +49 -0
- package/src/astro/search/SearchBox.astro +61 -0
- package/src/astro/search/SearchBoxForm.astro +509 -0
- package/src/astro/tables/CaptionedTable.astro +72 -0
- package/src/astro/videos/CoywolfVideo.astro +448 -0
- package/src/backups/download.ts +112 -0
- package/src/backups/dump.test.mjs +287 -0
- package/src/backups/dump.ts +288 -0
- package/src/backups/module.ts +276 -0
- package/src/backups/pack.ts +33 -0
- package/src/backups/restore.test.mjs +103 -0
- package/src/backups/restore.ts +209 -0
- package/src/backups/store.ts +409 -0
- package/src/breadcrumbs/pack.ts +113 -0
- package/src/breadcrumbs/settings.ts +102 -0
- package/src/breadcrumbs/stamp.ts +40 -0
- package/src/breadcrumbs/trail.ts +123 -0
- package/src/codeBlocks/contrast.ts +179 -0
- package/src/codeBlocks/hljs.d.ts +5 -0
- package/src/codeBlocks/pack.ts +110 -0
- package/src/codeBlocks/render.test.ts +152 -0
- package/src/codeBlocks/render.ts +304 -0
- package/src/codeBlocks/settings.ts +18 -0
- package/src/codeBlocks/themes.generated.ts +304 -0
- package/src/codeBlocks/themes.ts +114 -0
- package/src/core/compose.ts +244 -0
- package/src/core/content-url.ts +888 -0
- package/src/core/csv.ts +119 -0
- package/src/core/d1-batch.ts +65 -0
- package/src/core/entry-docs.ts +175 -0
- package/src/core/features-module.ts +48 -0
- package/src/core/features.ts +392 -0
- package/src/core/generation.ts +88 -0
- package/src/core/guide-content.ts +166 -0
- package/src/core/module.ts +104 -0
- package/src/core/read-failures.ts +57 -0
- package/src/core/request-memo.ts +71 -0
- package/src/core/secrets.ts +83 -0
- package/src/core/settings-enhance.ts +74 -0
- package/src/core/site.ts +36 -0
- package/src/core/storage.ts +27 -0
- package/src/customBlocks/pack.ts +260 -0
- package/src/customBlocks/render.ts +424 -0
- package/src/customBlocks/rich.ts +313 -0
- package/src/customBlocks/settings.ts +46 -0
- package/src/discovery/build.ts +252 -0
- package/src/discovery/feed-build.ts +217 -0
- package/src/discovery/feed.ts +172 -0
- package/src/discovery/indexnow.ts +114 -0
- package/src/discovery/llms.ts +79 -0
- package/src/discovery/markdown.ts +294 -0
- package/src/discovery/memory-cache.ts +97 -0
- package/src/discovery/middleware.ts +186 -0
- package/src/discovery/module.ts +353 -0
- package/src/discovery/news.ts +99 -0
- package/src/discovery/pack.ts +58 -0
- package/src/discovery/settings.ts +91 -0
- package/src/discovery/urls.ts +35 -0
- package/src/env.d.ts +10 -0
- package/src/files/card.ts +231 -0
- package/src/files/counts.test.ts +114 -0
- package/src/files/counts.ts +60 -0
- package/src/files/export.ts +44 -0
- package/src/files/format.test.ts +93 -0
- package/src/files/format.ts +163 -0
- package/src/files/module.ts +596 -0
- package/src/files/pack.ts +92 -0
- package/src/files/r2-rules.test.ts +30 -0
- package/src/files/r2-rules.ts +41 -0
- package/src/files/range.ts +50 -0
- package/src/files/s3.ts +114 -0
- package/src/files/serve.ts +141 -0
- package/src/files/sigv4.test.ts +64 -0
- package/src/files/sigv4.ts +160 -0
- package/src/files/site.ts +137 -0
- package/src/files/walker.ts +58 -0
- package/src/formUploads/lib.ts +266 -0
- package/src/formUploads/pack.ts +55 -0
- package/src/formUploads/wrap.ts +592 -0
- package/src/forms.ts +16 -0
- package/src/headings/headings.test.ts +159 -0
- package/src/headings/pack.ts +154 -0
- package/src/headings/render.ts +139 -0
- package/src/headings/settings.ts +117 -0
- package/src/headings/slug.ts +68 -0
- package/src/headings/stamp.ts +142 -0
- package/src/headings/test-hooks.mjs +14 -0
- package/src/headings/toc.ts +48 -0
- package/src/images/cloudflare.ts +329 -0
- package/src/images/lib.ts +221 -0
- package/src/images/module.ts +372 -0
- package/src/images/pack.ts +804 -0
- package/src/images/pending.ts +37 -0
- package/src/images/settings.ts +48 -0
- package/src/images/setup.ts +192 -0
- package/src/images/variants-estimate.ts +162 -0
- package/src/images/variants-store.ts +475 -0
- package/src/images/variants.ts +311 -0
- package/src/index.ts +143 -0
- package/src/links/check.ts +101 -0
- package/src/links/classify.ts +270 -0
- package/src/links/export.ts +59 -0
- package/src/links/module.ts +652 -0
- package/src/links/pack.ts +43 -0
- package/src/links/pt.ts +325 -0
- package/src/links/store.ts +704 -0
- package/src/middleware.ts +287 -0
- package/src/modules.ts +53 -0
- package/src/options.ts +66 -0
- package/src/pageCache/lib.test.mjs +224 -0
- package/src/pageCache/lib.ts +274 -0
- package/src/pageCache/pack.ts +240 -0
- package/src/pageCache/usage-core.ts +152 -0
- package/src/pageCache/usage.ts +262 -0
- package/src/pageCache/warm.test.mjs +670 -0
- package/src/pageCache/warm.ts +722 -0
- package/src/redirects/middleware.ts +169 -0
- package/src/redirects/module.ts +124 -0
- package/src/redirects/pack.ts +89 -0
- package/src/redirects/removed-core.ts +151 -0
- package/src/redirects/removed.ts +293 -0
- package/src/redirects/rules.ts +355 -0
- package/src/redirects/slug-changes.ts +207 -0
- package/src/redirects/slug-core.test.ts +50 -0
- package/src/redirects/slug-core.ts +49 -0
- package/src/redirects/transfer.ts +92 -0
- package/src/reviews/lib.ts +624 -0
- package/src/reviews/pack.ts +135 -0
- package/src/reviews/schema.ts +128 -0
- package/src/reviews/settings.ts +26 -0
- package/src/robots/bots.ts +321 -0
- package/src/robots/data/bots.json +696 -0
- package/src/robots/data/verified.json +582 -0
- package/src/robots/directory.ts +26 -0
- package/src/robots/explain.ts +191 -0
- package/src/robots/guided.ts +300 -0
- package/src/robots/importer.ts +263 -0
- package/src/robots/middleware.ts +151 -0
- package/src/robots/module.ts +446 -0
- package/src/robots/pack.ts +69 -0
- package/src/robots/radar.ts +129 -0
- package/src/robots/rep.test.ts +335 -0
- package/src/robots/rep.ts +362 -0
- package/src/robots/rules.test.ts +251 -0
- package/src/robots/rules.ts +883 -0
- package/src/robots/transfer.ts +45 -0
- package/src/robots/validate.ts +524 -0
- package/src/schema/catalog.ts +146 -0
- package/src/schema/graph.test.mjs +522 -0
- package/src/schema/graph.ts +791 -0
- package/src/schema/module.ts +1138 -0
- package/src/schema/og-image.ts +100 -0
- package/src/schema/pack.ts +58 -0
- package/src/search/engine.ts +578 -0
- package/src/search/fallback.ts +80 -0
- package/src/search/live-cache.ts +116 -0
- package/src/search/live-client.ts +770 -0
- package/src/search/live-serve.ts +282 -0
- package/src/search/module.ts +261 -0
- package/src/search/pack.ts +57 -0
- package/src/search/query.ts +132 -0
- package/src/search/ratelimit-core.ts +118 -0
- package/src/search/ratelimit.ts +79 -0
- package/src/search/snippet.ts +135 -0
- package/src/search/title-index.ts +139 -0
- package/src/search/title-match.ts +192 -0
- package/src/shared.ts +71 -0
- package/src/version.ts +2 -0
- package/src/videos/date.ts +40 -0
- package/src/videos/engagement.ts +75 -0
- package/src/videos/lib.ts +679 -0
- package/src/videos/module.ts +1160 -0
- package/src/videos/pack.ts +187 -0
- package/src/videos/pause.ts +42 -0
- package/src/videos/poster.ts +324 -0
- package/src/videos/render.ts +336 -0
- package/src/videos/store.ts +551 -0
- package/src/videos/stream.ts +134 -0
- package/src/wpImport/convert.ts +702 -0
- package/src/wpImport/guests.ts +329 -0
- package/src/wpImport/gutenberg.ts +157 -0
- package/src/wpImport/markers.ts +66 -0
- package/src/wpImport/pack.ts +451 -0
- package/src/wpImport/parents.ts +202 -0
- package/src/wpImport/prepare.ts +592 -0
- package/src/wpImport/redirects.ts +358 -0
- package/src/wpImport/sources.ts +67 -0
- package/src/wpImport/stream.ts +160 -0
- package/src/wpImport/urls.ts +354 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live results and the title index, served by the pack middleware (feature
|
|
3
|
+
* "search.live") ahead of EmDash's plugin route dispatch, with Cloudflare's
|
|
4
|
+
* Cache API in front.
|
|
5
|
+
*
|
|
6
|
+
* Worker responses aren't cached by the CDN on their own, so every answer is
|
|
7
|
+
* stored in caches.default (per Cloudflare location) and in a small
|
|
8
|
+
* per-isolate memory cache, keyed by the normalized query, limit,
|
|
9
|
+
* collections, locale and the search content version. Publishing changes the
|
|
10
|
+
* version, so new keys miss and old answers simply expire. Cache hits skip
|
|
11
|
+
* the rate limit and the database entirely; misses count against
|
|
12
|
+
* search.rateLimit when it's on.
|
|
13
|
+
*
|
|
14
|
+
* The plugin routes (search/live, search/index) stay as the fallback for
|
|
15
|
+
* sites without the middleware; they run the same engine.
|
|
16
|
+
*/
|
|
17
|
+
import { contentUrlFingerprint } from "../core/content-url.js";
|
|
18
|
+
import { isOn, registerSiteSetting, rememberSiteSetting, siteFeatures, siteSetting } from "../core/features.js";
|
|
19
|
+
import type { PackMiddleware } from "../core/module.js";
|
|
20
|
+
import { keepAtEdge, keepOffEdge } from "../pageCache/lib.js";
|
|
21
|
+
import { type QueryStats, countingD1, liveSearchD1, loadSearchMeta } from "./engine.js";
|
|
22
|
+
import { Lru, effectiveVersion, fingerprint, indexCacheKey, liveCacheKey, newContentVersion, normalizeCollections, normalizeLiveQuery, normalizeLocale } from "./live-cache.js";
|
|
23
|
+
import { LIVE_ASSET, LIVE_ASSET_HASH, type LiveClientConfig, liveScript, liveScriptConfig } from "./live-client.js";
|
|
24
|
+
import { rateLimitResponse } from "./ratelimit.js";
|
|
25
|
+
import { TITLE_INDEX_MAX, buildTitleIndex } from "./title-index.js";
|
|
26
|
+
|
|
27
|
+
export const LIVE_ENDPOINT = "/_emdash/api/plugins/coywolf-pack/search/live";
|
|
28
|
+
export const INDEX_ENDPOINT = "/_emdash/api/plugins/coywolf-pack/search/index";
|
|
29
|
+
|
|
30
|
+
/** Plugin setting holding the search content version. */
|
|
31
|
+
export const VERSION_SETTING = "searchVersion";
|
|
32
|
+
registerSiteSetting(VERSION_SETTING);
|
|
33
|
+
|
|
34
|
+
/** Seconds a live answer is kept at the edge (the version in its key retires it sooner after a publish). */
|
|
35
|
+
export const LIVE_EDGE_TTL = 300;
|
|
36
|
+
/** Seconds browsers keep a live answer. */
|
|
37
|
+
export const LIVE_BROWSER_TTL = 60;
|
|
38
|
+
/** Seconds a title index is kept at the edge. */
|
|
39
|
+
const INDEX_EDGE_TTL = 86_400;
|
|
40
|
+
|
|
41
|
+
interface LiveServeConfig {
|
|
42
|
+
database: string;
|
|
43
|
+
limit: number;
|
|
44
|
+
indexMax: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let config: LiveServeConfig = { database: "DB", limit: 8, indexMax: TITLE_INDEX_MAX };
|
|
48
|
+
|
|
49
|
+
/** Set from coywolfPlugin({ search }) when the Worker starts (same pattern as the rate limit options). */
|
|
50
|
+
export function configureSearchLive(options: Partial<LiveServeConfig>): void {
|
|
51
|
+
config = { ...config, ...Object.fromEntries(Object.entries(options).filter(([, v]) => v !== undefined)) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function liveServeConfig(): LiveServeConfig {
|
|
55
|
+
return config;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The current search content version (from the per-isolate settings cache; no query on most requests). */
|
|
59
|
+
export async function searchVersion(database = config.database): Promise<string> {
|
|
60
|
+
return effectiveVersion(await siteSetting<string>(VERSION_SETTING, database), contentUrlFingerprint());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Give published content a new version (content hooks, search settings changes). */
|
|
64
|
+
export async function bumpSearchVersion(ctx: { settings: { set(key: string, value: unknown): Promise<void> } }): Promise<string> {
|
|
65
|
+
const version = newContentVersion();
|
|
66
|
+
await ctx.settings.set(VERSION_SETTING, version);
|
|
67
|
+
rememberSiteSetting(VERSION_SETTING, version);
|
|
68
|
+
return version;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let liveMemory = new Lru<string>(500);
|
|
72
|
+
let indexMemory = new Lru<string>(4);
|
|
73
|
+
|
|
74
|
+
/** Empty this isolate's memory caches (tests; the edge cache is untouched). */
|
|
75
|
+
export function resetLiveMemory(): void {
|
|
76
|
+
liveMemory = new Lru<string>(500);
|
|
77
|
+
indexMemory = new Lru<string>(4);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const JSON_TYPE = "application/json; charset=utf-8";
|
|
81
|
+
|
|
82
|
+
function edgeCache(): Cache | undefined {
|
|
83
|
+
return (globalThis as unknown as { caches?: { default?: Cache } }).caches?.default;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function timing(state: string, started: number, stats?: QueryStats): string {
|
|
87
|
+
const parts = [`cw-search;desc="${state}";dur=${Date.now() - started}`];
|
|
88
|
+
if (stats) parts.push(`cw-d1;desc="${stats.statements} statements, ${stats.roundTrips} round trips"`);
|
|
89
|
+
return parts.join(", ");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A fresh response with mutable headers (cached responses' headers are immutable, and later middleware may set some). */
|
|
93
|
+
function jsonResponse(body: string | null, status: number, headers: Record<string, string>): Response {
|
|
94
|
+
return new Response(body, { status, headers: new Headers({ "Content-Type": JSON_TYPE, "X-Content-Type-Options": "nosniff", ...headers }) });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function badRequest(message: string): Response {
|
|
98
|
+
return jsonResponse(JSON.stringify({ success: false, error: { code: "VALIDATION_ERROR", message } }), 400, { "Cache-Control": "no-store" });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Text of a cached answer: this isolate's memory, then the edge cache. */
|
|
102
|
+
async function cachedBody(memory: Lru<string>, key: string): Promise<{ body: string; where: string } | null> {
|
|
103
|
+
const inMemory = memory.get(key);
|
|
104
|
+
if (inMemory !== undefined) return { body: inMemory, where: "hit-memory" };
|
|
105
|
+
const cache = edgeCache();
|
|
106
|
+
const hit = cache ? await cache.match(key).catch(() => undefined) : undefined;
|
|
107
|
+
if (!hit) return null;
|
|
108
|
+
const body = await hit.text();
|
|
109
|
+
memory.set(key, body);
|
|
110
|
+
return { body, where: "hit-edge" };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function store(memory: Lru<string>, key: string, body: string, edgeTtl: number, waitUntil: (p: Promise<unknown>) => void): void {
|
|
114
|
+
memory.set(key, body);
|
|
115
|
+
const cache = edgeCache();
|
|
116
|
+
if (cache) waitUntil(cache.put(key, new Response(body, { headers: { "Content-Type": JSON_TYPE, "Cache-Control": `public, max-age=${edgeTtl}` } })).catch(() => undefined));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
type Context = Parameters<PackMiddleware["handle"]>[0];
|
|
120
|
+
|
|
121
|
+
async function rateLimited(context: Context, env: Record<string, unknown>): Promise<Response | undefined> {
|
|
122
|
+
return isOn(await siteFeatures(config.database), "search.rateLimit") ? rateLimitResponse(context, env) : undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function serveLive(context: Context, env: Record<string, unknown>, waitUntil: (p: Promise<unknown>) => void, db: D1Database): Promise<Response> {
|
|
126
|
+
const started = Date.now();
|
|
127
|
+
const params = context.url.searchParams;
|
|
128
|
+
const version = await searchVersion();
|
|
129
|
+
|
|
130
|
+
// Warm-up (first focus of a search field): wake this isolate and load the search metadata, nothing else.
|
|
131
|
+
if (params.get("warm") === "1") {
|
|
132
|
+
const stats: QueryStats = { statements: 0, roundTrips: 0 };
|
|
133
|
+
await loadSearchMeta(countingD1(db, stats), config.database, version).catch(() => undefined);
|
|
134
|
+
return new Response(null, { status: 204, headers: new Headers({ "Cache-Control": "no-store", "Server-Timing": timing("warm", started, stats) }) });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const query = normalizeLiveQuery(params.get("q") ?? "");
|
|
138
|
+
if (!query) return badRequest("Enter a search.");
|
|
139
|
+
if (query.length > 200) return badRequest("That search is too long.");
|
|
140
|
+
const rawLimit = params.get("limit");
|
|
141
|
+
const limit = rawLimit === null || rawLimit === "" ? config.limit : Number(rawLimit);
|
|
142
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 20) return badRequest("Limit must be a whole number from 1 to 20.");
|
|
143
|
+
const rawCollections = params.get("collections");
|
|
144
|
+
if (rawCollections && rawCollections.length > 500) return badRequest("Too many collections.");
|
|
145
|
+
const collections = normalizeCollections(rawCollections);
|
|
146
|
+
const locale = normalizeLocale(params.get("locale"));
|
|
147
|
+
|
|
148
|
+
const key = liveCacheKey(context.url.origin, { version, query, limit, collections, locale });
|
|
149
|
+
const browser = { "Cache-Control": `public, max-age=${LIVE_BROWSER_TTL}` };
|
|
150
|
+
const hit = await cachedBody(liveMemory, key);
|
|
151
|
+
if (hit) return jsonResponse(hit.body, 200, { ...browser, "Server-Timing": timing(hit.where, started), "X-Coywolf-Cache": "HIT" });
|
|
152
|
+
|
|
153
|
+
const limited = await rateLimited(context, env);
|
|
154
|
+
if (limited) return limited;
|
|
155
|
+
|
|
156
|
+
const stats: QueryStats = { statements: 0, roundTrips: 0 };
|
|
157
|
+
const data = await liveSearchD1(countingD1(db, stats), query, { collections, locale, limit, database: config.database, version });
|
|
158
|
+
const body = JSON.stringify({ success: true, data });
|
|
159
|
+
store(liveMemory, key, body, LIVE_EDGE_TTL, waitUntil);
|
|
160
|
+
return jsonResponse(body, 200, { ...browser, "Server-Timing": timing("miss", started, stats), "X-Coywolf-Cache": "MISS" });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function serveIndex(context: Context, env: Record<string, unknown>, waitUntil: (p: Promise<unknown>) => void, db: D1Database): Promise<Response> {
|
|
164
|
+
const started = Date.now();
|
|
165
|
+
const params = context.url.searchParams;
|
|
166
|
+
const version = await searchVersion();
|
|
167
|
+
const rawCollections = params.get("collections");
|
|
168
|
+
if (rawCollections && rawCollections.length > 500) return badRequest("Too many collections.");
|
|
169
|
+
const collections = normalizeCollections(rawCollections);
|
|
170
|
+
const locale = normalizeLocale(params.get("locale"));
|
|
171
|
+
const key = indexCacheKey(context.url.origin, { version, collections, locale });
|
|
172
|
+
|
|
173
|
+
// Pages carry the version in the index URL: a matching one never changes, so browsers keep it.
|
|
174
|
+
// A stale page (an old version) still gets the current index, just not for long.
|
|
175
|
+
const headers = {
|
|
176
|
+
"Cache-Control": params.get("v") === version ? "public, max-age=31536000, immutable" : "public, max-age=300",
|
|
177
|
+
ETag: `"${fingerprint(key)}"`,
|
|
178
|
+
};
|
|
179
|
+
if (context.request.headers.get("If-None-Match") === headers.ETag) return new Response(null, { status: 304, headers: new Headers(headers) });
|
|
180
|
+
|
|
181
|
+
const hit = await cachedBody(indexMemory, key);
|
|
182
|
+
if (hit) return jsonResponse(hit.body, 200, { ...headers, "Server-Timing": timing(hit.where, started), "X-Coywolf-Cache": "HIT" });
|
|
183
|
+
|
|
184
|
+
const limited = await rateLimited(context, env);
|
|
185
|
+
if (limited) return limited;
|
|
186
|
+
|
|
187
|
+
const stats: QueryStats = { statements: 0, roundTrips: 0 };
|
|
188
|
+
const counted = countingD1(db, stats);
|
|
189
|
+
const meta = await loadSearchMeta(counted, config.database, version);
|
|
190
|
+
const index = await buildTitleIndex(counted, meta, { collections, locale, version, max: config.indexMax });
|
|
191
|
+
const body = JSON.stringify(index);
|
|
192
|
+
store(indexMemory, key, body, INDEX_EDGE_TTL, waitUntil);
|
|
193
|
+
return jsonResponse(body, 200, { ...headers, "Server-Timing": timing("miss", started, stats), "X-Coywolf-Cache": "MISS" });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The live results client as a file. Only the pack middleware serves it: EmDash's
|
|
198
|
+
* raw plugin routes can't answer with a JavaScript content type or set
|
|
199
|
+
* Cache-Control, so sites without the middleware get the script inline instead
|
|
200
|
+
* (liveScriptFragment).
|
|
201
|
+
*/
|
|
202
|
+
export const ASSET_ENDPOINT = "/_emdash/api/plugins/coywolf-pack/search/live-client";
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Per isolate: the pack middleware has run here. It runs before every page
|
|
206
|
+
* render on a site that installed it, so by the time a page asks for its
|
|
207
|
+
* fragments this is set when (and only when) the middleware will answer
|
|
208
|
+
* ASSET_ENDPOINT. A site without it (or a dev setup with a second copy of the
|
|
209
|
+
* pack) never sets it and keeps the inline script.
|
|
210
|
+
*/
|
|
211
|
+
let middlewareActive = false;
|
|
212
|
+
|
|
213
|
+
/** Called by the pack middleware on each request it handles (false: tests). */
|
|
214
|
+
export function notePackMiddleware(active = true): void {
|
|
215
|
+
middlewareActive = active;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Whether pages can load the client file, i.e. the pack middleware serves it (see notePackMiddleware). */
|
|
219
|
+
export function packMiddlewareActive(): boolean {
|
|
220
|
+
return middlewareActive;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The page fragment for live results: the versioned client file when the pack
|
|
225
|
+
* middleware serves it, else the same client inline (as before the file existed).
|
|
226
|
+
*/
|
|
227
|
+
export function liveScriptFragment(config: LiveClientConfig, external = middlewareActive) {
|
|
228
|
+
return external
|
|
229
|
+
? {
|
|
230
|
+
kind: "external-script" as const,
|
|
231
|
+
placement: "body:end" as const,
|
|
232
|
+
key: "search-live",
|
|
233
|
+
src: liveAssetUrl(),
|
|
234
|
+
defer: true,
|
|
235
|
+
attributes: { "data-cw-live": liveScriptConfig(config) },
|
|
236
|
+
}
|
|
237
|
+
: { kind: "inline-script" as const, placement: "body:end" as const, key: "search-live", code: liveScript(config) };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Where pages load the client from: versioned, so it can be cached for a year. */
|
|
241
|
+
export function liveAssetUrl(): string {
|
|
242
|
+
return `${ASSET_ENDPOINT}?v=${LIVE_ASSET_HASH}`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Status, headers and body for the client file. Immutable for a year when the
|
|
247
|
+
* request names the current version; a few minutes otherwise (an old page's
|
|
248
|
+
* URL still gets today's client, but nothing pins it).
|
|
249
|
+
*/
|
|
250
|
+
export function liveAssetParts(version: string | null): { status: number; headers: Record<string, string>; body: string } {
|
|
251
|
+
return {
|
|
252
|
+
status: 200,
|
|
253
|
+
headers: {
|
|
254
|
+
"Content-Type": "text/javascript; charset=utf-8",
|
|
255
|
+
"X-Content-Type-Options": "nosniff",
|
|
256
|
+
"Cache-Control": version === LIVE_ASSET_HASH ? "public, max-age=31536000, immutable" : "public, max-age=300",
|
|
257
|
+
},
|
|
258
|
+
body: LIVE_ASSET,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export const searchLiveMiddleware: PackMiddleware = {
|
|
263
|
+
module: "search",
|
|
264
|
+
feature: "search.live",
|
|
265
|
+
// The client file goes to the edge cache (a year when the URL names this version); live results use their own cache.
|
|
266
|
+
ownsCache: true,
|
|
267
|
+
handle: async (context, env, waitUntil) => {
|
|
268
|
+
const path = context.url.pathname.length > 1 ? context.url.pathname.replace(/\/+$/, "") : context.url.pathname;
|
|
269
|
+
if (path === ASSET_ENDPOINT && (context.request.method === "GET" || context.request.method === "HEAD")) {
|
|
270
|
+
const version = context.url.searchParams.get("v");
|
|
271
|
+
const asset = liveAssetParts(version);
|
|
272
|
+
keepAtEdge(context, version === LIVE_ASSET_HASH ? 31536000 : 300);
|
|
273
|
+
return new Response(context.request.method === "HEAD" ? null : asset.body, { status: asset.status, headers: asset.headers });
|
|
274
|
+
}
|
|
275
|
+
if (path !== LIVE_ENDPOINT && path !== INDEX_ENDPOINT) return undefined;
|
|
276
|
+
keepOffEdge(context);
|
|
277
|
+
if (context.request.method !== "GET") return undefined;
|
|
278
|
+
const db = env[config.database] as D1Database | undefined;
|
|
279
|
+
if (!db) return undefined;
|
|
280
|
+
return path === LIVE_ENDPOINT ? serveLive(context, env, waitUntil, db) : serveIndex(context, env, waitUntil, db);
|
|
281
|
+
},
|
|
282
|
+
};
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search module routes.
|
|
3
|
+
*
|
|
4
|
+
* - search/query (public): EmDash search with an OR fallback, title-first
|
|
5
|
+
* suggestions, and resolved URLs.
|
|
6
|
+
* - search/live (public): live results for the as-you-type dropdown
|
|
7
|
+
* (feature "search.live"): title matches, then full text, each with a
|
|
8
|
+
* highlighted excerpt. Backs the live results script (injected on every
|
|
9
|
+
* page by the page:fragments hook below) and the SearchBox. With the pack
|
|
10
|
+
* middleware installed, live-serve.ts answers this path first, from the
|
|
11
|
+
* edge cache when it can; this route is the fallback.
|
|
12
|
+
* - search/index (public): the title index for instant results in the
|
|
13
|
+
* browser (title-index.ts), likewise served by the middleware first.
|
|
14
|
+
* - search/touch (admin): a new search content version, after search
|
|
15
|
+
* settings change (the Search admin page calls it), so cached answers and
|
|
16
|
+
* title indexes are rebuilt.
|
|
17
|
+
* - search/config (admin): each collection's search configuration and
|
|
18
|
+
* fields, read-only, for the Search admin page. The page changes settings
|
|
19
|
+
* through EmDash's own /_emdash/api/search/{enable,rebuild,stats}
|
|
20
|
+
* endpoints, so EmDash's permission checks (search:manage) apply.
|
|
21
|
+
*/
|
|
22
|
+
import { type PluginContext, PluginRouteError, definePluginRoute } from "emdash";
|
|
23
|
+
import { z } from "zod";
|
|
24
|
+
|
|
25
|
+
import { requireCachedFeature, requireFeature } from "../core/features.js";
|
|
26
|
+
import { COLLECTION_SLUG, readCollections } from "../core/content-url.js";
|
|
27
|
+
import { parseInput, workerEnv } from "../shared.js";
|
|
28
|
+
import { liveSearchD1, loadSearchMeta } from "./engine.js";
|
|
29
|
+
import { normalizeCollections, normalizeLiveQuery, normalizeLocale } from "./live-cache.js";
|
|
30
|
+
import { INDEX_ENDPOINT, LIVE_ENDPOINT, bumpSearchVersion, configureSearchLive, liveScriptFragment, searchVersion } from "./live-serve.js";
|
|
31
|
+
import { searchWithFallback } from "./query.js";
|
|
32
|
+
import { TITLE_INDEX_MAX, buildTitleIndex } from "./title-index.js";
|
|
33
|
+
|
|
34
|
+
export { INDEX_ENDPOINT, LIVE_ENDPOINT };
|
|
35
|
+
|
|
36
|
+
export interface SearchOptions {
|
|
37
|
+
/** D1 binding of the site database. Default "DB". */
|
|
38
|
+
database?: string;
|
|
39
|
+
/** Requests per minute per visitor for search.rateLimit's in-memory fallback. Default 120; 0 turns it off. */
|
|
40
|
+
requestsPerMinute?: number;
|
|
41
|
+
/** Workers Rate Limiting binding name for search.rateLimit. Default none (per-isolate memory only). */
|
|
42
|
+
rateLimiter?: string;
|
|
43
|
+
/** Live results dropdown (feature "search.live") on the site's search forms. */
|
|
44
|
+
live?: LiveOptions;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface LiveOptions {
|
|
48
|
+
/** Results in the dropdown. Default 8 (1–20). */
|
|
49
|
+
limit?: number;
|
|
50
|
+
/** Characters typed before results show. Default 2. */
|
|
51
|
+
minChars?: number;
|
|
52
|
+
/** Milliseconds to wait after the last keystroke before asking the server. Default 120 (title matches show at once). */
|
|
53
|
+
debounce?: number;
|
|
54
|
+
/** Instant title matches from a title index loaded in the browser on first focus. Default true. */
|
|
55
|
+
instant?: boolean;
|
|
56
|
+
/** Most entries in the title index (newest kept). Default 5000. */
|
|
57
|
+
indexMax?: number;
|
|
58
|
+
/** Select the first result as results appear, so Enter opens it (Enter otherwise submits the form). Default true. */
|
|
59
|
+
enterOpensTop?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const clampInt = (value: number | undefined, min: number, max: number, fallback: number) =>
|
|
63
|
+
typeof value === "number" && Number.isFinite(value) ? Math.min(max, Math.max(min, Math.round(value))) : fallback;
|
|
64
|
+
|
|
65
|
+
/** Most collections one search may name. */
|
|
66
|
+
const MAX_COLLECTIONS = 10;
|
|
67
|
+
|
|
68
|
+
const liveInput = z.object({
|
|
69
|
+
q: z.string().trim().min(1, "Enter a search.").max(200, "That search is too long."),
|
|
70
|
+
collections: z.string().max(500).optional(),
|
|
71
|
+
locale: z.string().max(35).optional(),
|
|
72
|
+
limit: z.coerce.number().int().min(1).max(20).optional(),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const indexInput = z.object({
|
|
76
|
+
collections: z.string().max(500).optional(),
|
|
77
|
+
locale: z.string().max(35).optional(),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const queryInput = z.object({
|
|
81
|
+
q: z.string().trim().min(1, "Enter a search.").max(200, "That search is too long."),
|
|
82
|
+
mode: z.enum(["search", "suggest"]).optional(),
|
|
83
|
+
collections: z.string().max(500).optional(),
|
|
84
|
+
locale: z.string().max(35).optional(),
|
|
85
|
+
limit: z.coerce.number().int().min(1).max(20).optional(),
|
|
86
|
+
cursor: z.string().max(500).optional(),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
interface CollectionRow {
|
|
90
|
+
id: string;
|
|
91
|
+
slug: string;
|
|
92
|
+
label: string;
|
|
93
|
+
search_config: string | null;
|
|
94
|
+
title_field: string | null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface FieldRow {
|
|
98
|
+
collection_id: string;
|
|
99
|
+
slug: string;
|
|
100
|
+
label: string;
|
|
101
|
+
type: string;
|
|
102
|
+
searchable: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function searchModule(options: SearchOptions) {
|
|
106
|
+
const database = options.database ?? "DB";
|
|
107
|
+
const live = {
|
|
108
|
+
endpoint: LIVE_ENDPOINT,
|
|
109
|
+
limit: clampInt(options.live?.limit, 1, 20, 8),
|
|
110
|
+
minChars: clampInt(options.live?.minChars, 1, 20, 2),
|
|
111
|
+
debounce: clampInt(options.live?.debounce, 0, 2000, 120),
|
|
112
|
+
enterOpensTop: options.live?.enterOpensTop ?? true,
|
|
113
|
+
};
|
|
114
|
+
const instant = options.live?.instant ?? true;
|
|
115
|
+
const indexMax = clampInt(options.live?.indexMax, 1, 20_000, TITLE_INDEX_MAX);
|
|
116
|
+
configureSearchLive({ database, limit: live.limit, indexMax });
|
|
117
|
+
|
|
118
|
+
/** Only collections that exist; asking for nothing real finds nothing rather than everything. Undefined means all. */
|
|
119
|
+
async function knownCollections(list: string | undefined): Promise<string[] | undefined> {
|
|
120
|
+
if (!list) return undefined;
|
|
121
|
+
const requested = [...new Set(list.split(",").map((c) => c.trim()))].filter((c) => COLLECTION_SLUG.test(c)).slice(0, MAX_COLLECTIONS);
|
|
122
|
+
const known = requested.length ? await readCollections(await db(), requested) : new Map();
|
|
123
|
+
return requested.filter((c) => known.has(c));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function db() {
|
|
127
|
+
const env = await workerEnv();
|
|
128
|
+
const d1 = env[database] as D1Database | undefined;
|
|
129
|
+
if (!d1) throw PluginRouteError.badRequest("Search: missing database binding.");
|
|
130
|
+
return d1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const routes = {
|
|
134
|
+
"search/query": definePluginRoute({
|
|
135
|
+
public: true,
|
|
136
|
+
methods: ["GET"],
|
|
137
|
+
request: { body: "none" },
|
|
138
|
+
cacheControl: "public, max-age=60",
|
|
139
|
+
handler: async (ctx) => {
|
|
140
|
+
await requireFeature(ctx, "search.box");
|
|
141
|
+
const input = parseInput(queryInput, ctx.input);
|
|
142
|
+
const collections = await knownCollections(input.collections);
|
|
143
|
+
if (collections && !collections.length) return { items: [], fallback: false };
|
|
144
|
+
return searchWithFallback(input.q, {
|
|
145
|
+
mode: input.mode,
|
|
146
|
+
collections,
|
|
147
|
+
locale: input.locale,
|
|
148
|
+
limit: input.limit ?? 8,
|
|
149
|
+
cursor: input.cursor,
|
|
150
|
+
database,
|
|
151
|
+
});
|
|
152
|
+
},
|
|
153
|
+
}),
|
|
154
|
+
|
|
155
|
+
"search/live": definePluginRoute({
|
|
156
|
+
public: true,
|
|
157
|
+
methods: ["GET"],
|
|
158
|
+
request: { body: "none" },
|
|
159
|
+
// Published content only, the same for every visitor; a minute keeps a burst of identical keystrokes cheap.
|
|
160
|
+
cacheControl: "public, max-age=60",
|
|
161
|
+
handler: async (ctx) => {
|
|
162
|
+
await requireCachedFeature(ctx, "search.live");
|
|
163
|
+
const raw = (ctx.input ?? {}) as Record<string, unknown>;
|
|
164
|
+
const version = await searchVersion(database);
|
|
165
|
+
if (raw.warm === "1") {
|
|
166
|
+
await loadSearchMeta(await db(), database, version).catch(() => undefined);
|
|
167
|
+
return { items: [], fallback: false };
|
|
168
|
+
}
|
|
169
|
+
const input = parseInput(liveInput, raw);
|
|
170
|
+
return liveSearchD1(await db(), normalizeLiveQuery(input.q), {
|
|
171
|
+
collections: normalizeCollections(input.collections),
|
|
172
|
+
locale: normalizeLocale(input.locale),
|
|
173
|
+
limit: input.limit ?? live.limit,
|
|
174
|
+
database,
|
|
175
|
+
version,
|
|
176
|
+
});
|
|
177
|
+
},
|
|
178
|
+
}),
|
|
179
|
+
|
|
180
|
+
"search/index": definePluginRoute({
|
|
181
|
+
public: true,
|
|
182
|
+
methods: ["GET"],
|
|
183
|
+
request: { body: "none" },
|
|
184
|
+
cacheControl: "public, max-age=300",
|
|
185
|
+
handler: async (ctx) => {
|
|
186
|
+
await requireCachedFeature(ctx, "search.live");
|
|
187
|
+
const input = parseInput(indexInput, ctx.input ?? {});
|
|
188
|
+
const version = await searchVersion(database);
|
|
189
|
+
const d1 = await db();
|
|
190
|
+
const meta = await loadSearchMeta(d1, database, version);
|
|
191
|
+
return buildTitleIndex(d1, meta, { collections: normalizeCollections(input.collections), locale: normalizeLocale(input.locale), version, max: indexMax });
|
|
192
|
+
},
|
|
193
|
+
}),
|
|
194
|
+
|
|
195
|
+
"search/touch": definePluginRoute({
|
|
196
|
+
permission: "search:manage",
|
|
197
|
+
methods: ["POST"],
|
|
198
|
+
request: { body: "none" },
|
|
199
|
+
handler: async (ctx) => {
|
|
200
|
+
await requireFeature(ctx, "search");
|
|
201
|
+
return { version: await bumpSearchVersion(ctx) };
|
|
202
|
+
},
|
|
203
|
+
}),
|
|
204
|
+
|
|
205
|
+
"search/config": {
|
|
206
|
+
permission: "search:manage" as const,
|
|
207
|
+
handler: async (ctx: Parameters<typeof requireFeature>[0]) => {
|
|
208
|
+
await requireFeature(ctx, "search.settings");
|
|
209
|
+
const d1 = await db();
|
|
210
|
+
const [collections, fields] = await Promise.all([
|
|
211
|
+
d1.prepare("SELECT id, slug, label, search_config, title_field FROM _emdash_collections ORDER BY label").all<CollectionRow>(),
|
|
212
|
+
d1.prepare("SELECT collection_id, slug, label, type, searchable FROM _emdash_fields ORDER BY sort_order").all<FieldRow>(),
|
|
213
|
+
]);
|
|
214
|
+
return {
|
|
215
|
+
collections: collections.results.map((c) => {
|
|
216
|
+
let config: { enabled?: boolean; weights?: Record<string, number>; tokenize?: string } = {};
|
|
217
|
+
try {
|
|
218
|
+
config = c.search_config ? JSON.parse(c.search_config) : {};
|
|
219
|
+
} catch {
|
|
220
|
+
config = {};
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
slug: c.slug,
|
|
224
|
+
label: c.label,
|
|
225
|
+
enabled: config.enabled === true,
|
|
226
|
+
weights: config.weights ?? {},
|
|
227
|
+
tokenize: config.tokenize ?? "porter unicode61",
|
|
228
|
+
titleField: c.title_field,
|
|
229
|
+
fields: fields.results
|
|
230
|
+
.filter((f) => f.collection_id === c.id)
|
|
231
|
+
.map((f) => ({ slug: f.slug, label: f.label, type: f.type, searchable: f.searchable === 1 })),
|
|
232
|
+
};
|
|
233
|
+
}),
|
|
234
|
+
};
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/** Published content changed: cached live answers and title indexes for the old version retire. */
|
|
240
|
+
const touch = async (ctx: { settings: { set(key: string, value: unknown): Promise<void> } }) => {
|
|
241
|
+
await bumpSearchVersion(ctx);
|
|
242
|
+
};
|
|
243
|
+
const isPublished = (content: Record<string, unknown> | undefined) => !content || content.status === undefined || content.status === "published";
|
|
244
|
+
|
|
245
|
+
const hooks = {
|
|
246
|
+
/**
|
|
247
|
+
* The live results script, on every public page (it attaches only where there's a search form):
|
|
248
|
+
* a deferred, versioned file browsers cache, with this page's config in an attribute, when the
|
|
249
|
+
* pack middleware serves that file; otherwise the same client inline (see liveScriptFragment).
|
|
250
|
+
*/
|
|
251
|
+
"page:fragments": async (event: { page: { locale: string | null } }) =>
|
|
252
|
+
liveScriptFragment({ ...live, indexEndpoint: instant ? INDEX_ENDPOINT : null, version: await searchVersion(database), locale: event.page.locale ?? null }),
|
|
253
|
+
"content:afterSave": (event: { content?: Record<string, unknown> }, ctx: PluginContext) => (isPublished(event.content) ? touch(ctx) : undefined),
|
|
254
|
+
"content:afterPublish": (_event: unknown, ctx: PluginContext) => touch(ctx),
|
|
255
|
+
"content:afterUnpublish": (_event: unknown, ctx: PluginContext) => touch(ctx),
|
|
256
|
+
"content:afterDelete": (_event: unknown, ctx: PluginContext) => touch(ctx),
|
|
257
|
+
"content:afterRestore": (_event: unknown, ctx: PluginContext) => touch(ctx),
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
return { routes, hooks };
|
|
261
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { registerFeatures } from "../core/features.js";
|
|
2
|
+
import type { PackModule } from "../core/module.js";
|
|
3
|
+
import { type SearchOptions, searchModule } from "./module.js";
|
|
4
|
+
import { configureSearchRateLimit } from "./ratelimit.js";
|
|
5
|
+
|
|
6
|
+
export { searchRateLimitMiddleware } from "./ratelimit.js";
|
|
7
|
+
export { searchLiveMiddleware } from "./live-serve.js";
|
|
8
|
+
|
|
9
|
+
export const FEATURES = [
|
|
10
|
+
{
|
|
11
|
+
id: "search",
|
|
12
|
+
label: "Search",
|
|
13
|
+
description: "Tools on top of EmDash's built-in full-text search.",
|
|
14
|
+
default: false,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: "search.settings",
|
|
18
|
+
label: "Search settings",
|
|
19
|
+
description: "An admin page for which collections are searchable, field weights, the tokenizer, and index rebuilds.",
|
|
20
|
+
default: false,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: "search.live",
|
|
24
|
+
label: "Live results",
|
|
25
|
+
description: "Show matching posts in a dropdown as visitors type.",
|
|
26
|
+
// On with Search, including sites that turned Search on before this existed (an unsaved choice takes the default).
|
|
27
|
+
default: true,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "search.box",
|
|
31
|
+
label: "Search box",
|
|
32
|
+
description:
|
|
33
|
+
"The SearchBox component: a search form with a clear button, live results (with Live results on), and results for any of the words when nothing matches all of them.",
|
|
34
|
+
default: false,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: "search.rateLimit",
|
|
38
|
+
label: "Search rate limit",
|
|
39
|
+
description: "Limits how often one visitor can search (120 a minute by default), answering 429 beyond that.",
|
|
40
|
+
default: false,
|
|
41
|
+
},
|
|
42
|
+
];
|
|
43
|
+
registerFeatures(FEATURES);
|
|
44
|
+
|
|
45
|
+
export function searchPack(options: SearchOptions): PackModule {
|
|
46
|
+
configureSearchRateLimit({ requestsPerMinute: options.requestsPerMinute, rateLimiter: options.rateLimiter });
|
|
47
|
+
const module = searchModule(options);
|
|
48
|
+
return {
|
|
49
|
+
id: "search",
|
|
50
|
+
label: "Search",
|
|
51
|
+
features: FEATURES,
|
|
52
|
+
routes: module.routes,
|
|
53
|
+
hooks: module.hooks,
|
|
54
|
+
hookFeature: { "page:fragments": "search.live" },
|
|
55
|
+
adminPages: [{ path: "/search", label: "Search", icon: "magnifying-glass" }],
|
|
56
|
+
};
|
|
57
|
+
}
|